Compare commits

...

7 Commits

Author SHA1 Message Date
Thanh Quy - wolf df1f32c23d feat: enhance login, registration, and payment functionalities
- Added `credentials: "include"` to fetch requests in LoginOtpPage and RegisterPage for cookie handling.
- Introduced `eateryId` in PaymentPage to manage cart context.
- Updated ProductCardProps and ShopCardProps to include `eateryId`.
- Enhanced PaymentSummaryCard to accept `eateryId` prop.
- Modified ShopCard to set `eateryId` in cart context on click.
- Implemented menu item management in MenuItemsTab with add, edit, and delete functionalities.
- Created new API functions for handling reviews and cart operations.
- Updated GraphQL queries and mutations for menu items and cart management.
- Added loading states and error handling in ProductGrid and ReviewModal.
- Enhanced local storage management for cart and eatery ID.
2026-05-08 21:59:18 +07:00
Thanh Quy- wolf 3caa37426e Create account for staff 2026-05-08 19:58:07 +07:00
Thanh Quy - wolf cd2de11da0 Transfer to English 2026-05-06 00:55:06 +07:00
Thanh Quy - wolf 40b673b9bb Truy van menu nha hang
Release package / release (pull_request) Successful in 2m32s
2026-05-05 21:36:48 +07:00
Thanh Quy - wolf 582b21ead4 Quy-dev 2026-04-29 16:37:23 +07:00
Thanh Quy - wolf 4a43299969 fix: enhance OTP sending and error handling in registration flow 2026-04-20 14:42:19 +07:00
Thanh Quy - wolf 4eaed6c973 fix: update registration flow to include user state management 2026-04-19 18:52:33 +07:00
75 changed files with 2527 additions and 1020 deletions
+7 -7
View File
@@ -16,10 +16,10 @@ export default function FeedPage() {
{/* Page title */}
<div className="mb-8">
<h1 className="text-foreground text-2xl font-bold md:text-3xl">
Khám phá quán nước
Explore Shops
</h1>
<p className="mt-1 text-sm text-(--color-text-muted)">
Tìm chọn quán yêu thích của bạn
Find and choose your favorite shop
</p>
</div>
@@ -35,7 +35,7 @@ export default function FeedPage() {
}}
className="cursor-pointer border-none bg-transparent text-sm text-(--color-primary) hover:underline"
>
Xóa bộ lọc
Clear filters
</button>
</div>
)}
@@ -46,7 +46,7 @@ export default function FeedPage() {
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="flex shrink-0 items-center gap-2 text-sm font-semibold text-(--color-text-secondary)">
<i className="fa-solid fa-filter text-(--color-primary)"></i>
<span>Lọc quán</span>
<span>Filter shops</span>
</div>
{/* Name search */}
@@ -54,7 +54,7 @@ export default function FeedPage() {
value={searchName}
onChange={setSearchName}
onClear={() => setSearchName("")}
placeholder="Tìm theo tên quán..."
placeholder="Search by shop name..."
className="min-w-0 flex-1"
/>
@@ -65,13 +65,13 @@ export default function FeedPage() {
type="text"
value={searchAddress}
onChange={(e) => setSearchAddress(e.target.value)}
placeholder="Tìm theo địa chỉ..."
placeholder="Search by address..."
className="bg-background text-foreground focus:ring-opacity-20 w-full rounded-xl border border-(--color-border) py-2.5 pr-9 pl-9 text-sm transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)"
/>
{searchAddress && (
<button
onClick={() => setSearchAddress("")}
aria-label="Xóa tìm kiếm địa ch"
aria-label="Clear address search"
className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent p-0 text-(--color-text-muted) transition-colors duration-150 hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark text-sm"></i>
+174
View File
@@ -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>
);
}
+1 -28
View File
@@ -25,39 +25,12 @@ export default function LoginPage() {
{SHOP_INFO.name}
</h1>
<p className="text-sm text-(--color-text-muted)">
Đăng nhập vào hệ thống
Enter your phone number to sign in
</p>
</div>
{/* Login Form */}
<LoginForm />
{/* Demo Credentials Info */}
<div className="bg-background mt-6 rounded-lg p-4">
<p className="mb-2 text-xs font-semibold text-(--color-text-muted)">
Tài khoản demo:
</p>
<ul className="space-y-1 text-xs text-(--color-text-muted)">
<li>
Quản :{" "}
<code className="rounded bg-white px-1.5 py-0.5">
admin / admin
</code>
</li>
<li>
Nhân viên:{" "}
<code className="rounded bg-white px-1.5 py-0.5">
Nguyễn Văn An / Nguyễn Văn An
</code>
</li>
<li>
Khách hàng:{" "}
<code className="rounded bg-white px-1.5 py-0.5">
0987654321 / user1
</code>
</li>
</ul>
</div>
</div>
</div>
);
+176
View File
@@ -0,0 +1,176 @@
"use client";
import Button from "@/components/atoms/buttons/Button";
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
import { useAuth } from "@/lib/auth-context";
import { SHOP_INFO } from "@/lib/constants";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { FormEvent, useEffect, useState } from "react";
export default function LoginPasswordPage() {
const router = useRouter();
const { setUser } = useAuth();
const [phone, setPhone] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState({ password: "", general: "" });
useEffect(() => {
const storedPhone = sessionStorage.getItem("login_phone");
const storedRole = sessionStorage.getItem("login_role");
if (!storedPhone || storedRole !== "manager") {
router.replace("/login");
return;
}
setPhone(storedPhone);
}, [router]);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!password.trim()) {
setErrors({ password: "Please enter your password", general: "" });
return;
}
setIsLoading(true);
setErrors({ password: "", general: "" });
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone, password, role: "manager" }),
});
if (res.ok) {
const userData = await res.json();
setUser(userData);
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
sessionStorage.removeItem("login_phone");
sessionStorage.removeItem("login_role");
router.push("/manager");
} else {
const STATUS_ERROR_MAP: Record<number, string> = {
400: "Incorrect login details, please try again",
401: "Incorrect password",
403: "Account is locked",
404: "Account does not exist",
};
const msg =
STATUS_ERROR_MAP[res.status] ??
(res.status >= 500
? "Server error, please try again later"
: "An error occurred, please try again");
setErrors({ password: "", general: msg });
}
} catch {
setErrors({ password: "", general: "Unable to connect, please try again" });
} finally {
setIsLoading(false);
}
};
if (!phone) return null;
return (
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
{/* Logo & Shop Name */}
<div className="mb-8 flex flex-col items-center">
<div className="relative mb-4 h-20 w-20">
<Image
src={SHOP_INFO.logo}
alt={SHOP_INFO.name}
fill
className="object-contain"
sizes="80px"
priority
/>
</div>
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
{SHOP_INFO.name}
</h1>
<p className="text-sm text-(--color-text-muted)">Manager sign in</p>
</div>
{errors.general && <ErrorMessageLogin message={errors.general} />}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Phone display */}
<div className="rounded-lg border border-(--color-border) bg-(--color-background) px-4 py-3 text-sm text-(--color-text-secondary)">
<i className="fa-solid fa-phone mr-2 text-(--color-text-muted)"></i>
{phone}
</div>
{/* Password Input */}
<div>
<label
htmlFor="password"
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
Password
</label>
<div className="relative">
<i className="fa-solid fa-lock absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
<input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => {
setPassword(e.target.value);
setErrors({ password: "", general: "" });
}}
placeholder="Enter password"
disabled={isLoading}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors.password ? "border-red-400" : "border-(--color-border)"}`}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute top-1/2 right-4 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
aria-label={showPassword ? "Hide password" : "Show password"}
>
<i className={`fa-solid ${showPassword ? "fa-eye-slash" : "fa-eye"}`}></i>
</button>
</div>
{errors.password && (
<ErrorMessageLogin message={errors.password} type="secondary" />
)}
</div>
{/* Buttons */}
<div className="space-y-3 pt-2">
<Button
variant="primaryNoBorder"
type="submit"
style="login"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Sign in"
)}
</Button>
<button
type="button"
onClick={() => router.push("/login")}
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
>
Change phone number
</button>
</div>
</form>
</div>
</div>
);
}
+196
View File
@@ -0,0 +1,196 @@
"use client";
import Button from "@/components/atoms/buttons/Button";
import { SHOP_INFO } from "@/lib/constants";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FormEvent, useEffect, useState } from "react";
type PageState = "checking" | "available" | "closed" | "error";
export default function ManagerSignupPage() {
const router = useRouter();
const [pageState, setPageState] = useState<PageState>("checking");
const [isLoading, setIsLoading] = useState(false);
const [form, setForm] = useState({ name: "", phone: "", password: "", eateryName: "" });
const [errors, setErrors] = useState({ name: "", phone: "", password: "", eateryName: "", submit: "" });
useEffect(() => {
fetch("/api/manager/signup")
.then((res) => {
setPageState("available")
// if (res.ok) ;
// else if (res.status === 403) setPageState("closed");
// else setPageState("error");
})
.catch(() => setPageState("error"));
}, []);
const validatePhone = (phone: string) => /^(0[3|5|7|8|9])[0-9]{8}$/.test(phone);
const handleChange = (field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, [field]: e.target.value });
setErrors({ ...errors, [field]: "", submit: "" });
};
const validate = () => {
const next = { name: "", phone: "", password: "", eateryName: "", submit: "" };
if (!form.name.trim()) next.name = "Please enter your full name";
if (!form.phone.trim()) next.phone = "Please enter your phone number";
else if (!validatePhone(form.phone)) next.phone = "Invalid phone number (e.g. 0987654321)";
if (!form.password.trim()) next.password = "Please enter your password";
else if (form.password.length < 6) next.password = "Password must be at least 6 characters";
if (!form.eateryName.trim()) next.eateryName = "Please enter the restaurant name";
setErrors(next);
return !next.name && !next.phone && !next.password && !next.eateryName;
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!validate()) return;
setIsLoading(true);
try {
const res = await fetch("/api/manager/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (res.ok || res.status === 201) {
router.push("/login");
} else {
const errorCode = (await res.text().catch(() => "")).trim();
const errorMap: Record<string, string> = {
ExistedUser: "Phone number already registered",
InvalidPhoneNumber: "Invalid phone number",
};
setErrors({ ...errors, submit: errorMap[errorCode] ?? "An error occurred, please try again" });
}
} catch {
setErrors({ ...errors, submit: "Unable to connect, please try again" });
} finally {
setIsLoading(false);
}
};
return (
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
{/* Logo */}
<div className="mb-8 flex flex-col items-center">
<div className="relative mb-4 h-20 w-20">
<Image src={SHOP_INFO.logo} alt={SHOP_INFO.name} fill className="object-contain" sizes="80px" priority />
</div>
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">{SHOP_INFO.name}</h1>
<p className="text-sm text-(--color-text-muted)">Create a manager account</p>
</div>
{/* Checking */}
{pageState === "checking" && (
<div className="flex flex-col items-center gap-3 py-8 text-(--color-text-muted)">
<i className="fa-solid fa-spinner fa-spin text-2xl text-(--color-primary)"></i>
<p className="text-sm">Checking...</p>
</div>
)}
{/* Closed */}
{pageState === "closed" && (
<div className="flex flex-col items-center gap-4 py-6">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
<i className="fa-solid fa-lock text-2xl text-red-500"></i>
</div>
<div className="text-center">
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">Registration closed</h2>
<p className="text-sm text-(--color-text-muted)">The system already has a restaurant. Registration is no longer available.</p>
</div>
<Link
href="/login"
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white"
>
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
View File
@@ -38,7 +38,7 @@ export default function Home() {
}, [activeCategory]);
const activeCategoryLabel =
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả";
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "All";
return (
<div className="bg-background flex min-h-[calc(100vh-var(--spacing-header-height))] items-start">
@@ -69,7 +69,7 @@ export default function Home() {
setSearchQuery(q);
}}
onClear={() => setSearchQuery("")}
placeholder="Tìm kiếm món..."
placeholder="Search items..."
className="sm:max-w-xs"
/>
</div>
+15 -13
View File
@@ -8,12 +8,13 @@ import { useCart } from "@/lib/cart-context";
import { useState } from "react";
const formatPrice = (value: number) =>
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
value.toLocaleString("en-US", { style: "currency", currency: "VND" });
export default function PaymentPage() {
const {
items,
totalPrice,
eateryId,
increaseQty,
decreaseQty,
removeFromCart,
@@ -32,13 +33,13 @@ export default function PaymentPage() {
<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">
<h1 className="text-foreground text-lg font-bold md:text-xl">
Trang thanh toán
Payment page
</h1>
</div>
{items.length === 0 ? (
<div className="px-4 py-10 text-center text-(--color-text-muted)">
Chưa sản phẩm nào trong giỏ hàng.
There are no products in the shopping cart yet.
</div>
) : (
<div className="overflow-x-auto">
@@ -46,22 +47,22 @@ export default function PaymentPage() {
<thead>
<tr className="bg-(--color-border-light)/40 text-left">
<th scope="col" className="px-4 py-3 font-semibold">
Tên sản phẩm
Product name
</th>
<th scope="col" className="px-4 py-3 font-semibold">
Giá tiền
Price
</th>
<th scope="col" className="px-4 py-3 font-semibold">
tả
Description
</th>
<th scope="col" className="px-4 py-3 font-semibold">
Số lượng
Quantity
</th>
<th
scope="col"
className="px-4 py-3 text-right font-semibold"
>
Xóa
Delete
</th>
</tr>
</thead>
@@ -85,7 +86,7 @@ export default function PaymentPage() {
<button
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)"
aria-label={`Giảm số lượng ${item.name}`}
aria-label={`Decrease quantity of ${item.name}`}
>
-
</button>
@@ -97,12 +98,12 @@ export default function PaymentPage() {
setQuantity(item.id, Number(e.target.value))
}
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
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)"
aria-label={`Tăng số lượng ${item.name}`}
aria-label={`Increase quantity of ${item.name}`}
>
+
</button>
@@ -114,9 +115,9 @@ export default function PaymentPage() {
variant="danger"
size="md"
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>
</td>
</tr>
@@ -132,6 +133,7 @@ export default function PaymentPage() {
totalPrice={totalPrice}
isCustomer={isCustomer}
backHref="/"
eateryId={eateryId ?? undefined}
/>
</div>
</div>
+147 -32
View File
@@ -6,19 +6,48 @@ import { SHOP_INFO } from "@/lib/constants";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { SubmitEvent, useState } from "react";
import { FormEvent, useEffect, useState } from "react";
// Static OTP for demo (in production, this would be sent via SMS)
const DEMO_OTP = "123456";
export default function RegisterPage() {
const router = useRouter();
const { completeRegistration } = useAuth();
const { setUser } = useAuth();
const [step, setStep] = useState<"phone" | "otp">("phone");
const [phone, setPhone] = useState("");
const [otp, setOtp] = useState("");
const [errors, setErrors] = useState({ phone: "", otp: "" });
const [isLoading, setIsLoading] = useState(false);
const [otpSending, setOtpSending] = useState(false);
const [otpSendError, setOtpSendError] = useState("");
const sendOtp = async () => {
setOtpSending(true);
setOtpSendError("");
try {
const res = await fetch("/api/sms_otp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone }),
});
if (!res.ok) {
setOtpSendError("Unable to send OTP, please try again");
}
} catch {
setOtpSendError("Unable to connect, please try again");
} finally {
setOtpSending(false);
}
};
useEffect(() => {
if (step === "otp") {
sendOtp();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
// Validate Vietnamese phone number
const validatePhone = (phoneNumber: string): boolean => {
@@ -28,43 +57,87 @@ export default function RegisterPage() {
return phoneRegex.test(phoneNumber);
};
const handlePhoneSubmit = (e: SubmitEvent<HTMLFormElement>) => {
const handlePhoneSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!phone.trim()) {
setErrors({ ...errors, phone: "Vui lòng nhập số điện thoại" });
setErrors({ ...errors, phone: "Please enter your phone number" });
return;
}
if (!validatePhone(phone)) {
setErrors({
...errors,
phone: "Số điện thoại không hợp lệ (VD: 0987654321)",
phone: "Invalid phone number (e.g. 0987654321)",
});
return;
}
// Move to OTP step
setStep("otp");
setIsLoading(true);
setErrors({ phone: "", otp: "" });
try {
const res = await fetch("/api/customer/quick_signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone }),
});
if (res.ok) {
setStep("otp");
} else {
const errorCode = (await res.text().catch(() => "")).trim();
const phoneErrorMap: Record<string, string> = {
ExistedUser: "Phone number already registered",
InvalidPhoneNumber: "Invalid phone number",
};
const msg = phoneErrorMap[errorCode] ?? "An error occurred, please try again";
setErrors({ phone: msg, otp: "" });
}
} catch {
setErrors({ phone: "Unable to connect, please try again", otp: "" });
} finally {
setIsLoading(false);
}
};
const handleOtpSubmit = (e: SubmitEvent<HTMLFormElement>) => {
const handleOtpSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!otp.trim()) {
setErrors({ ...errors, otp: "Vui lòng nhập mã OTP" });
setErrors({ ...errors, otp: "Please enter your OTP code" });
return;
}
if (otp !== DEMO_OTP) {
setErrors({ ...errors, otp: "Mã OTP không đúng" });
return;
}
setIsLoading(true);
setErrors({ phone: "", otp: "" });
// Complete registration
completeRegistration(phone);
try {
const res = await fetch("/api/customer/quick_login", {
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 = () => {
@@ -94,8 +167,8 @@ export default function RegisterPage() {
</h1>
<p className="text-sm text-(--color-text-muted)">
{step === "phone"
? "Đăng ký tài khoản khách hàng"
: "Xác thực số điện thoại"}
? "Create a customer account"
: "Verify your phone number"}
</p>
</div>
@@ -131,7 +204,7 @@ export default function RegisterPage() {
htmlFor="phone"
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
Số điện thoại
Phone number
</label>
<div className="relative">
<i className="fa-solid fa-phone absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
@@ -144,7 +217,8 @@ export default function RegisterPage() {
setErrors({ ...errors, phone: "" });
}}
placeholder="0987654321"
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors.phone ? "border-red-400" : "border-(--color-border)"} `}
disabled={isLoading}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors.phone ? "border-red-400" : "border-(--color-border)"} `}
/>
</div>
{errors.phone && (
@@ -154,7 +228,7 @@ export default function RegisterPage() {
</p>
)}
<p className="mt-2 text-xs text-(--color-text-muted)">
Nhập số điện thoại Việt Nam (10 số, bắt đu bằng 0)
Enter a Vietnamese phone number (10 digits, starting with 0)
</p>
</div>
@@ -166,8 +240,16 @@ export default function RegisterPage() {
type="submit"
style="login"
size="lg"
disabled={isLoading}
>
Tiếp tục
{isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Continue"
)}
</Button>
{/* Back to Login */}
@@ -175,7 +257,7 @@ export default function RegisterPage() {
href="/login"
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
>
Quay lại đăng nhập
Back to sign in
</Link>
</div>
</form>
@@ -188,7 +270,16 @@ export default function RegisterPage() {
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<p className="mb-2 text-sm text-blue-800">
<i className="fa-solid fa-circle-info mr-2"></i>
OTP đã đưc gửi đến số <strong>{phone}</strong>
{otpSending ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-1"></i>
Sending OTP to <strong>{phone}</strong>...
</>
) : (
<>
OTP code sent to <strong>{phone}</strong>
</>
)}
</p>
<p className="text-xs text-blue-600">
Demo OTP:{" "}
@@ -197,6 +288,12 @@ export default function RegisterPage() {
</code>
</p>
</div>
{otpSendError && (
<p className="flex items-center gap-1 text-xs text-red-500">
<i className="fa-solid fa-circle-exclamation"></i>
{otpSendError}
</p>
)}
{/* OTP Input */}
<div>
@@ -204,7 +301,7 @@ export default function RegisterPage() {
htmlFor="otp"
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
OTP
OTP code
</label>
<div className="relative">
<i className="fa-solid fa-key absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
@@ -216,9 +313,10 @@ export default function RegisterPage() {
setOtp(e.target.value);
setErrors({ ...errors, otp: "" });
}}
placeholder="Nhập mã OTP"
placeholder="Enter OTP code"
maxLength={6}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 text-center text-lg tracking-widest transition-all duration-150 outline-none placeholder:text-sm placeholder:tracking-normal placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors.otp ? "border-red-400" : "border-(--color-border)"} `}
disabled={isLoading}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 text-center text-lg tracking-widest transition-all duration-150 outline-none placeholder:text-sm placeholder:tracking-normal placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors.otp ? "border-red-400" : "border-(--color-border)"} `}
/>
</div>
{errors.otp && (
@@ -237,8 +335,16 @@ export default function RegisterPage() {
type="submit"
style="login"
size="lg"
disabled={isLoading || otpSending}
>
Hoàn tất đăng
{isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Complete registration"
)}
</Button>
{/* Back Button */}
@@ -247,19 +353,28 @@ export default function RegisterPage() {
onClick={handleBackToPhone}
size="lg"
style="login"
disabled={isLoading}
>
Thay đi số điện thoại
Change phone number
</Button>
</div>
{/* Resend OTP (disabled in demo) */}
{/* Resend OTP */}
<div className="text-center">
<button
type="button"
disabled
className="cursor-not-allowed text-sm text-(--color-text-muted)"
onClick={sendOtp}
disabled={otpSending || isLoading}
className="text-sm text-(--color-primary) underline disabled:cursor-not-allowed disabled:opacity-50 disabled:no-underline"
>
Gửi lại OTP (60s)
{otpSending ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-1"></i>
Resending...
</>
) : (
"Resend OTP code"
)}
</button>
</div>
</form>
+32 -32
View File
@@ -28,10 +28,10 @@ import { useMemo, useState } from "react";
// ─── Constants ────────────────────────────────────────────────────────────────
const PERIOD_LABELS: Record<AnalyticsPeriod, string> = {
day: "Theo ngày",
week: "Theo tuần",
month: "Theo tháng",
year: "Theo năm",
day: "By day",
week: "By week",
month: "By month",
year: "By year",
};
const CATEGORY_COLORS = [
@@ -67,7 +67,7 @@ const CHART_META: Record<ChartType, { icon: string; label: string }> = {
function CategorySelect({
value,
onChange,
label = "Danh mục:",
label = "Category:",
}: {
value: string;
onChange: (v: string) => void;
@@ -78,12 +78,12 @@ function CategorySelect({
<div className="flex items-center gap-2">
<label className="text-xs text-(--color-text-muted)">{label}</label>
<select
title="Danh mục"
title="Category"
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-background text-foreground rounded-lg border border-(--color-border) px-2 py-1.5 text-xs"
>
<option value="all">Tất cả</option>
<option value="all">All</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
@@ -181,7 +181,7 @@ export default function AnalyticsPage() {
</span>
<div>
<h1 className="text-foreground text-lg leading-tight font-bold">
Thống & Phân tích tài chính
Financial Statistics & Analytics
</h1>
<p className="text-xs text-(--color-text-muted)">
Financial Analytics Dashboard
@@ -205,7 +205,7 @@ export default function AnalyticsPage() {
</button>
))}
<select
title="Chọn kỳ"
title="Select period"
value={period}
onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)}
className="text-foreground block rounded-lg border border-(--color-border) bg-(--color-bg-card) px-2 py-1.5 text-xs sm:hidden"
@@ -226,12 +226,12 @@ export default function AnalyticsPage() {
{/* ── Summary Cards ── */}
<section>
<h2 className="mb-3 text-sm font-semibold tracking-wider text-(--color-text-muted) uppercase">
Tổng quan
Overview
</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<SummaryCard
icon="fa-solid fa-sack-dollar"
title="Tổng doanh thu"
title="Total Revenue"
value={formatCurrency(totalRevenue)}
subtitle={PERIOD_LABELS[period]}
change={revComp.change}
@@ -240,27 +240,27 @@ export default function AnalyticsPage() {
/>
<SummaryCard
icon="fa-solid fa-receipt"
title="Số đơn hàng"
title="Total Orders"
value={totalOrders.toLocaleString()}
subtitle="Tổng đơn trong kỳ"
subtitle="Total orders in period"
change={ordComp.change}
changePercent={ordComp.changePercent}
isPositive={ordComp.isPositive}
/>
<SummaryCard
icon="fa-solid fa-circle-dollar-to-slot"
title="Tổng lợi nhuận"
title="Total Profit"
value={formatCurrency(totalProfit)}
subtitle="Ước tính từ dữ liệu bán hàng"
subtitle="Estimated from sales data"
change={proComp.change}
changePercent={proComp.changePercent}
isPositive={proComp.isPositive}
/>
<SummaryCard
icon="fa-solid fa-basket-shopping"
title="Giá trị đơn TB"
title="Avg. Order Value"
value={formatCurrency(avgOrderValue)}
subtitle="Doanh thu / số đơn hàng"
subtitle="Revenue / number of orders"
change={0}
changePercent={0}
isPositive={true}
@@ -273,7 +273,7 @@ export default function AnalyticsPage() {
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-foreground text-base font-semibold">
<i className="fa-solid fa-chart-area mr-2 text-(--color-primary)"></i>
Biểu đ doanh thu
Revenue Chart
</h2>
<div className="flex gap-2">
{CHART_TYPES.map((t) => (
@@ -298,7 +298,7 @@ export default function AnalyticsPage() {
{activeChart === "line" && (
<>
<p className="mb-3 text-xs text-(--color-text-muted)">
Doanh thu theo thời gian {PERIOD_LABELS[period]}
Revenue over time {PERIOD_LABELS[period]}
</p>
<LineChart data={revenueData} height={220} />
</>
@@ -306,7 +306,7 @@ export default function AnalyticsPage() {
{activeChart === "bar" && (
<>
<p className="mb-3 text-xs text-(--color-text-muted)">
So sánh doanh thu nửa đu nửa sau kỳ hiện tại
Comparing revenue of the first and second half of the current period
</p>
<BarChart
current={barCurrent}
@@ -318,7 +318,7 @@ export default function AnalyticsPage() {
{activeChart === "pie" && (
<>
<p className="mb-3 text-xs text-(--color-text-muted)">
Tỷ trọng doanh thu theo danh mục sản phẩm
Revenue share by product category
</p>
<PieChart data={pieData} />
</>
@@ -330,7 +330,7 @@ export default function AnalyticsPage() {
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-foreground text-base font-semibold">
<i className="fa-solid fa-fire mr-2 text-orange-500"></i>
Top sản phẩm bán chạy
Top best-selling products
</h2>
<CategorySelect
value={categoryFilter}
@@ -353,7 +353,7 @@ export default function AnalyticsPage() {
</div>
<div className="flex shrink-0 items-center gap-3 text-xs">
<span className="text-(--color-text-muted) tabular-nums">
{p.unitsSold} ly
{p.unitsSold} cups
</span>
<span className="font-semibold text-(--color-primary) tabular-nums">
{formatCurrency(p.revenue)}
@@ -377,17 +377,17 @@ export default function AnalyticsPage() {
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-foreground text-base font-semibold">
<i className="fa-solid fa-table text-foreground mr-2"></i>
Phân tích chi tiết sản phẩm
Detailed product analytics
</h2>
<CategorySelect
value={categoryFilter}
onChange={setCategoryFilter}
label="Lọc danh mục:"
label="Filter category:"
/>
</div>
<p className="mb-3 text-xs text-(--color-text-muted)">
Click vào tiêu đ cột đ sắp xếp. Hiển thị {filteredSales.length}{" "}
sản phẩm.
Click column headers to sort. Showing {filteredSales.length}{" "}
products.
</p>
<ProductTable data={filteredSales} />
@@ -395,7 +395,7 @@ export default function AnalyticsPage() {
<div className="bg-background mt-4 flex flex-wrap gap-4 rounded-xl p-4 text-sm">
<div>
<span className="text-(--color-text-muted)">
Tổng doanh thu:{" "}
Total revenue:{" "}
</span>
<span className="font-semibold text-(--color-primary)">
{formatCurrencyFull(filteredRevenue)}
@@ -403,7 +403,7 @@ export default function AnalyticsPage() {
</div>
<div>
<span className="text-(--color-text-muted)">
Tổng lợi nhuận:{" "}
Total profit:{" "}
</span>
<span className="font-semibold text-green-600">
{formatCurrencyFull(filteredProfit)}
@@ -411,15 +411,15 @@ export default function AnalyticsPage() {
</div>
<div>
<span className="text-(--color-text-muted)">
Tổng sản lượng:{" "}
Total units:{" "}
</span>
<span className="text-foreground font-semibold">
{filteredUnits.toLocaleString()} ly
{filteredUnits.toLocaleString()} cups
</span>
</div>
<div>
<span className="text-(--color-text-muted)">
Biên LN trung bình:{" "}
Avg. profit margin:{" "}
</span>
<span className="font-semibold text-yellow-700">
{avgMargin.toFixed(1)}%
+170
View File
@@ -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>
);
}
+226 -24
View File
@@ -3,21 +3,24 @@
import {
CategoriesTab,
CombosTab,
MenuItemsTab,
ProductsTab,
} from "@/components/organisms/manager";
import { useAuth } from "@/lib/auth-context";
import { useManager } from "@/lib/manager-context";
import Link from "next/link";
import { useState } from "react";
export default function ManagerPage() {
const { user, logout } = useAuth();
const { activeTab, setActiveTab, products, combos, categories } =
useManager();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const tabs = [
{
id: "products" as const,
label: "Thực đơn",
label: "Menu",
icon: "fa-solid fa-utensils",
count: products.length,
},
@@ -29,10 +32,16 @@ export default function ManagerPage() {
},
{
id: "categories" as const,
label: "Danh mục",
label: "Categories",
icon: "fa-solid fa-tags",
count: categories.length,
},
{
id: "menu-items" as const,
label: "Menu",
icon: "fa-solid fa-bowl-food",
count: null,
},
];
return (
@@ -51,7 +60,7 @@ export default function ManagerPage() {
<nav className="flex-1 space-y-1 p-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Quản thực đơn
Menu Management
</p>
{tabs.map((tab) => (
<button
@@ -65,6 +74,7 @@ export default function ManagerPage() {
>
<i className={`${tab.icon} w-4 text-center`}></i>
<span className="flex-1 text-left">{tab.label}</span>
{tab.count !== null && (
<span
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
activeTab === tab.id
@@ -74,20 +84,21 @@ export default function ManagerPage() {
>
{tab.count}
</span>
)}
</button>
))}
<div className="mt-3 border-t border-(--color-border-light) pt-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Phân tích
Analytics
</p>
<Link
href="/manager/analytics"
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark)"
>
<i className="fa-solid fa-chart-line w-4 text-center"></i>
<span className="flex-1 text-left">Tài chính</span>
<span className="flex-1 text-left">Finance</span>
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)">
Mới
New
</span>
</Link>
<Link
@@ -95,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)"
>
<i className="fa-solid fa-calendar-days w-4 text-center"></i>
<span className="flex-1 text-left">Ca làm</span>
<span className="flex-1 text-left">Shifts</span>
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)">
Mới
New
</span>
</Link>
</div>
<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>
<div className="border-t border-(--color-border-light) p-3">
@@ -110,9 +133,9 @@ export default function ManagerPage() {
</div>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-semibold">
{user?.name ?? "Quản lý"}
{user?.name ?? "Manager"}
</p>
<p className="text-xs text-(--color-text-muted)">Quản quán</p>
<p className="text-xs text-(--color-text-muted)">Store Manager</p>
</div>
</div>
<div className="mt-1 flex gap-2 px-1">
@@ -121,14 +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"
>
<i className="fa-solid fa-house"></i>
Trang chủ
Home
</Link>
<button
onClick={logout}
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
>
<i className="fa-solid fa-right-from-bracket"></i>
Đăng xuất
Logout
</button>
</div>
</div>
@@ -139,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">
<div>
<h1 className="text-foreground text-lg font-bold">
{tabs.find((t) => t.id === activeTab)?.label ?? "Quản lý"}
{tabs.find((t) => t.id === activeTab)?.label ?? "Manager"}
</h1>
<p className="text-xs text-(--color-text-muted)">
Quản {" "}
Manage{" "}
{activeTab === "products"
? "thực đơn"
? "menu items"
: activeTab === "combos"
? "combo"
: "danh mục"}{" "}
của quán
? "combos"
: activeTab === "categories"
? "categories"
: "menu"}{" "}
for your store
</p>
</div>
{/* Mobile tabs */}
<div className="flex items-center gap-1 lg:hidden">
{/* smlg: icon row */}
<div className="hidden items-center gap-1 sm:flex lg:hidden">
{tabs.map((tab) => (
<button
key={tab.id}
@@ -165,7 +190,7 @@ export default function ManagerPage() {
}`}
>
<i className={tab.icon}></i>
<span className="hidden sm:inline">{tab.label}</span>
<span>{tab.label}</span>
</button>
))}
<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"
>
<i className="fa-solid fa-chart-line"></i>
<span className="hidden sm:inline">Tài chính</span>
<span>Finance</span>
</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 (smlg) */}
<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>
{/* 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"
>
<i className="fa-solid fa-chart-line"></i>
Thống 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
href="/"
className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
>
<i className="fa-solid fa-house"></i>
Trang chủ
Home
</Link>
</div>
</header>
@@ -200,6 +401,7 @@ export default function ManagerPage() {
{activeTab === "products" && <ProductsTab />}
{activeTab === "combos" && <CombosTab />}
{activeTab === "categories" && <CategoriesTab />}
{activeTab === "menu-items" && <MenuItemsTab />}
</main>
</div>
</div>
+34 -34
View File
@@ -12,18 +12,18 @@ import Link from "next/link";
import { useState } from "react";
const MONTH_NAMES = [
"Tháng 1",
"Tháng 2",
"Tháng 3",
"Tháng 4",
"Tháng 5",
"Tháng 6",
"Tháng 7",
"Tháng 8",
"Tháng 9",
"Tháng 10",
"Tháng 11",
"Tháng 12",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
function getMonday(d: Date): Date {
@@ -95,7 +95,7 @@ export default function StaffSchedulePage() {
<i className="fa-solid fa-calendar-days text-sm text-white"></i>
</div>
<div>
<p className="text-foreground text-sm font-bold">Lịch làm việc</p>
<p className="text-foreground text-sm font-bold">Work Schedule</p>
<p className="text-xs text-(--color-text-muted)">
{isManager ? "Manager" : "Staff"}
</p>
@@ -105,7 +105,7 @@ export default function StaffSchedulePage() {
{/* View toggle */}
<nav className="flex-1 space-y-1 p-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Chế đ xem
View
</p>
<button
type="button"
@@ -117,7 +117,7 @@ export default function StaffSchedulePage() {
}`}
>
<i className="fa-solid fa-table-columns w-4 text-center"></i>
<span className="flex-1 text-left">Theo tuần</span>
<span className="flex-1 text-left">Weekly</span>
</button>
<button
type="button"
@@ -129,13 +129,13 @@ export default function StaffSchedulePage() {
}`}
>
<i className="fa-solid fa-calendar w-4 text-center"></i>
<span className="flex-1 text-left">Theo tháng</span>
<span className="flex-1 text-left">Monthly</span>
</button>
{/* Quick nav */}
<div className="mt-3 border-t border-(--color-border-light) pt-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Điều hướng
Navigation
</p>
<button
type="button"
@@ -143,7 +143,7 @@ export default function StaffSchedulePage() {
className="hover:bg-background flex w-full cursor-pointer items-center gap-3 rounded-xl border-none bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) transition-all hover:text-(--color-primary-dark)"
>
<i className="fa-solid fa-crosshairs w-4 text-center"></i>
<span className="flex-1 text-left">Hôm nay</span>
<span className="flex-1 text-left">Today</span>
</button>
</div>
@@ -151,7 +151,7 @@ export default function StaffSchedulePage() {
{isManager && (
<div className="mt-3 border-t border-(--color-border-light) pt-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Quản
Management
</p>
<Link
href="/manager"
@@ -167,7 +167,7 @@ export default function StaffSchedulePage() {
<div className="mt-3 border-t border-(--color-border-light) pt-3">
<div className="rounded-xl bg-(--color-primary)/5 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Ngân sách tuần
Weekly Budget
</p>
<p className="mt-1 text-lg font-bold text-(--color-primary)">
{weeklyBudget.toLocaleString("vi-VN")}
@@ -187,10 +187,10 @@ export default function StaffSchedulePage() {
</div>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-semibold">
{user?.name ?? "Nhân viên"}
{user?.name ?? "Staff"}
</p>
<p className="text-xs text-(--color-text-muted)">
{isManager ? "Quản lý" : "Nhân viên"}
{isManager ? "Manager" : "Staff"}
</p>
</div>
</div>
@@ -200,7 +200,7 @@ export default function StaffSchedulePage() {
className="hover:bg-background flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
>
<i className="fa-solid fa-house"></i>
Trang chủ
Home
</Link>
<button
type="button"
@@ -208,7 +208,7 @@ export default function StaffSchedulePage() {
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
>
<i className="fa-solid fa-right-from-bracket"></i>
Đăng xuất
Logout
</button>
</div>
</div>
@@ -220,7 +220,7 @@ export default function StaffSchedulePage() {
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-(--color-border-light) bg-white px-4 py-3 shadow-sm md:px-5 md:py-4">
<div>
<h1 className="text-foreground text-base font-bold md:text-lg">
Đăng ca làm
Register Shift
</h1>
<p className="text-xs text-(--color-text-muted)">
{view === "week"
@@ -241,7 +241,7 @@ export default function StaffSchedulePage() {
: "bg-transparent text-(--color-text-secondary)"
}`}
>
Tuần
Week
</button>
<button
type="button"
@@ -252,14 +252,14 @@ export default function StaffSchedulePage() {
: "bg-transparent text-(--color-text-secondary)"
}`}
>
Tháng
Month
</button>
</div>
{/* Navigation arrows */}
<div className="hidden items-center gap-1 md:flex">
<button
title="Về trước"
title="Previous"
type="button"
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:bg-gray-50"
@@ -271,10 +271,10 @@ export default function StaffSchedulePage() {
onClick={goToToday}
className="cursor-pointer rounded-lg border border-(--color-border-light) bg-transparent px-3 py-1.5 text-xs font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
>
Hôm nay
Today
</button>
<button
title="Tiếp theo"
title="Next"
type="button"
onClick={view === "week" ? goToNextWeek : goToNextMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:bg-gray-50"
@@ -294,14 +294,14 @@ export default function StaffSchedulePage() {
className="hidden cursor-pointer items-center gap-1.5 rounded-xl border-none bg-(--color-primary) px-3 py-2 text-xs font-semibold text-white transition hover:opacity-90 md:flex"
>
<i className="fa-solid fa-plus"></i>
Tạo ca
Create Shift
</button>
)}
{/* Mobile nav */}
<div className="flex items-center gap-1 md:hidden">
<button
title="Về trước"
title="Previous"
type="button"
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
@@ -309,7 +309,7 @@ export default function StaffSchedulePage() {
<i className="fa-solid fa-chevron-left text-xs"></i>
</button>
<button
title="Tiếp theo"
title="Next"
type="button"
onClick={view === "week" ? goToNextWeek : goToNextMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
@@ -353,7 +353,7 @@ export default function StaffSchedulePage() {
{/* Mobile FAB for manager */}
{isManager && (
<button
title="Tạo ca"
title="Create Shift"
type="button"
onClick={() => {
setCreateDate(undefined);
+3 -3
View File
@@ -18,8 +18,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Coffee Shop — Hệ thống đặt món",
description: "Đặt món cà phê, trà, nước ép và nhiều hơn nữa tại Coffee Shop.",
title: "Coffee Shop — Order System",
description: "Order coffee, tea, juice and more at Coffee Shop.",
icons: {
icon: "/favicon/favicon.ico",
shortcut: "/favicon/favicon.ico",
@@ -33,7 +33,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="vi">
<html lang="en">
<head>
{/* FontAwesome 6 — icons used throughout the app */}
<link
-31
View File
@@ -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>
);
}
-7
View File
@@ -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;
}
-31
View File
@@ -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>
);
}
-4
View File
@@ -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";
-46
View File
@@ -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>
);
}
+2 -6
View File
@@ -3,17 +3,13 @@ export { Button } from "./buttons";
export type { ButtonProps } from "./buttons";
// Inputs
export { TextInput, SearchInput, Textarea } from "./inputs";
export type { TextInputProps, SearchInputProps, TextareaProps } from "./inputs";
export { TextInput, Textarea } from "./inputs";
export type { TextInputProps, TextareaProps } from "./inputs";
// Typography
export { Heading, Text, Caption } from "./typography";
export type { HeadingProps, TextProps, CaptionProps } from "./typography";
// Badges
export { Badge, PriceBadge } from "./badges";
export type { BadgeProps } from "./badges";
// Dividers
export { Divider } from "./dividers";
export type { DividerProps } from "./dividers";
-14
View File
@@ -13,17 +13,3 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
error?: string;
className?: string;
}
export interface SearchInputProps extends InputHTMLAttributes<HTMLInputElement> {
onClear?: () => void;
className?: string;
}
export interface LoginInputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
type: string;
name: string;
value: string;
errors?: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
-60
View File
@@ -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>
);
}
-35
View File
@@ -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 -7
View File
@@ -1,9 +1,3 @@
export { default as TextInput } from "./TextInput";
export { default as SearchInput } from "./SearchInput";
export { default as Textarea } from "./Textarea";
export type {
TextInputProps,
SearchInputProps,
TextareaProps,
LoginInputProps,
} from "./Input.types";
export type { TextInputProps, TextareaProps } from "./Input.types";
+2
View File
@@ -11,6 +11,7 @@ export interface ProductCardProps {
export interface ShopCardProps {
id: number;
eateryId: string;
name: string;
address: string;
image: string;
@@ -21,5 +22,6 @@ export interface PaymentSummaryCardProps {
totalPrice: number;
isCustomer: boolean;
backHref: string;
eateryId?: string;
onPay?: () => void;
}
@@ -12,6 +12,7 @@ export default function PaymentSummaryCard({
totalPrice,
isCustomer = false,
backHref,
eateryId,
}: PaymentSummaryCardProps) {
const [isReviewOpen, setIsReviewOpen] = useState(false);
const handlePayment = () => {
@@ -23,10 +24,10 @@ export default function PaymentSummaryCard({
return (
<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">
<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">
<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)">
{formatPrice(totalPrice)}
</span>
@@ -40,7 +41,7 @@ export default function PaymentSummaryCard({
size="md"
variant="primary"
>
Tiền mặt
Cash
</Button>
<Button
@@ -61,7 +62,7 @@ export default function PaymentSummaryCard({
size="md"
variant="primary"
>
Đánh giá
Review
</Button>
)}
@@ -77,7 +78,7 @@ export default function PaymentSummaryCard({
variant="secondary"
className="w-full"
>
Quay về
Return
</Button>
</Link>
</div>
@@ -86,6 +87,7 @@ export default function PaymentSummaryCard({
<ReviewModal
isOpen={isReviewOpen}
onClose={() => setIsReviewOpen(false)}
eateryId={eateryId}
/>
</aside>
);
+3 -3
View File
@@ -17,7 +17,7 @@ import type { ProductCardProps } from "./Card.types";
*/
export default function ProductCard({
image,
imageAlt = "Ảnh sản phẩm",
imageAlt = "Product image",
productName,
price,
description,
@@ -67,9 +67,9 @@ export default function ProductCard({
variant="primary"
size="sm"
icon="fa-cart-plus"
aria-label={`Mua ${productName}`}
aria-label={`Buy ${productName}`}
>
Mua
Buy
</Button>
</div>
</div>
+6 -6
View File
@@ -11,22 +11,22 @@ const STATUS_STYLES: Record<
available: {
bg: "bg-blue-50 border-blue-200",
text: "text-blue-700",
label: "Còn trống",
label: "Available",
},
registered: {
bg: "bg-blue-100 border-blue-400",
text: "text-blue-900",
label: "Đã đăng ký",
label: "Registered",
},
approved_leave: {
bg: "bg-purple-50 border-purple-300",
text: "text-purple-700",
label: "Nghỉ phép",
label: "On Leave",
},
absent: {
bg: "bg-red-50 border-red-300",
text: "text-red-700",
label: "Vắng mặt",
label: "Absent",
},
};
@@ -94,7 +94,7 @@ export default function ShiftCard({
{shift.registeredStaff.length > 0 && (
<div className="mt-2 border-t border-current/10 pt-2">
<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>
<div className="mt-1 flex flex-wrap gap-1">
{shift.registeredStaff.map((s) => (
@@ -111,7 +111,7 @@ export default function ShiftCard({
{shift.status === "available" && shift.registeredStaff.length === 0 && (
<p className="mt-2 text-[10px] italic opacity-50">
{shift.maxStaff} vị trí còn trống
{shift.maxStaff} spots available
</p>
)}
</button>
+7 -2
View File
@@ -1,9 +1,13 @@
"use client";
import { useCart } from "@/lib/cart-context";
import Image from "next/image";
import Link from "next/link";
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 (
<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 */}
@@ -25,10 +29,11 @@ export default function ShopCard({ name, address, image }: ShopCardProps) {
</h3>
<Link
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"
>
<i className="fa-solid fa-book-open text-[10px]"></i>
Xem menu
View menu
</Link>
</div>
@@ -6,7 +6,7 @@ export default function SearchBar({
value,
onChange,
onClear,
placeholder = "Tìm kiếm...",
placeholder = "Search...",
className = "",
}: SearchBarProps) {
return (
@@ -17,14 +17,14 @@ export default function SearchBar({
value={value}
onChange={(e) => onChange(e.target.value)}
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"
/>
{value && (
<button
onClick={onClear}
title="Xóa tìm kiếm"
aria-label="Xóa tìm kiếm"
title="Clear search"
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)"
>
<i className="fa-solid fa-xmark text-sm"></i>
+4 -4
View File
@@ -162,7 +162,7 @@ export function BarChart({ current, previous, height = 200 }: BarChartProps) {
fontSize="10"
fill="#F0D9A8"
>
{d.label} ({hovered.set === "cur" ? "Hiện tại" : "Trước"})
{d.label} ({hovered.set === "cur" ? "Current" : "Previous"})
</text>
<text
x={tipX + tipW / 2}
@@ -181,7 +181,7 @@ export function BarChart({ current, previous, height = 200 }: BarChartProps) {
fontSize="10"
fill="#A08060"
>
{d.orders} đơn hàng
{d.orders} orders
</text>
</g>
);
@@ -190,7 +190,7 @@ export function BarChart({ current, previous, height = 200 }: BarChartProps) {
{/* Legend */}
<rect x={padL} y={4} width={10} height={10} rx="2" fill="#6F4E37" />
<text x={padL + 13} y={13} fontSize="10" fill="#6F4E37">
Hiện tại
Current
</text>
<rect
x={padL + 65}
@@ -201,7 +201,7 @@ export function BarChart({ current, previous, height = 200 }: BarChartProps) {
fill="#E2C9A8"
/>
<text x={padL + 78} y={13} fontSize="10" fill="#A08060">
Kỳ trước
Previous
</text>
</svg>
</div>
+1 -1
View File
@@ -180,7 +180,7 @@ export function LineChart({ data, height = 200 }: LineChartProps) {
fontSize="10"
fill="#A08060"
>
{p.data.orders} đơn
{p.data.orders} orders
</text>
</g>
);
@@ -76,21 +76,21 @@ export function ProductTable({ data }: ProductTableProps) {
#
</th>
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
Sản phẩm
Product
</th>
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
Danh mục
Category
</th>
<SortTh col="unitsSold" label="Số lượng" className="text-right" />
<SortTh col="revenue" label="Doanh thu" className="text-right" />
<SortTh col="unitsSold" label="Units Sold" 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)">
Giá nhập
Cost Price
</th>
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
Giá bán
Selling Price
</th>
<SortTh col="profit" label="Lợi nhuận" className="text-right" />
<SortTh col="profitMargin" label="Biên LN" className="text-right" />
<SortTh col="profit" label="Profit" className="text-right" />
<SortTh col="profitMargin" label="Margin" className="text-right" />
</tr>
</thead>
<tbody>
@@ -53,7 +53,7 @@ export function SummaryCard({
</span>
<span className="text-xs font-normal text-(--color-text-muted)">
({isPositive ? "+" : ""}
{formatCurrency(change)}) so với kỳ trước
{formatCurrency(change)}) vs previous period
</span>
</div>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@ export default function CartFab() {
return (
<Link
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"
>
<i className="fa-solid fa-cart-shopping text-lg"></i>
+72 -72
View File
@@ -1,122 +1,122 @@
"use client";
import Button from "@/components/atoms/buttons/Button";
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
import LoginInput from "@/components/atoms/inputs/LoginInput";
import { useAuth } from "@/lib/auth-context";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { SubmitEvent, useState } from "react";
import { FormEvent, useState } from "react";
const PHONE_REGEX = /^(0[35789])[0-9]{8}$/;
export default function LoginForm() {
const router = useRouter();
const { login } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [errors, setErrors] = useState({
username: "",
password: "",
general: "",
});
const [phone, setPhone] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState({ phone: "", general: "" });
const validate = (): boolean => {
const newErrors = { username: "", password: "", general: "" };
let isValid = true;
if (!username.trim()) {
newErrors.username = "Vui lòng nhập tên đăng nhập";
isValid = false;
if (!phone.trim()) {
setErrors({ phone: "Please enter your phone number", general: "" });
return false;
}
if (!password.trim()) {
newErrors.password = "Vui lòng nhập mật khẩu";
isValid = false;
} else if (password.length < 4) {
newErrors.password = "Mật khẩu phải có ít nhất 4 ký tự";
isValid = false;
if (!PHONE_REGEX.test(phone)) {
setErrors({ phone: "Invalid phone number (e.g. 0987654321)", general: "" });
return false;
}
setErrors(newErrors);
return isValid;
return true;
};
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!validate()) return;
const success = await login(username, password);
setIsLoading(true);
setErrors({ phone: "", general: "" });
if (success) {
router.push("/");
} else {
setErrors({
username: "",
password: "",
general: "Tên đăng nhập hoặc mật khẩu không đúng",
try {
const res = await fetch("/api/sms_otp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone }),
});
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 (
<div>
{/* Error Message */}
{errors.general && <ErrorMessageLogin message={errors.general} />}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
{/* Username Input */}
<LoginInput
label="Tên đăng nhập"
type="text"
name="username"
value={username}
<label
htmlFor="phone"
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
Phone number
</label>
<div className="relative">
<i className="fa-solid fa-phone absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
<input
id="phone"
type="tel"
value={phone}
onChange={(e) => {
setUsername(e.target.value);
setErrors({ ...errors, username: "", general: "" });
setPhone(e.target.value);
setErrors({ phone: "", general: "" });
}}
errors={errors.username}
placeholder="0987654321"
disabled={isLoading}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors.phone ? "border-red-400" : "border-(--color-border)"}`}
/>
{errors.username && (
<ErrorMessageLogin message={errors.username} type="secondary" />
</div>
{errors.phone && (
<ErrorMessageLogin message={errors.phone} type="secondary" />
)}
<p className="mt-2 text-xs text-(--color-text-muted)">
Enter a Vietnamese phone number (10 digits, starting with 0)
</p>
</div>
{/* Password Input */}
<div>
<LoginInput
label="Mật khẩu"
type="password"
name="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setErrors({ ...errors, password: "", general: "" });
}}
errors={errors.password}
/>
{errors.password && (
<ErrorMessageLogin message={errors.password} type="secondary" />
)}
</div>
{/* Buttons */}
<div className="space-y-3 pt-2">
{/* Login Button */}
<Button
variant="primaryNoBorder"
type="submit"
style="login"
size="lg"
disabled={isLoading}
>
Đăng nhập
{isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Continue"
)}
</Button>
{/* Register Button */}
<Link
href="/register"
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
>
Đăng tài khoản
Create account
</Link>
</div>
</form>
@@ -23,15 +23,15 @@ export default function CategoriesTab() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-(--color-text-muted)">
<strong className="text-foreground">{categories.length}</strong> danh
mục
<strong className="text-foreground">{categories.length}</strong>{" "}
categories
</p>
<button
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"
>
<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>
</div>
@@ -50,19 +50,19 @@ export default function CategoriesTab() {
<p className="text-foreground truncate font-semibold">
{cat.name}
</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 className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
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)"
>
<i className="fa-solid fa-pen text-[11px]"></i>
</button>
<button
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"
>
<i className="fa-solid fa-trash text-[11px]"></i>
@@ -75,7 +75,7 @@ export default function CategoriesTab() {
{categories.length === 0 && (
<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>
<p className="text-sm">Chưa danh mục nào</p>
<p className="text-sm">No categories yet</p>
</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="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
<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>
<button
title="Close"
@@ -66,7 +66,7 @@ export default function CategoryModal({
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
<div>
<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>
<input
required
@@ -74,7 +74,7 @@ export default function CategoryModal({
value={form.name}
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"
placeholder="Ví dụ: Cà Phê"
placeholder="e.g. Coffee"
/>
</div>
@@ -107,13 +107,13 @@ export default function CategoryModal({
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)"
>
Hủy
Cancel
</button>
<button
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"
>
{isEdit ? "Lưu thay đổi" : "Thêm danh mục"}
{isEdit ? "Save change" : "Add category"}
</button>
</div>
</form>
+17 -17
View File
@@ -6,7 +6,7 @@ import { useState } from "react";
import type { ComboModalProps } from "./Manager.types";
function formatPrice(price: number) {
return price.toLocaleString("vi-VN") + "đ";
return price.toLocaleString("en-US") + "";
}
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 items-center justify-between border-b border-(--color-border-light) px-6 py-4">
<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>
<button
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>
<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>
<input
required
@@ -100,16 +100,16 @@ export default function ComboModal({
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className={inputCls}
placeholder="Ví dụ: Combo Cà Phê Đôi"
placeholder="e.g. Double Coffee Combo"
/>
</div>
<div>
<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>
<input
title="Giá combo"
title="Price in Vietnamese đồng"
required
type="number"
min={0}
@@ -124,10 +124,10 @@ export default function ComboModal({
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
tả
Description
</label>
<textarea
title="Mô tả combo"
title="Description"
rows={2}
value={form.description}
onChange={(e) =>
@@ -139,10 +139,10 @@ export default function ComboModal({
<div>
<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 && (
<span className="text-xs text-red-500">
(Chọn ít nhất 1 món)
(Choose at least one item.)
</span>
)}
</label>
@@ -162,7 +162,7 @@ export default function ComboModal({
</span>
<div className="flex items-center gap-1">
<button
title="Giảm"
title="Decrease"
type="button"
onClick={() => updateItemQty(p.id, qty - 1)}
disabled={qty === 0}
@@ -174,7 +174,7 @@ export default function ComboModal({
{qty}
</span>
<button
title="Tăng"
title="Increase"
type="button"
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)"
@@ -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>
<p className="text-foreground text-sm font-medium">
Trạng thái
Status
</p>
<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>
</div>
<button
title="Chuyển đổi trạng thái"
title="Toggle status"
type="button"
onClick={() => setForm({ ...form, available: !form.available })}
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}
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
type="submit"
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"
>
{isEdit ? "Lưu thay đổi" : "Thêm combo"}
{isEdit ? "Save change" : "Add combo"}
</button>
</div>
</form>
+8 -8
View File
@@ -9,7 +9,7 @@ import DeleteConfirm from "./DeleteConfirm";
import StatusBadge from "./StatusBadge";
function formatPrice(price: number) {
return price.toLocaleString("vi-VN") + "đ";
return price.toLocaleString("en-US") + "";
}
export default function CombosTab() {
@@ -26,7 +26,7 @@ export default function CombosTab() {
const [deleteTarget, setDeleteTarget] = useState<Combo | null>(null);
const getProductName = (id: number) =>
products.find((p) => p.id === id)?.name ?? `Món #${id}`;
products.find((p) => p.id === id)?.name ?? `Item #${id}`;
return (
<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"
>
<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>
</div>
@@ -47,7 +47,7 @@ export default function CombosTab() {
{combos.length === 0 ? (
<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>
<p className="text-sm">Chưa combo nào</p>
<p className="text-sm">No combos available yet.</p>
</div>
) : (
combos.map((combo) => (
@@ -69,7 +69,7 @@ export default function CombosTab() {
<button
onClick={() => toggleComboAvailability(combo.id)}
className="ml-3 shrink-0 cursor-pointer border-none bg-transparent"
title="Đổi trạng thái"
title="Toggle status"
>
<StatusBadge available={combo.available} />
</button>
@@ -77,7 +77,7 @@ export default function CombosTab() {
<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">
Bao gồm
Include
</p>
<ul className="space-y-0.5">
{combo.items.map((item) => (
@@ -99,14 +99,14 @@ export default function CombosTab() {
<div className="flex gap-1.5">
<button
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)"
>
<i className="fa-solid fa-pen text-xs"></i>
</button>
<button
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"
>
<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">
<i className="fa-solid fa-trash-can text-xl text-red-500"></i>
</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)">
Hành đng này không thể hoàn tác.
This action cannot be undone.
</p>
</div>
<div className="flex gap-3">
@@ -27,13 +27,13 @@ export default function DeleteConfirm({
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)"
>
Hủy
Cancel
</button>
<button
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"
>
Xóa
Delete
</button>
</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>
);
}
+14 -14
View File
@@ -41,11 +41,11 @@ export default function ProductModal({
<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">
<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>
<button
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)"
>
<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">
<div>
<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>
<input
required
@@ -63,18 +63,18 @@ export default function ProductModal({
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className={inputCls}
placeholder="Ví dụ: Cà Phê Đen"
placeholder="e.g. Black Coffee"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<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>
<select
required
title="Chọn danh mục"
title="Select category"
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
className={inputCls}
@@ -88,7 +88,7 @@ export default function ProductModal({
</div>
<div>
<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>
<input
required
@@ -107,7 +107,7 @@ export default function ProductModal({
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
tả
Description
</label>
<textarea
rows={3}
@@ -116,19 +116,19 @@ export default function ProductModal({
setForm({ ...form, description: e.target.value })
}
className={`${inputCls} resize-none`}
placeholder="Mô tả ngắn về món..."
placeholder="Short description of the item..."
/>
</div>
<div className="bg-background flex items-center justify-between rounded-xl border border-(--color-border-light) px-4 py-3">
<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)">
{form.available ? "Còn hàng" : "Tạm hết"}
{form.available ? "In Stock" : "Temporarily Out"}
</p>
</div>
<button
title="Chuyển trạng thái"
title="Toggle status"
type="button"
onClick={() => setForm({ ...form, available: !form.available })}
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}
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
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"
>
{isEdit ? "Lưu thay đổi" : "Thêm món"}
{isEdit ? "Save Changes" : "Add Item"}
</button>
</div>
</form>
+22 -22
View File
@@ -9,7 +9,7 @@ import ProductModal from "./ProductModal";
import StatusBadge from "./StatusBadge";
function formatPrice(price: number) {
return price.toLocaleString("vi-VN") + "đ";
return price.toLocaleString("en-US") + "";
}
export default function ProductsTab() {
@@ -59,12 +59,12 @@ export default function ProductsTab() {
type="text"
value={search}
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"
/>
{search && (
<button
title="Xóa tìm kiếm"
title="Clear search"
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)"
>
@@ -77,9 +77,9 @@ export default function ProductsTab() {
value={filterCategory}
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)"
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) => (
<option key={cat.id} value={cat.id}>
{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)"
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="available">Còn hàng</option>
<option value="unavailable">Tạm hết</option>
<option value="all">All states</option>
<option value="available">In stock</option>
<option value="unavailable">Temporarily out of stock</option>
</select>
<button
title="Thêm món"
title="Add dish"
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"
>
<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>
</div>
<p className="text-sm text-(--color-text-muted)">
Hiển thị <strong className="text-foreground">{filtered.length}</strong>{" "}
/ {products.length} món
Showing <strong className="text-foreground">{filtered.length}</strong>{" "}
/ {products.length} items
</p>
{/* Table */}
@@ -123,19 +123,19 @@ export default function ProductsTab() {
<thead className="bg-background">
<tr>
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
Tên món
Item Name
</th>
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
Danh mục
Category
</th>
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
Giá
Price
</th>
<th className="px-4 py-3 text-center font-semibold text-(--color-text-secondary)">
Trạng thái
Status
</th>
<th className="px-4 py-3 text-center font-semibold text-(--color-text-secondary)">
Thao tác
Actions
</th>
</tr>
</thead>
@@ -147,7 +147,7 @@ export default function ProductsTab() {
className="py-12 text-center text-(--color-text-muted)"
>
<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>
</tr>
) : (
@@ -180,7 +180,7 @@ export default function ProductsTab() {
<td className="px-4 py-3 text-center">
<button
onClick={() => toggleProductAvailability(p.id)}
title="Nhấn để đổi trạng thái"
title="Click to toggle status"
className="cursor-pointer border-none bg-transparent"
>
<StatusBadge available={p.available ?? true} />
@@ -190,14 +190,14 @@ export default function ProductsTab() {
<div className="flex items-center justify-center gap-1.5">
<button
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)"
>
<i className="fa-solid fa-pen text-xs"></i>
</button>
<button
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"
>
<i className="fa-solid fa-trash text-xs"></i>
+1 -1
View File
@@ -14,7 +14,7 @@ export default function StatusBadge({ available }: StatusBadgeProps) {
available ? "bg-emerald-500" : "bg-amber-500"
}`}
/>
{available ? "Còn hàng" : "Tạm hết"}
{available ? "In stock" : "Out of stock"}
</span>
);
}
+1
View File
@@ -6,6 +6,7 @@ export { default as ComboModal } from "./ComboModal";
export { default as ProductsTab } from "./ProductsTab";
export { default as CategoriesTab } from "./CategoriesTab";
export { default as CombosTab } from "./CombosTab";
export { default as MenuItemsTab } from "./MenuItemsTab";
export type {
ProductModalProps,
CategoryModalProps,
@@ -1,6 +1,7 @@
export interface ReviewModalProps {
isOpen: boolean;
onClose: () => void;
eateryId?: string;
}
export interface ConfirmModalProps {
+49 -18
View File
@@ -1,28 +1,52 @@
"use client";
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 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 [hovered, setHovered] = useState(0);
const [review, setReview] = useState("");
const [submitted, setSubmitted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!isOpen) return null;
const handleSubmit = () => {
const isAuthenticated = hasAuthCookie() || user?.role === "customer";
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 = () => {
// Reset state when closing
setRating(0);
setHovered(0);
setReview("");
setSubmitted(false);
setError(null);
onClose();
};
@@ -49,13 +73,13 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
<i className="fa-solid fa-heart text-(--color-accent)"></i>
</div>
<Heading level={2} id="review-modal-title">
Cảm ơn quý khách
Thank you
</Heading>
<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>
<Button onClick={handleClose} variant="primary" className="mt-4">
Đóng
Close
</Button>
</div>
) : (
@@ -65,21 +89,21 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
id="review-modal-title"
className="text-foreground mb-1 text-xl font-bold"
>
Đánh giá của bạn
Your Review
</h2>
<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>
{/* Star rating */}
<div className="mb-5">
<p className="mb-2 text-sm font-medium text-(--color-text-secondary)">
Mức đ hài lòng
Satisfaction level
</p>
<div
className="flex gap-2"
role="radiogroup"
aria-label="Xếp hạng sao"
aria-label="Star rating"
>
{[1, 2, 3, 4, 5].map((star) => {
const isActive = star <= (hovered || rating);
@@ -90,7 +114,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
onClick={() => setRating(star)}
onMouseEnter={() => setHovered(star)}
onMouseLeave={() => setHovered(0)}
aria-label={`${star} sao`}
aria-label={`${star} star`}
aria-pressed={rating === star}
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 && (
<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
]
}
@@ -120,14 +144,21 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
<div className="mb-6">
<Textarea
id="review-text"
label="Nhận xét (tùy chọn)"
label="Comment (optional)"
value={review}
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}
/>
</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 */}
<div className="flex gap-3">
<Button
@@ -137,16 +168,16 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
className="flex-1"
icon="fa-arrow-left"
>
Quay lại
Go back
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={rating === 0}
disabled={rating === 0 || isLoading}
className="flex-1"
icon="fa-check"
icon={isLoading ? "fa-spinner fa-spin" : "fa-check"}
>
Xác nhận
{isLoading ? "Submitting..." : "Confirm"}
</Button>
</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`}
>
<i className="fa-solid fa-utensils mr-2 text-(--color-primary)"></i>
Thực Đơn
Menu
</span>
{/* Toggle button — hidden on xl+ (sidebar is always expanded there) */}
<button
onClick={onToggle}
title={isOpen ? "Thu gọn menu" : "Mở rộng menu"}
aria-label={isOpen ? "Thu gọn menu" : "Mở rộng menu"}
title={isOpen ? "Collapse menu" : "Expand 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"
>
<i
@@ -2,19 +2,66 @@
import { ProductCard } from "@/components/molecules/cards";
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 type { Product } from "@/lib/types";
import { useEffect, useState } from "react";
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({
searchQuery = "",
isSidebarOpen = false,
}: ProductGridProps) {
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 matchesCategory =
activeCategory === "all" || p.category === activeCategory;
@@ -25,9 +72,6 @@ export default function ProductGrid({
return isAvailable && matchesCategory && matchesSearch;
});
const activeCategoryLabel =
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả";
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-3 xl:grid-cols-4 2xl:grid-cols-5";
@@ -59,8 +103,20 @@ export default function ProductGrid({
</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 ── */}
{filteredProducts.length > 0 ? (
{!loading && filteredProducts.length > 0 && (
<div className={`grid gap-4 ${gridCols}`}>
{filteredProducts.map((product) => (
<ProductCard
@@ -74,14 +130,16 @@ export default function ProductGrid({
/>
))}
</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)">
<i className="fa-solid fa-mug-hot text-5xl opacity-30"></i>
<p className="text-base font-medium">
{searchQuery
? `Không tìm thấy món nào cho "${searchQuery}"`
: "Chưa có món trong danh mục này"}
? `No items found for "${searchQuery}"`
: "No items in this category yet"}
</p>
</div>
)}
@@ -7,7 +7,7 @@ import { useMemo, useState } from "react";
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 {
const y = d.getFullYear();
@@ -26,18 +26,18 @@ function isToday(d: Date): boolean {
}
const MONTH_NAMES = [
"Tháng 1",
"Tháng 2",
"Tháng 3",
"Tháng 4",
"Tháng 5",
"Tháng 6",
"Tháng 7",
"Tháng 8",
"Tháng 9",
"Tháng 10",
"Tháng 11",
"Tháng 12",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
export default function MobileShiftView({
@@ -94,7 +94,7 @@ export default function MobileShiftView({
{/* Month navigation */}
<div className="mb-3 flex items-center justify-between">
<button
title="Trở lại tháng trước"
title="Previous month"
type="button"
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"
@@ -105,7 +105,7 @@ export default function MobileShiftView({
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
</h3>
<button
title="Trở lại tháng sau"
title="Next month"
type="button"
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"
@@ -173,22 +173,22 @@ export default function MobileShiftView({
<div className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-amber-400"></span>
<span className="text-[10px] text-(--color-text-muted)">
Còn trống
Available
</span>
</div>
<div className="flex items-center gap-1">
<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 className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
<span className="text-[10px] text-(--color-text-muted)">
Nghỉ phép
On leave
</span>
</div>
<div className="flex items-center gap-1">
<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>
@@ -199,7 +199,7 @@ export default function MobileShiftView({
{dayOfWeek}, {selectedDateObj.getDate()}/
{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()}
<span className="ml-2 text-xs font-normal text-(--color-text-muted)">
({selectedShifts.length} ca)
({selectedShifts.length} shift{selectedShifts.length !== 1 ? "s" : ""})
</span>
</h3>
@@ -207,7 +207,7 @@ export default function MobileShiftView({
<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>
<p className="text-sm text-(--color-text-muted)">
Không ca làm trong ngày này
No shifts for this day
</p>
</div>
) : (
@@ -5,7 +5,7 @@ import { useMemo } from "react";
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 {
const y = d.getFullYear();
@@ -125,7 +125,7 @@ export default function MonthlyCalendar({
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-blue-400"></span>
<span className="text-[10px] text-blue-600">
{summary.available} trống
{summary.available} available
</span>
</div>
)}
@@ -133,7 +133,7 @@ export default function MonthlyCalendar({
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-blue-700"></span>
<span className="text-[10px] text-blue-800">
{summary.registered} đã ĐK
{summary.registered} registered
</span>
</div>
)}
@@ -141,7 +141,7 @@ export default function MonthlyCalendar({
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
<span className="text-[10px] text-purple-600">
{summary.leave} nghỉ
{summary.leave} on leave
</span>
</div>
)}
@@ -149,7 +149,7 @@ export default function MonthlyCalendar({
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-red-400"></span>
<span className="text-[10px] text-red-600">
{summary.absent} vắng
{summary.absent} absent
</span>
</div>
)}
@@ -35,7 +35,7 @@ export default function ShiftCreateModal({
// Validate
if (!date || !startTime || !endTime) {
setError("Vui lòng điền đầy đủ thông tin.");
setError("Please fill in all required fields.");
return;
}
@@ -45,7 +45,7 @@ export default function ShiftCreateModal({
const endMinutes = eh * 60 + em;
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;
}
@@ -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>
<h2 className="text-foreground text-base font-bold">
Tạo ca làm mới
Create New Shift
</h2>
<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>
</div>
<button
@@ -98,7 +98,7 @@ export default function ShiftCreateModal({
{/* Date */}
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Ngày
Date
</label>
<input
title="Date"
@@ -113,7 +113,7 @@ export default function ShiftCreateModal({
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Giờ bắt đu
Start Time
</label>
<input
title="Start Time"
@@ -125,7 +125,7 @@ export default function ShiftCreateModal({
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Giờ kết thúc
End Time
</label>
<input
title="End Time"
@@ -140,7 +140,7 @@ export default function ShiftCreateModal({
{/* Department */}
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Bộ phận
Department
</label>
<select
title="Department"
@@ -160,7 +160,7 @@ export default function ShiftCreateModal({
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Số nhân viên tối đa
Max Staff
</label>
<input
title="Max Staff"
@@ -174,7 +174,7 @@ export default function ShiftCreateModal({
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Lương ca (VND)
Wage (VND)
</label>
<input
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"
>
<i className="fa-solid fa-plus mr-2"></i>
Tạo ca làm
Create Shift
</button>
<button
type="button"
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"
>
Hủy
Cancel
</button>
</div>
</form>
@@ -32,10 +32,10 @@ export default function ShiftDetailModal({
setSuccess(null);
const result = registerShift(shift.id, user.id, user.name);
if (result.success) {
setSuccess("Đăng ký ca thành công!");
setSuccess("Shift registered successfully!");
setTimeout(onClose, 1200);
} 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;
setError(null);
unregisterShift(shift.id, user.id);
setSuccess("Đã hủy đăng ký ca.");
setSuccess("Shift registration cancelled.");
setTimeout(onClose, 1200);
};
const handleManagerUnregister = (staffId: number) => {
unregisterShift(shift.id, staffId);
setSuccess("Đã xóa nhân viên khỏi ca.");
setSuccess("Staff removed from shift.");
};
const handleDelete = () => {
@@ -58,10 +58,10 @@ export default function ShiftDetailModal({
};
const statusLabel = {
available: "Còn trống",
registered: "Đã đăng ký",
approved_leave: "Nghỉ phép",
absent: "Vắng mặt",
available: "Available",
registered: "Registered",
approved_leave: "On Leave",
absent: "Absent",
};
const statusColor = {
@@ -84,7 +84,7 @@ export default function ShiftDetailModal({
{dept && <i className={`${dept.icon} text-(--color-primary)`}></i>}
<div>
<h2 className="text-foreground text-base font-bold">
Chi tiết ca làm
Shift Details
</h2>
<p className="text-xs text-(--color-text-muted)">{dept?.name}</p>
</div>
@@ -114,11 +114,11 @@ export default function ShiftDetailModal({
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Ngày
Date
</p>
<p className="text-foreground mt-1 text-sm font-bold">
{new Date(shift.date + "T00:00:00").toLocaleDateString(
"vi-VN",
"en-US",
{
weekday: "long",
day: "2-digit",
@@ -130,7 +130,7 @@ export default function ShiftDetailModal({
</div>
<div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Giờ làm
Work Hours
</p>
<p className="text-foreground mt-1 text-sm font-bold">
{shift.startTime} {shift.endTime}
@@ -138,18 +138,18 @@ export default function ShiftDetailModal({
</div>
<div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Thời lượng
Duration
</p>
<p className="text-foreground mt-1 text-sm font-bold">
{shift.durationHours} giờ
{shift.durationHours} hrs
</p>
</div>
<div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Lương ca
Shift Wage
</p>
<p className="mt-1 text-sm font-bold text-(--color-primary)">
{shift.wage.toLocaleString("vi-VN")} VND
{shift.wage.toLocaleString("en-US")} VND
</p>
</div>
</div>
@@ -157,12 +157,12 @@ export default function ShiftDetailModal({
{/* Registered staff */}
<div>
<p className="mb-2 text-xs font-semibold text-(--color-text-secondary)">
Nhân viên đã đăng ({shift.registeredStaff.length}/
Registered Staff ({shift.registeredStaff.length}/
{shift.maxStaff})
</p>
{shift.registeredStaff.length === 0 ? (
<p className="text-xs text-(--color-text-muted) italic">
Chưa ai đăng
No one registered yet
</p>
) : (
<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"
>
<i className="fa-solid fa-user-minus mr-1"></i>
Xóa
Remove
</button>
)}
</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"
>
<i className="fa-solid fa-calendar-plus mr-2"></i>
Đăng ca
Register Shift
</button>
)}
{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"
>
<i className="fa-solid fa-calendar-minus mr-2"></i>
Hủy đăng
Cancel Registration
</button>
)}
{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"
>
<i className="fa-solid fa-trash mr-2"></i>
Xóa ca
Delete Shift
</button>
)}
<button
@@ -250,7 +250,7 @@ export default function ShiftDetailModal({
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"
>
Đóng
Close
</button>
</div>
</div>
@@ -22,7 +22,7 @@ const MONTH_NAMES_EN = [
"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"];
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="mb-3 flex items-center justify-between">
<button
title="Tuần trước"
title="Previous week"
type="button"
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"
@@ -105,7 +105,7 @@ export default function WeeklySchedule({
{MONTH_NAMES_EN[currentDate.getMonth()]} {currentDate.getFullYear()}
</h3>
<button
title="Tuần sau"
title="Next week"
type="button"
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"
@@ -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)"
>
<i className="fa-solid fa-plus mr-1"></i>
Thêm ca
Add shift
</button>
)}
</div>
@@ -213,7 +213,7 @@ export default function WeeklySchedule({
<thead>
<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">
Bộ phận
Department
</th>
{weekDates.map((date, i) => (
<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)"
>
<i className="fa-solid fa-plus mr-1"></i>
Thêm ca
Add shift
</button>
)}
</div>
+2 -1
View File
@@ -23,7 +23,7 @@ export default function ShopGrid({
return (
<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>
<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>
);
}
@@ -34,6 +34,7 @@ export default function ShopGrid({
<ShopCard
key={shop.id}
id={shop.id}
eateryId={shop.eateryId}
name={shop.name}
address={shop.address}
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";
-4
View File
@@ -2,10 +2,6 @@
export { MainLayout } from "./main-layout";
export type { MainLayoutProps } from "./main-layout";
// Auth Layout
export { AuthLayout } from "./auth-layout";
export type { AuthLayoutProps } from "./auth-layout";
// Feed Layout
export { FeedLayout } from "./feed-layout";
export type { FeedLayoutProps } from "./feed-layout";
@@ -13,26 +13,26 @@ import type { ManagerLayoutProps } from "./ManagerLayout.types";
* Redirects non-managers away; shows loading state while auth resolves.
*/
export default function ManagerLayout({ children }: ManagerLayoutProps) {
const { user } = useAuth();
const { user, isInitialized } = useAuth();
const router = useRouter();
useEffect(() => {
if (user !== null && user.role !== "manager") {
if (isInitialized && user !== null && user.role !== "manager") {
router.replace("/");
}
}, [user, router]);
}, [user, isInitialized, router]);
if (user === null) {
if (!isInitialized || user === null) {
return (
<div className="bg-background flex min-h-screen items-center justify-center">
<div className="flex flex-col items-center gap-4 text-(--color-text-muted)">
<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
href="/login"
className="text-sm font-medium text-(--color-primary) hover:underline"
>
Đăng nhập nếu chưa tài khoản
Sign in if you don&apos;t have an account
</Link>
</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="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>
<p className="text-sm">Đang kiểm tra quyền truy cập...</p>
<p className="text-sm">Checking access...</p>
<Link
href="/login"
className="text-sm font-medium text-(--color-primary) hover:underline"
>
Đăng nhập nếu chưa tài khoản
Sign in if you don&apos;t have an account
</Link>
</div>
</div>
+9 -9
View File
@@ -48,7 +48,7 @@ export default function Footer() {
<ul className="flex flex-col gap-2 text-sm opacity-80">
<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>
<span>Đa chỉ: {SHOP_INFO.address}</span>
<span>Address: {SHOP_INFO.address}</span>
</li>
<li className="flex items-center gap-2">
<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}`}
className="transition-colors duration-150 hover:text-(--color-accent)"
>
Số điện thoại: {SHOP_INFO.phone}
Phone: {SHOP_INFO.phone}
</a>
</li>
<li className="flex items-center gap-2">
@@ -83,7 +83,7 @@ export default function Footer() {
{/* ── 2. Social links ── */}
<div className="col-span-1">
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
Kết nối
Follow Us
</h3>
<ul className="flex flex-col gap-3">
<li>
@@ -129,20 +129,20 @@ export default function Footer() {
{/* ── 3. WiFi card ── */}
<div className="col-span-1">
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
WiFi Miễn Phí
Free WiFi
</h3>
<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">
<i className="fa-solid fa-wifi shrink-0 text-lg text-(--color-accent)"></i>
<span className="text-sm font-semibold">
Kết nối miễn phí
Connect for free
</span>
</div>
{/* Stacked label + value rows — no overflow risk */}
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs tracking-wide uppercase opacity-60">
Tên mạng
Network name
</span>
<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}
@@ -150,7 +150,7 @@ export default function Footer() {
</div>
<div className="flex flex-col gap-1">
<span className="text-xs tracking-wide uppercase opacity-60">
Mật khẩu
Password
</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)">
{SHOP_INFO.wifi.password}
@@ -170,9 +170,9 @@ export default function Footer() {
© {new Date().getFullYear()} {SHOP_INFO.name}. All rights reserved.
</span>
<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>{" "}
bằng Drinkool
Drinkool
</span>
</div>
</div>
+9 -9
View File
@@ -72,11 +72,11 @@ export default function Header() {
/* Guest: sign-in button */
<button
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"
>
<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>
) : user.role === "manager" ? (
/* Manager: dashboard link + logout */
@@ -90,8 +90,8 @@ export default function Header() {
</Link>
<button
onClick={handleAuthClick}
title="Đăng xuất"
aria-label="Đăng xuất"
title="Sign out"
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"
>
<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"
>
<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>
<button
onClick={handleAuthClick}
title="Nhấn để đăng xuất"
aria-label="Đăng xuất"
title="Click to sign out"
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"
>
{/* Avatar circle */}
@@ -124,14 +124,14 @@ export default function Header() {
/* Customer: phone icon + label */
<button
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"
>
{/* 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)">
<i className="fa-solid fa-user"></i>
</div>
<span className="hidden sm:inline">Khách hàng</span>
<span className="hidden sm:inline">Customer</span>
</button>
)}
</div>
+4 -4
View File
@@ -5,10 +5,10 @@
* e.g. 1_500_000 → "1.5 tr", 25_000 → "25 k"
*/
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) return (value / 1_000_000).toFixed(1) + " tr";
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) + " M";
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đ"
*/
export function formatCurrencyFull(value: number): string {
return value.toLocaleString("vi-VN") + "đ";
return value.toLocaleString("en-US") + "";
}
/**
+24
View File
@@ -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
View File
@@ -12,95 +12,18 @@ import type { User } from "./types";
interface AuthContextType {
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;
registerPhone: string | null;
setRegisterPhone: (phone: string | null) => void;
completeRegistration: (phone: string) => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Mock user database
const MOCK_AUTH_DB = {
// Admin
admin: {
username: "admin",
password: "admin",
user: {
id: 1,
name: "Quản lý",
role: "manager" as const,
avatar: null,
phone: "0912345678",
},
},
// Staff (username and password are their names)
"Nguyễn Văn An": {
username: "Nguyễn Văn An",
password: "Nguyễn Văn An",
user: {
id: 2,
name: "Nguyễn Văn An",
role: "staff" as const,
avatar: null,
phone: "0901234567",
},
},
"Trần Thị Bình": {
username: "Trần Thị Bình",
password: "Trần Thị Bình",
user: {
id: 3,
name: "Trần Thị Bình",
role: "staff" as const,
avatar: null,
phone: "0902345678",
},
},
"Lê Văn Cường": {
username: "Lê Văn Cường",
password: "Lê Văn Cường",
user: {
id: 4,
name: "Lê Văn Cường",
role: "staff" as const,
avatar: null,
phone: "0903456789",
},
},
// Customers (username is phone number, password is custom)
"0987654321": {
username: "0987654321",
password: "user1",
user: {
id: 5,
name: "Khách hàng",
role: "customer" as const,
avatar: null,
phone: "0987654321",
},
},
"0976543210": {
username: "0976543210",
password: "user1",
user: {
id: 6,
name: "Khách hàng",
role: "customer" as const,
avatar: null,
phone: "0976543210",
},
},
};
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [registerPhone, setRegisterPhone] = useState<string | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
// Load user from localStorage on mount
useEffect(() => {
const savedUser = localStorage.getItem("coffee-shop-user");
if (savedUser) {
@@ -110,32 +33,32 @@ export function AuthProvider({ children }: { children: ReactNode }) {
console.error("Failed to parse saved user", e);
}
}
setIsInitialized(true);
}, []);
const login = async (phone: string, password: string): Promise<boolean> => {
const login = async (username: string, password: string): Promise<{ ok: boolean; status?: number }> => {
const isPhone = /^0\d{9}$/.test(username.trim());
const role = isPhone ? "customer" : "manager";
try {
const response = await fetch("/api/customer/login", {
const response = await fetch("/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ phone, password }),
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: username, password, role }),
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
return true;
return { ok: true };
} else {
console.error("Đăng nhập thất bại:", response.statusText);
return false;
console.error("Login failed:", response.status);
return { ok: false, status: response.status };
}
} catch (error) {
console.error("Lỗi kết nối API:", error);
return false;
console.error("API connection error:", error);
return { ok: false };
}
};
@@ -144,37 +67,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.removeItem("coffee-shop-user");
};
const completeRegistration = (phone: string) => {
// Create new customer account
const newUser: User = {
id: Date.now(),
name: "Khách hàng",
role: "customer",
avatar: null,
phone,
};
// Add to mock database (in real app, this would be API call)
(MOCK_AUTH_DB as any)[phone] = {
username: phone,
password: "user1", // Default password for new customers
user: newUser,
};
setUser(newUser);
localStorage.setItem("coffee-shop-user", JSON.stringify(newUser));
setRegisterPhone(null);
};
return (
<AuthContext.Provider
value={{
user,
setUser,
isInitialized,
login,
logout,
registerPhone,
setRegisterPhone,
completeRegistration,
}}
>
{children}
+107 -5
View File
@@ -1,7 +1,19 @@
"use client";
import {
addItem as addItemGql,
createCart as createCartGql,
} from "@/lib/graphql/cart";
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 {
id: number;
@@ -9,12 +21,16 @@ export interface CartItem {
description: string;
price: number;
quantity: number;
menuItemId?: string; // backend UUID for GraphQL sync
}
interface CartContextValue {
items: CartItem[];
totalItems: number;
totalPrice: number;
eateryId: string | null;
backendCartId: string | null;
setEateryId: (id: string) => void;
addToCart: (product: Product) => void;
increaseQty: (id: number) => void;
decreaseQty: (id: number) => void;
@@ -23,29 +39,88 @@ interface CartContextValue {
}
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);
export function CartProvider({ children }: { children: React.ReactNode }) {
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(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
if (raw) {
const parsed = JSON.parse(raw) as CartItem[];
if (Array.isArray(parsed)) {
setItems(parsed.filter((i) => i && i.id && i.quantity > 0));
}
}
} catch {
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(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(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 snapshot = items;
setItems((prev) => {
const index = prev.findIndex((i) => i.id === product.id);
if (index === -1) {
@@ -57,14 +132,21 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
description: product.description,
price: product.price,
quantity: 1,
menuItemId: product.menuItemId,
},
];
}
const next = [...prev];
next[index] = { ...next[index], quantity: next[index].quantity + 1 };
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) => {
@@ -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) => {
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) => {
@@ -122,13 +220,17 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
items,
totalItems,
totalPrice,
eateryId,
backendCartId,
setEateryId,
addToCart,
increaseQty,
decreaseQty,
removeFromCart,
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>;
+101 -112
View File
@@ -15,9 +15,9 @@ import type {
// ===== SHOP INFORMATION =====
export const SHOP_INFO: ShopInfo = {
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",
address: "123 Đường Nguyn Huệ, Quận 1, TP. Hồ Chí Minh",
address: "123 Nguyen Hue Street, District 1, Ho Chi Minh City",
phone: "0901 234 567",
managerPhone: "0912 345 678",
email: "contact@coffeeshop.vn",
@@ -25,7 +25,7 @@ export const SHOP_INFO: ShopInfo = {
name: "CoffeeShop_Free",
password: "coffee2024",
},
openHours: "07:00 22:00 (Thứ 2 Chủ nhật)",
openHours: "07:00 22:00 (Monday Sunday)",
};
// ===== SOCIAL LINKS =====
@@ -38,15 +38,15 @@ export const SOCIAL_LINKS: SocialLinks = {
// ===== MENU CATEGORIES =====
// Each category has a unique FontAwesome icon representing the item type
export const MENU_CATEGORIES: MenuCategory[] = [
{ id: "all", name: "Tất cả", icon: "fa-solid fa-border-all" },
{ id: "cafe", name: "Cà Phê", icon: "fa-solid fa-mug-hot" },
{ id: "tra", name: "T", icon: "fa-solid fa-leaf" },
{ id: "sua-chua", name: "Sữa Chua", icon: "fa-solid fa-jar" },
{ id: "nuoc-ep", name: "Nước Ép", icon: "fa-solid fa-blender" },
{ id: "all", name: "All", icon: "fa-solid fa-border-all" },
{ id: "cafe", name: "Coffee", icon: "fa-solid fa-mug-hot" },
{ id: "tra", name: "Tea", icon: "fa-solid fa-leaf" },
{ id: "sua-chua", name: "Yogurt", icon: "fa-solid fa-jar" },
{ id: "nuoc-ep", name: "Juice", icon: "fa-solid fa-blender" },
{ id: "latte", name: "Latte", icon: "fa-solid fa-mug-saucer" },
{
id: "giai-khat",
name: "Giải Khát / Ăn Vặt",
name: "Refreshments / Snacks",
icon: "fa-solid fa-ice-cream",
},
{ id: "topping", name: "Topping", icon: "fa-solid fa-layer-group" },
@@ -57,182 +57,182 @@ export const MENU_CATEGORIES: MenuCategory[] = [
export const MOCK_PRODUCTS: Product[] = [
{
id: 1,
name: "Cà Phê Đen",
name: "Black Coffee",
category: "cafe",
price: 25000,
image: "/imgs/products/placeholder.jpg",
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,
},
{
id: 2,
name: "Cà Phê Sữa",
name: "Milk Coffee",
category: "cafe",
price: 30000,
image: "/imgs/products/placeholder.jpg",
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,
},
{
id: 3,
name: "Bạc Xỉu",
name: "White Coffee",
category: "cafe",
price: 32000,
image: "/imgs/products/placeholder.jpg",
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,
},
{
id: 4,
name: "Cà Phê Trứng",
name: "Egg Coffee",
category: "cafe",
price: 45000,
image: "/imgs/products/placeholder.jpg",
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,
},
{
id: 5,
name: "Trà Đào Cam Sả",
name: "Peach Orange Lemongrass Tea",
category: "tra",
price: 35000,
image: "/imgs/products/placeholder.jpg",
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,
},
{
id: 6,
name: "Trà Xanh Matcha",
name: "Matcha Green Tea",
category: "tra",
price: 40000,
image: "/imgs/products/placeholder.jpg",
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,
},
{
id: 7,
name: "Trà Vải Hoa Nhài",
name: "Lychee Jasmine Tea",
category: "tra",
price: 38000,
image: "/imgs/products/placeholder.jpg",
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,
},
{
id: 8,
name: "Sữa Chua Trân Châu",
name: "Yogurt with Tapioca Pearls",
category: "sua-chua",
price: 38000,
image: "/imgs/products/placeholder.jpg",
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,
},
{
id: 9,
name: "Sữa Chua Dâu",
name: "Strawberry Yogurt",
category: "sua-chua",
price: 40000,
image: "/imgs/products/placeholder.jpg",
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,
},
{
id: 10,
name: "Nước Ép Cam",
name: "Fresh Orange Juice",
category: "nuoc-ep",
price: 35000,
image: "/imgs/products/placeholder.jpg",
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,
},
{
id: 11,
name: "Nước Ép Dưa Hấu",
name: "Watermelon Juice",
category: "nuoc-ep",
price: 30000,
image: "/imgs/products/placeholder.jpg",
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,
},
{
id: 12,
name: "Latte Caramel",
name: "Caramel Latte",
category: "latte",
price: 45000,
image: "/imgs/products/placeholder.jpg",
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,
},
{
id: 13,
name: "Latte Vanilla",
name: "Vanilla Latte",
category: "latte",
price: 45000,
image: "/imgs/products/placeholder.jpg",
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,
},
{
id: 14,
name: "Bánh Mì Nướng Bơ",
name: "Toasted Butter Bread",
category: "giai-khat",
price: 20000,
image: "/imgs/products/placeholder.jpg",
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,
},
{
id: 15,
name: "Bánh Flan",
name: "Caramel Flan",
category: "giai-khat",
price: 25000,
image: "/imgs/products/placeholder.jpg",
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,
},
{
id: 16,
name: "Trân Châu Đen",
name: "Black Tapioca Pearls",
category: "topping",
price: 10000,
image: "/imgs/products/placeholder.jpg",
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,
},
{
id: 17,
name: "Thạch Cà Phê",
name: "Coffee Jelly",
category: "topping",
price: 10000,
image: "/imgs/products/placeholder.jpg",
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,
},
{
id: 18,
name: "Trân Châu Trắng",
name: "White Tapioca Pearls",
category: "topping",
price: 10000,
image: "/imgs/products/placeholder.jpg",
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,
},
];
@@ -241,8 +241,8 @@ export const MOCK_PRODUCTS: Product[] = [
export const MOCK_COMBOS: Combo[] = [
{
id: 1,
name: "Combo Cà Phê Đôi",
description: "2 ly cà phê đen + 2 bánh mì nướng bơ, tiết kiệm 15%.",
name: "Double Coffee Combo",
description: "2 black coffees + 2 toasted butter breads, save 15%.",
price: 75000,
image: "/imgs/products/placeholder.jpg",
items: [
@@ -253,8 +253,8 @@ export const MOCK_COMBOS: Combo[] = [
},
{
id: 2,
name: "Combo Trà Sữa Nhóm",
description: "2 trà đào cam sả + 2 trà xanh matcha, dành cho nhóm bạn.",
name: "Group Tea Combo",
description: "2 peach orange lemongrass teas + 2 matcha green teas, perfect for a group.",
price: 130000,
image: "/imgs/products/placeholder.jpg",
items: [
@@ -265,8 +265,8 @@ export const MOCK_COMBOS: Combo[] = [
},
{
id: 3,
name: "Combo Buổi Sáng",
description: "1 cà phê sữa + 1 bánh flan, khởi đầu ngày mới ngọt ngào.",
name: "Morning Combo",
description: "1 milk coffee + 1 caramel flan — start your day on a sweet note.",
price: 48000,
image: "/imgs/products/placeholder.jpg",
items: [
@@ -281,6 +281,7 @@ export const MOCK_COMBOS: Combo[] = [
export const MOCK_SHOPS: Shop[] = [
{
id: 1,
eateryId: "f39b2da0-aa73-4939-8601-d87b53fe7e27",
name: "The Coffee House",
address: "86 Cao Thắng, Quận 3, TP. Hồ Chí Minh",
image:
@@ -288,6 +289,7 @@ export const MOCK_SHOPS: Shop[] = [
},
{
id: 2,
eateryId: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
name: "Highlands Coffee",
address: "123 Nguyễn Huệ, Quận 1, TP. Hồ Chí Minh",
image:
@@ -295,6 +297,7 @@ export const MOCK_SHOPS: Shop[] = [
},
{
id: 3,
eateryId: "c3d4e5f6-a7b8-9012-cdef-123456789012",
name: "Phúc Long Heritage",
address: "42 Lê Lợi, Quận 1, TP. Hồ Chí Minh",
image:
@@ -302,6 +305,7 @@ export const MOCK_SHOPS: Shop[] = [
},
{
id: 4,
eateryId: "d4e5f6a7-b8c9-0123-def0-234567890123",
name: "Katinat Saigon Kafe",
address: "26 Lý Tự Trọng, Quận 1, TP. Hồ Chí Minh",
image:
@@ -309,6 +313,7 @@ export const MOCK_SHOPS: Shop[] = [
},
{
id: 5,
eateryId: "e5f6a7b8-c9d0-1234-ef01-345678901234",
name: "Trung Nguyên E-Coffee",
address: "15 Hai Bà Trưng, Quận 1, TP. Hồ Chí Minh",
image:
@@ -354,34 +359,34 @@ export const MOCK_REVENUE_DAILY: RevenueDataPoint[] = [
// Weekly revenue (last 12 weeks)
export const MOCK_REVENUE_WEEKLY: RevenueDataPoint[] = [
{ label: "T1/W1", revenue: 8200000, orders: 295 },
{ label: "T1/W2", revenue: 9450000, orders: 340 },
{ label: "T1/W3", revenue: 10100000, orders: 362 },
{ label: "T1/W4", revenue: 8750000, orders: 315 },
{ label: "T2/W1", revenue: 9200000, orders: 330 },
{ label: "T2/W2", revenue: 10500000, orders: 378 },
{ label: "T2/W3", revenue: 11200000, orders: 400 },
{ label: "T2/W4", revenue: 9800000, orders: 352 },
{ label: "T3/W1", revenue: 10400000, orders: 374 },
{ label: "T3/W2", revenue: 11800000, orders: 424 },
{ label: "T3/W3", revenue: 12500000, orders: 448 },
{ label: "T3/W4", revenue: 10900000, orders: 392 },
{ label: "Jan/W1", revenue: 8200000, orders: 295 },
{ label: "Jan/W2", revenue: 9450000, orders: 340 },
{ label: "Jan/W3", revenue: 10100000, orders: 362 },
{ label: "Jan/W4", revenue: 8750000, orders: 315 },
{ label: "Feb/W1", revenue: 9200000, orders: 330 },
{ label: "Feb/W2", revenue: 10500000, orders: 378 },
{ label: "Feb/W3", revenue: 11200000, orders: 400 },
{ label: "Feb/W4", revenue: 9800000, orders: 352 },
{ label: "Mar/W1", revenue: 10400000, orders: 374 },
{ label: "Mar/W2", revenue: 11800000, orders: 424 },
{ label: "Mar/W3", revenue: 12500000, orders: 448 },
{ label: "Mar/W4", revenue: 10900000, orders: 392 },
];
// Monthly revenue (last 12 months)
export const MOCK_REVENUE_MONTHLY: RevenueDataPoint[] = [
{ label: "T4/2025", revenue: 42000000, orders: 1512 },
{ label: "T5/2025", revenue: 45500000, orders: 1638 },
{ label: "T6/2025", revenue: 48000000, orders: 1728 },
{ label: "T7/2025", revenue: 52000000, orders: 1872 },
{ label: "T8/2025", revenue: 49500000, orders: 1782 },
{ label: "T9/2025", revenue: 46800000, orders: 1685 },
{ label: "T10/2025", revenue: 51200000, orders: 1843 },
{ label: "T11/2025", revenue: 55000000, orders: 1980 },
{ label: "T12/2025", revenue: 62000000, orders: 2232 },
{ label: "T1/2026", revenue: 44000000, orders: 1584 },
{ label: "T2/2026", revenue: 47500000, orders: 1710 },
{ label: "T3/2026", revenue: 53500000, orders: 1926 },
{ label: "Apr/2025", revenue: 42000000, orders: 1512 },
{ label: "May/2025", revenue: 45500000, orders: 1638 },
{ label: "Jun/2025", revenue: 48000000, orders: 1728 },
{ label: "Jul/2025", revenue: 52000000, orders: 1872 },
{ label: "Aug/2025", revenue: 49500000, orders: 1782 },
{ label: "Sep/2025", revenue: 46800000, orders: 1685 },
{ label: "Oct/2025", revenue: 51200000, orders: 1843 },
{ label: "Nov/2025", revenue: 55000000, orders: 1980 },
{ label: "Dec/2025", revenue: 62000000, orders: 2232 },
{ label: "Jan/2026", revenue: 44000000, orders: 1584 },
{ label: "Feb/2026", revenue: 47500000, orders: 1710 },
{ label: "Mar/2026", revenue: 53500000, orders: 1926 },
];
// Yearly revenue (last 5 years)
@@ -397,7 +402,7 @@ export const MOCK_REVENUE_YEARLY: RevenueDataPoint[] = [
export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
{
productId: 12,
name: "Latte Caramel",
name: "Caramel Latte",
category: "latte",
unitsSold: 487,
revenue: 21915000,
@@ -408,7 +413,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
},
{
productId: 6,
name: "Trà Xanh Matcha",
name: "Matcha Green Tea",
category: "tra",
unitsSold: 412,
revenue: 16480000,
@@ -419,7 +424,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
},
{
productId: 5,
name: "Trà Đào Cam Sả",
name: "Peach Orange Lemongrass Tea",
category: "tra",
unitsSold: 398,
revenue: 13930000,
@@ -430,7 +435,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
},
{
productId: 13,
name: "Latte Vanilla",
name: "Vanilla Latte",
category: "latte",
unitsSold: 356,
revenue: 16020000,
@@ -441,7 +446,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
},
{
productId: 4,
name: "Cà Phê Trứng",
name: "Egg Coffee",
category: "cafe",
unitsSold: 340,
revenue: 15300000,
@@ -452,7 +457,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
},
{
productId: 2,
name: "Cà Phê Sữa",
name: "Milk Coffee",
category: "cafe",
unitsSold: 325,
revenue: 9750000,
@@ -463,7 +468,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
},
{
productId: 3,
name: "Bạc Xỉu",
name: "White Coffee",
category: "cafe",
unitsSold: 298,
revenue: 9536000,
@@ -474,7 +479,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
},
{
productId: 1,
name: "Cà Phê Đen",
name: "Black Coffee",
category: "cafe",
unitsSold: 285,
revenue: 7125000,
@@ -485,7 +490,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
},
{
productId: 9,
name: "Sữa Chua Dâu",
name: "Strawberry Yogurt",
category: "sua-chua",
unitsSold: 267,
revenue: 10680000,
@@ -496,7 +501,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
},
{
productId: 10,
name: "Nước Ép Cam",
name: "Fresh Orange Juice",
category: "nuoc-ep",
unitsSold: 241,
revenue: 8435000,
@@ -507,7 +512,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
},
{
productId: 8,
name: "Sữa Chua Trân Châu",
name: "Yogurt with Tapioca Pearls",
category: "sua-chua",
unitsSold: 228,
revenue: 8664000,
@@ -518,7 +523,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
},
{
productId: 7,
name: "Trà Vải Hoa Nhài",
name: "Lychee Jasmine Tea",
category: "tra",
unitsSold: 215,
revenue: 8170000,
@@ -529,7 +534,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
},
{
productId: 15,
name: "Bánh Flan",
name: "Caramel Flan",
category: "giai-khat",
unitsSold: 198,
revenue: 4950000,
@@ -540,7 +545,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
},
{
productId: 11,
name: "Nước Ép Dưa Hấu",
name: "Watermelon Juice",
category: "nuoc-ep",
unitsSold: 182,
revenue: 5460000,
@@ -551,7 +556,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
},
{
productId: 14,
name: "Bánh Mì Nướng Bơ",
name: "Toasted Butter Bread",
category: "giai-khat",
unitsSold: 175,
revenue: 3500000,
@@ -562,7 +567,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
},
{
productId: 16,
name: "Trân Châu Đen",
name: "Black Tapioca Pearls",
category: "topping",
unitsSold: 456,
revenue: 4560000,
@@ -573,7 +578,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
},
{
productId: 17,
name: "Thạch Cà Phê",
name: "Coffee Jelly",
category: "topping",
unitsSold: 389,
revenue: 3890000,
@@ -584,7 +589,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
},
{
productId: 18,
name: "Trân Châu Trắng",
name: "White Tapioca Pearls",
category: "topping",
unitsSold: 342,
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 =====
export const DEPARTMENTS: Department[] = [
@@ -635,9 +624,9 @@ function generateMockShifts(): ShiftSlot[] {
];
const staffPool = [
{ id: 2, name: "Nguyễn Văn An" },
{ id: 3, name: "Trần Thị Bình" },
{ id: 4, name: "Lê Văn Cường" },
{ id: 2, name: "Alex Nguyen" },
{ id: 3, name: "Binh Tran" },
{ id: 4, name: "Cuong Le" },
];
// Generate shifts from April 6 to April 26, 2026
+86
View File
@@ -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;
}
+78
View File
@@ -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);
}
+11
View File
@@ -0,0 +1,11 @@
export interface GqlCartItem {
productId: string;
quantity: number;
}
export interface GqlCart {
id: string;
userId: string;
eateryId: string;
items: GqlCartItem[];
}
+1 -1
View File
@@ -7,7 +7,7 @@ import type { Combo, ComboItem, MenuCategory, Product } from "./types";
// ─── Types ────────────────────────────────────────────────────────────────────
export type ManagerTab = "products" | "combos" | "categories";
export type ManagerTab = "products" | "combos" | "categories" | "menu-items";
interface ManagerContextType {
// Data
+4 -4
View File
@@ -204,14 +204,14 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
staffName: string,
): { success: boolean; error?: string } => {
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) {
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)) {
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 (
@@ -225,7 +225,7 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
) {
return {
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.",
};
}
+16
View File
@@ -25,6 +25,7 @@ export interface Product {
image: string;
description: string;
available?: boolean;
menuItemId?: string; // backend UUID — present when product comes from eatery-service
}
// ===== SHOP INFO TYPES =====
@@ -52,9 +53,24 @@ export interface SocialLinks {
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 =====
export interface Shop {
id: number;
eateryId: string;
name: string;
address: string;
image: string;
+6 -9
View File
@@ -20,16 +20,13 @@ const nextConfig: NextConfig = {
],
},
async rewrites() {
const rewrites = [];
if (process.env.NODE_ENV !== "production") {
rewrites.push({
const gatewayUrl = "http://localhost:32080";
return [
{
source: "/api/:path*",
destination: "http://host.docker.internal:32080/api/:path*",
});
}
return rewrites;
destination: `${gatewayUrl}/api/:path*`,
},
];
},
};