Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df1f32c23d | |||
| 3caa37426e | |||
| cd2de11da0 | |||
| 40b673b9bb | |||
| 582b21ead4 | |||
| 4a43299969 | |||
| 4eaed6c973 |
@@ -49,6 +49,7 @@ export default function LoginOtpPage() {
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ phone, otp }),
|
||||
});
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function ManagerSignupPage() {
|
||||
href="/login"
|
||||
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white"
|
||||
>
|
||||
Quay lại đăng nhập
|
||||
Back to Sign In
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
@@ -122,13 +122,13 @@ export default function ManagerSignupPage() {
|
||||
<i className="fa-solid fa-triangle-exclamation text-2xl text-yellow-500"></i>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">Không thể kết nối</h2>
|
||||
<p className="text-sm text-(--color-text-muted)">Không thể kiểm tra trạng thái đăng ký. Vui lòng thử lại.</p>
|
||||
<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")); }}>
|
||||
Thử lại
|
||||
Retry
|
||||
</Button>
|
||||
<Link href="/login" className="text-sm text-(--color-primary) underline">Quay lại đăng nhập</Link>
|
||||
<Link href="/login" className="text-sm text-(--color-primary) underline">Back to Sign In</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -136,10 +136,10 @@ export default function ManagerSignupPage() {
|
||||
{pageState === "available" && (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{[
|
||||
{ id: "name", label: "Họ tên", icon: "fa-user", placeholder: "Nguyễn Văn A", field: "name" as const, type: "text" },
|
||||
{ id: "phone", label: "Số điện thoại", icon: "fa-phone", placeholder: "0987654321", field: "phone" as const, type: "tel" },
|
||||
{ id: "password", label: "Mật khẩu", icon: "fa-lock", placeholder: "Ít nhất 6 ký tự", field: "password" as const, type: "password" },
|
||||
{ id: "eateryName", label: "Tên nhà hàng", icon: "fa-store", placeholder: "Coffee & More", field: "eateryName" as const, type: "text" },
|
||||
{ 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)">
|
||||
@@ -176,16 +176,16 @@ export default function ManagerSignupPage() {
|
||||
<div className="space-y-3 pt-2">
|
||||
<Button variant="primaryNoBorder" type="submit" style="login" size="lg" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<><i className="fa-solid fa-spinner fa-spin mr-2"></i>Đang xử lý...</>
|
||||
<><i className="fa-solid fa-spinner fa-spin mr-2"></i>Processing...</>
|
||||
) : (
|
||||
"Đăng ký"
|
||||
"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"
|
||||
>
|
||||
Đã có tài khoản? Đăng nhập
|
||||
Already have an account? Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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 có 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">
|
||||
@@ -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,7 +115,7 @@ 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`}
|
||||
>
|
||||
Delete product
|
||||
</Button>
|
||||
@@ -132,6 +133,7 @@ export default function PaymentPage() {
|
||||
totalPrice={totalPrice}
|
||||
isCustomer={isCustomer}
|
||||
backHref="/"
|
||||
eateryId={eateryId ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,6 +116,7 @@ export default function RegisterPage() {
|
||||
const res = await fetch("/api/customer/quick_login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ phone, otp }),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
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 = [
|
||||
{
|
||||
@@ -110,6 +112,18 @@ export default function ManagerPage() {
|
||||
</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">
|
||||
@@ -163,8 +177,8 @@ export default function ManagerPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mobile tabs */}
|
||||
<div className="flex items-center gap-1 lg:hidden">
|
||||
{/* sm–lg: icon row */}
|
||||
<div className="hidden items-center gap-1 sm:flex lg:hidden">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -176,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
|
||||
@@ -184,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">Finance</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 (sm–lg) */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen((o) => !o)}
|
||||
className={`flex cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-xs font-medium transition ${
|
||||
mobileMenuOpen
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-background text-(--color-text-secondary) hover:text-(--color-primary)"
|
||||
}`}
|
||||
>
|
||||
<i className="fa-solid fa-user-tie"></i>
|
||||
</button>
|
||||
{mobileMenuOpen && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-44 rounded-xl border border-(--color-border-light) bg-white p-2 shadow-lg">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)">
|
||||
<i className="fa-solid fa-user-tie text-xs text-(--color-primary)"></i>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-foreground truncate text-xs font-semibold">
|
||||
{user?.name ?? "Manager"}
|
||||
</p>
|
||||
<p className="text-[11px] text-(--color-text-muted)">
|
||||
Store Manager
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-1 border-t border-(--color-border-light)" />
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="hover:bg-background flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||
>
|
||||
<i className="fa-solid fa-house w-4 text-center"></i>
|
||||
Return to home
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
logout();
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-lg border-none bg-transparent px-3 py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
|
||||
>
|
||||
<i className="fa-solid fa-right-from-bracket w-4 text-center"></i>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* xs (< sm): single hamburger → full dropdown */}
|
||||
<div className="relative sm:hidden">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen((o) => !o)}
|
||||
className={`flex cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-sm font-medium transition ${
|
||||
mobileMenuOpen
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-background text-(--color-text-secondary) hover:text-(--color-primary)"
|
||||
}`}
|
||||
>
|
||||
<i
|
||||
className={
|
||||
mobileMenuOpen ? "fa-solid fa-xmark" : "fa-solid fa-bars"
|
||||
}
|
||||
></i>
|
||||
</button>
|
||||
{mobileMenuOpen && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-52 rounded-xl border border-(--color-border-light) bg-white p-2 shadow-lg">
|
||||
{/* User info */}
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)">
|
||||
<i className="fa-solid fa-user-tie text-xs text-(--color-primary)"></i>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-foreground truncate text-xs font-semibold">
|
||||
{user?.name ?? "Manager"}
|
||||
</p>
|
||||
<p className="text-[11px] text-(--color-text-muted)">
|
||||
Store Manager
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-1 border-t border-(--color-border-light)" />
|
||||
{/* Tabs */}
|
||||
<p className="px-3 py-1 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Menu Management
|
||||
</p>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className={`flex w-full cursor-pointer items-center gap-2 rounded-lg border-none px-3 py-2 text-xs font-medium transition ${
|
||||
activeTab === tab.id
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-background)"
|
||||
}`}
|
||||
>
|
||||
<i className={`${tab.icon} w-4 text-center`}></i>
|
||||
<span className="flex-1 text-left">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="my-1 border-t border-(--color-border-light)" />
|
||||
{/* Analytics */}
|
||||
<p className="px-3 py-1 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Analytics
|
||||
</p>
|
||||
<Link
|
||||
href="/manager/analytics"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:bg-(--color-accent-light)"
|
||||
>
|
||||
<i className="fa-solid fa-chart-line w-4 text-center"></i>
|
||||
Finance
|
||||
</Link>
|
||||
<Link
|
||||
href="/staff/schedule"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="hover:bg-background flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||
>
|
||||
<i className="fa-solid fa-calendar-days w-4 text-center"></i>
|
||||
Shifts
|
||||
</Link>
|
||||
<div className="my-1 border-t border-(--color-border-light)" />
|
||||
{/* Staff Management */}
|
||||
<p className="px-3 py-1 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Staff Management
|
||||
</p>
|
||||
<Link
|
||||
href="/manager/create-staff"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="hover:bg-background flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||
>
|
||||
<i className="fa-solid fa-user-plus w-4 text-center"></i>
|
||||
Create Staff Account
|
||||
</Link>
|
||||
<div className="my-1 border-t border-(--color-border-light)" />
|
||||
{/* Actions */}
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="hover:bg-background flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||
>
|
||||
<i className="fa-solid fa-house w-4 text-center"></i>
|
||||
Return to home
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
logout();
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-lg border-none bg-transparent px-3 py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
|
||||
>
|
||||
<i className="fa-solid fa-right-from-bracket w-4 text-center"></i>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop actions */}
|
||||
@@ -197,6 +380,13 @@ export default function ManagerPage() {
|
||||
<i className="fa-solid fa-chart-line"></i>
|
||||
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"
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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 = () => {
|
||||
@@ -86,6 +87,7 @@ export default function PaymentSummaryCard({
|
||||
<ReviewModal
|
||||
isOpen={isReviewOpen}
|
||||
onClose={() => setIsReviewOpen(false)}
|
||||
eateryId={eateryId}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -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,7 +67,7 @@ export default function ProductCard({
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon="fa-cart-plus"
|
||||
aria-label={`Mua ${productName}`}
|
||||
aria-label={`Buy ${productName}`}
|
||||
>
|
||||
Buy
|
||||
</Button>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,6 +29,7 @@ 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)">
|
||||
Mô 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>
|
||||
|
||||
@@ -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 có 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>
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { addManagerMenuItem, gqlFetch } from "@/lib/graphql";
|
||||
import {
|
||||
addMenuItem,
|
||||
deleteMenuItem,
|
||||
gqlFetch,
|
||||
getMenuItemsByEatery,
|
||||
updateMenuItem,
|
||||
} from "@/lib/graphql";
|
||||
import type { MenuItem } from "@/lib/types";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
interface Eatery {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
function formatPrice(price: number) {
|
||||
return price.toLocaleString("vi-VN") + "đ";
|
||||
return price.toLocaleString("en-US") + "₫";
|
||||
}
|
||||
|
||||
export default function MenuItemsTab() {
|
||||
@@ -26,12 +27,24 @@ export default function MenuItemsTab() {
|
||||
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[] }>(
|
||||
@@ -42,15 +55,10 @@ export default function MenuItemsTab() {
|
||||
);
|
||||
}, [user]);
|
||||
|
||||
const loadMenuItems = useCallback(async (id: string): Promise<MenuItem[]> => {
|
||||
const data = await gqlFetch<{ menuItemsByEatery: MenuItem[] }>(
|
||||
`query($eateryId: String!) {
|
||||
menuItemsByEatery(eateryId: $eateryId) { id name price }
|
||||
}`,
|
||||
{ eateryId: id },
|
||||
);
|
||||
return data.menuItemsByEatery ?? [];
|
||||
}, []);
|
||||
const loadMenuItems = useCallback(
|
||||
(id: string) => getMenuItemsByEatery(id),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
@@ -62,7 +70,7 @@ export default function MenuItemsTab() {
|
||||
if (id) setMenuItems(await loadMenuItems(id));
|
||||
else setMenuItems([]);
|
||||
} catch {
|
||||
setError("Không thể tải dữ liệu menu. Kiểm tra kết nối backend.");
|
||||
setError("Cannot load menu data. Please check the backend connection.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -70,19 +78,23 @@ export default function MenuItemsTab() {
|
||||
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 addManagerMenuItem(newName.trim(), parseFloat(newPrice));
|
||||
const added = await addMenuItem({
|
||||
name: newName.trim(),
|
||||
price: parseFloat(newPrice),
|
||||
});
|
||||
if (added) setMenuItems((prev) => [...prev, added]);
|
||||
setNewName("");
|
||||
setNewPrice("");
|
||||
setShowForm(false);
|
||||
} catch {
|
||||
setSubmitError("Không thể thêm món. Vui lòng thử lại.");
|
||||
setSubmitError("Cannot add dish. Please try again.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -95,11 +107,64 @@ export default function MenuItemsTab() {
|
||||
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>
|
||||
Đang tải menu...
|
||||
Loading menu...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -117,7 +182,7 @@ export default function MenuItemsTab() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-store mb-2 block text-3xl opacity-30"></i>
|
||||
<p className="text-sm">Quán của bạn chưa được khởi tạo trong hệ thống.</p>
|
||||
<p className="text-sm">Your eatery has not been initialized in the system.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -127,14 +192,14 @@ export default function MenuItemsTab() {
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
<strong className="text-foreground">{menuItems.length}</strong> món trong menu
|
||||
<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">Thêm món</span>
|
||||
<span className="hidden sm:inline">Add new dish</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -144,10 +209,13 @@ export default function MenuItemsTab() {
|
||||
<thead className="bg-background">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||
Tên món
|
||||
Dish name
|
||||
</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-right font-semibold text-(--color-text-secondary)">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -155,18 +223,18 @@ export default function MenuItemsTab() {
|
||||
{menuItems.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
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>
|
||||
Chưa có món nào trong menu
|
||||
There are no items on the menu yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
menuItems.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="hover:bg-background transition-colors"
|
||||
className="transition-colors hover:bg-background"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-foreground font-medium">{item.name}</p>
|
||||
@@ -174,6 +242,48 @@ export default function MenuItemsTab() {
|
||||
<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>
|
||||
))
|
||||
)}
|
||||
@@ -186,7 +296,7 @@ export default function MenuItemsTab() {
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-lg">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-foreground font-bold">Thêm món mới</h2>
|
||||
<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)"
|
||||
@@ -198,13 +308,13 @@ export default function MenuItemsTab() {
|
||||
<form onSubmit={handleAdd} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Tên món <span className="text-red-500">*</span>
|
||||
Dish name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Nhập tên món..."
|
||||
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"
|
||||
/>
|
||||
@@ -212,13 +322,13 @@ export default function MenuItemsTab() {
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Giá (VNĐ) <span className="text-red-500">*</span>
|
||||
Price (VND) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newPrice}
|
||||
onChange={(e) => setNewPrice(e.target.value)}
|
||||
placeholder="Nhập giá..."
|
||||
placeholder="Enter price..."
|
||||
required
|
||||
min={0}
|
||||
step={500}
|
||||
@@ -239,7 +349,7 @@ export default function MenuItemsTab() {
|
||||
onClick={closeForm}
|
||||
className="flex-1 cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent py-2 text-sm font-medium text-(--color-text-secondary) transition hover:bg-background"
|
||||
>
|
||||
Hủy
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -249,10 +359,90 @@ export default function MenuItemsTab() {
|
||||
{submitting ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner mr-1 animate-spin"></i>
|
||||
Đang lưu...
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Thêm món"
|
||||
"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>
|
||||
|
||||
@@ -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)">
|
||||
Mô 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>
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
export interface ReviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
eateryId?: string;
|
||||
}
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
|
||||
@@ -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 = () => {
|
||||
setSubmitted(true);
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -128,6 +152,13 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
/>
|
||||
</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
|
||||
@@ -142,11 +173,11 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
<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"}
|
||||
>
|
||||
Confirm
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -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 ký ({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 có ai đăng ký
|
||||
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 ký 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 ký
|
||||
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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -27,12 +27,12 @@ export default function ManagerLayout({ children }: ManagerLayoutProps) {
|
||||
<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 có tài khoản
|
||||
Sign in if you don'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 có tài khoản
|
||||
Sign in if you don't have an account
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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") + "₫";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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})`);
|
||||
}
|
||||
}
|
||||
@@ -53,11 +53,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
|
||||
return { ok: true };
|
||||
} else {
|
||||
console.error("Đăng nhập thất bại:", response.status);
|
||||
console.error("Login failed:", response.status);
|
||||
return { ok: false, status: response.status };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Lỗi kết nối API:", error);
|
||||
console.error("API connection error:", error);
|
||||
return { ok: false };
|
||||
}
|
||||
};
|
||||
|
||||
+110
-8
@@ -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;
|
||||
const parsed = JSON.parse(raw) as CartItem[];
|
||||
if (Array.isArray(parsed)) {
|
||||
setItems(parsed.filter((i) => i && i.id && i.quantity > 0));
|
||||
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>;
|
||||
|
||||
@@ -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:
|
||||
|
||||
+53
-12
@@ -1,4 +1,4 @@
|
||||
import type { Eatery } from "./types";
|
||||
import type { Eatery, MenuItem } from "./types";
|
||||
|
||||
export async function gqlFetch<T = Record<string, unknown>>(
|
||||
query: string,
|
||||
@@ -11,25 +11,66 @@ export async function gqlFetch<T = Record<string, unknown>>(
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`GraphQL request failed: ${res.status}`);
|
||||
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 addManagerMenuItem(
|
||||
name: string,
|
||||
price: number,
|
||||
): Promise<{ id: string; name: string; price: number } | null> {
|
||||
const data = await gqlFetch<{
|
||||
addMenuItem: { id: string; name: string; price: number } | null;
|
||||
}>(
|
||||
`mutation($name: String!, $price: Float!) {
|
||||
addMenuItem(name: $name, price: $price) { id name price }
|
||||
export async function getMenuItemsByEatery(
|
||||
eateryId: string,
|
||||
): Promise<MenuItem[]> {
|
||||
const data = await gqlFetch<{ menuItemsByEatery: MenuItem[] }>(
|
||||
`query menuItemsByEatery($eateryId: String!) {
|
||||
menuItemsByEatery(eateryId: $eateryId) { id name price }
|
||||
}`,
|
||||
{ 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 {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { GqlCart } from "./cart.types";
|
||||
|
||||
async function cartFetch<T>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const res = await fetch("/api/cart/graphql", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
const message =
|
||||
Array.isArray(body) && body[0]?.message
|
||||
? (body[0].message as string)
|
||||
: `Cart API error: ${res.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function createCart(eateryId: string): Promise<string> {
|
||||
const data = await cartFetch<{ createCart: string }>(
|
||||
`mutation createCart($eateryId: String) {
|
||||
createCart(eateryId: $eateryId)
|
||||
}`,
|
||||
{ eateryId },
|
||||
);
|
||||
return data.createCart;
|
||||
}
|
||||
|
||||
export async function getCart(cartId: string): Promise<GqlCart> {
|
||||
const data = await cartFetch<{ getCart: GqlCart }>(
|
||||
`query getCart($cartId: String) {
|
||||
getCart(cartId: $cartId) {
|
||||
id
|
||||
userId
|
||||
eateryId
|
||||
items { productId quantity }
|
||||
}
|
||||
}`,
|
||||
{ cartId },
|
||||
);
|
||||
return data.getCart;
|
||||
}
|
||||
|
||||
// quantity > 0: add; quantity < 0: decrease/remove (backend uses Redis hincrby)
|
||||
export async function addItem(
|
||||
cartId: string,
|
||||
menuItemId: string,
|
||||
quantity: number,
|
||||
): Promise<GqlCart> {
|
||||
const data = await cartFetch<{ addItem: GqlCart }>(
|
||||
`mutation addItem($cartId: String, $menuItemId: String, $quantity: BigInteger) {
|
||||
addItem(cartId: $cartId, menuItemId: $menuItemId, quantity: $quantity) {
|
||||
id
|
||||
userId
|
||||
eateryId
|
||||
items { productId quantity }
|
||||
}
|
||||
}`,
|
||||
{ cartId, menuItemId, quantity },
|
||||
);
|
||||
return data.addItem;
|
||||
}
|
||||
|
||||
// Backend has no removeItem — zero out via negative addItem
|
||||
export async function removeItem(
|
||||
cartId: string,
|
||||
menuItemId: string,
|
||||
currentQuantity: number,
|
||||
): Promise<GqlCart> {
|
||||
return addItem(cartId, menuItemId, -currentQuantity);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface GqlCartItem {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface GqlCart {
|
||||
id: string;
|
||||
userId: string;
|
||||
eateryId: string;
|
||||
items: GqlCartItem[];
|
||||
}
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =====
|
||||
@@ -60,9 +61,16 @@ export interface Eatery {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user