Compare commits

..

7 Commits

Author SHA1 Message Date
Thanh Quy - wolf df1f32c23d feat: enhance login, registration, and payment functionalities
- Added `credentials: "include"` to fetch requests in LoginOtpPage and RegisterPage for cookie handling.
- Introduced `eateryId` in PaymentPage to manage cart context.
- Updated ProductCardProps and ShopCardProps to include `eateryId`.
- Enhanced PaymentSummaryCard to accept `eateryId` prop.
- Modified ShopCard to set `eateryId` in cart context on click.
- Implemented menu item management in MenuItemsTab with add, edit, and delete functionalities.
- Created new API functions for handling reviews and cart operations.
- Updated GraphQL queries and mutations for menu items and cart management.
- Added loading states and error handling in ProductGrid and ReviewModal.
- Enhanced local storage management for cart and eatery ID.
2026-05-08 21:59:18 +07:00
Thanh Quy- wolf 3caa37426e Create account for staff 2026-05-08 19:58:07 +07:00
Thanh Quy - wolf cd2de11da0 Transfer to English 2026-05-06 00:55:06 +07:00
Thanh Quy - wolf 40b673b9bb Truy van menu nha hang
Release package / release (pull_request) Successful in 2m32s
2026-05-05 21:36:48 +07:00
Thanh Quy - wolf 582b21ead4 Quy-dev 2026-04-29 16:37:23 +07:00
Thanh Quy - wolf 4a43299969 fix: enhance OTP sending and error handling in registration flow 2026-04-20 14:42:19 +07:00
Thanh Quy - wolf 4eaed6c973 fix: update registration flow to include user state management 2026-04-19 18:52:33 +07:00
69 changed files with 4359 additions and 1349 deletions
-9
View File
@@ -1,9 +0,0 @@
{
"features": {
"ghcr.io/muhmdraouf/devcontainers-features/alpine-apk:0": {
"version": "0.0.1",
"resolved": "ghcr.io/muhmdraouf/devcontainers-features/alpine-apk@sha256:3f5010a1880699fad8f65f71002e56bc5cf57c47c63da36c0efea85958ff9044",
"integrity": "sha256:3f5010a1880699fad8f65f71002e56bc5cf57c47c63da36c0efea85958ff9044"
}
}
}
+1 -4
View File
@@ -13,10 +13,7 @@
},
"customizations": {
"vscode": {
"extensions": [
"esbenp.prettier-vscode",
"anthropic.claude-code"
]
"extensions": ["esbenp.prettier-vscode"]
}
},
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- uses: pnpm/action-setup@v3
with:
version: 10.9.0
version: latest
run_install: false
- name: Get pnpm store directory
+1 -6
View File
@@ -1,14 +1,9 @@
import { MainLayout } from "@/components/templates/main-layout";
import { ManagerProvider } from "@/lib/manager-context";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<MainLayout>
<ManagerProvider>{children}</ManagerProvider>
</MainLayout>
);
return <MainLayout>{children}</MainLayout>;
}
+1
View File
@@ -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 }),
});
+3 -8
View File
@@ -43,7 +43,7 @@ export default function LoginPasswordPage() {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone, password }),
body: JSON.stringify({ phone, password, role: "manager" }),
});
if (res.ok) {
@@ -68,10 +68,7 @@ export default function LoginPasswordPage() {
setErrors({ password: "", general: msg });
}
} catch {
setErrors({
password: "",
general: "Unable to connect, please try again",
});
setErrors({ password: "", general: "Unable to connect, please try again" });
} finally {
setIsLoading(false);
}
@@ -137,9 +134,7 @@ export default function LoginPasswordPage() {
className="absolute top-1/2 right-4 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
aria-label={showPassword ? "Hide password" : "Show password"}
>
<i
className={`fa-solid ${showPassword ? "fa-eye-slash" : "fa-eye"}`}
></i>
<i className={`fa-solid ${showPassword ? "fa-eye-slash" : "fa-eye"}`}></i>
</button>
</div>
{errors.password && (
+34 -140
View File
@@ -14,24 +14,13 @@ export default function ManagerSignupPage() {
const [pageState, setPageState] = useState<PageState>("checking");
const [isLoading, setIsLoading] = useState(false);
const [form, setForm] = useState({
name: "",
phone: "",
password: "",
eateryName: "",
});
const [errors, setErrors] = useState({
name: "",
phone: "",
password: "",
eateryName: "",
submit: "",
});
const [form, setForm] = useState({ name: "", phone: "", password: "", eateryName: "" });
const [errors, setErrors] = useState({ name: "", phone: "", password: "", eateryName: "", submit: "" });
useEffect(() => {
fetch("/api/manager/signup")
.then((res) => {
setPageState("available");
setPageState("available")
// if (res.ok) ;
// else if (res.status === 403) setPageState("closed");
// else setPageState("error");
@@ -39,32 +28,21 @@ export default function ManagerSignupPage() {
.catch(() => setPageState("error"));
}, []);
const validatePhone = (phone: string) =>
/^(0[3|5|7|8|9])[0-9]{8}$/.test(phone);
const validatePhone = (phone: string) => /^(0[3|5|7|8|9])[0-9]{8}$/.test(phone);
const handleChange =
(field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, [field]: e.target.value });
setErrors({ ...errors, [field]: "", submit: "" });
};
const handleChange = (field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, [field]: e.target.value });
setErrors({ ...errors, [field]: "", submit: "" });
};
const validate = () => {
const next = {
name: "",
phone: "",
password: "",
eateryName: "",
submit: "",
};
const next = { name: "", phone: "", password: "", eateryName: "", submit: "" };
if (!form.name.trim()) next.name = "Please enter your full name";
if (!form.phone.trim()) next.phone = "Please enter your phone number";
else if (!validatePhone(form.phone))
next.phone = "Invalid phone number (e.g. 0987654321)";
else if (!validatePhone(form.phone)) next.phone = "Invalid phone number (e.g. 0987654321)";
if (!form.password.trim()) next.password = "Please enter your password";
else if (form.password.length < 6)
next.password = "Password must be at least 6 characters";
if (!form.eateryName.trim())
next.eateryName = "Please enter the restaurant name";
else if (form.password.length < 6) next.password = "Password must be at least 6 characters";
if (!form.eateryName.trim()) next.eateryName = "Please enter the restaurant name";
setErrors(next);
return !next.name && !next.phone && !next.password && !next.eateryName;
};
@@ -89,10 +67,7 @@ export default function ManagerSignupPage() {
ExistedUser: "Phone number already registered",
InvalidPhoneNumber: "Invalid phone number",
};
setErrors({
...errors,
submit: errorMap[errorCode] ?? "An error occurred, please try again",
});
setErrors({ ...errors, submit: errorMap[errorCode] ?? "An error occurred, please try again" });
}
} catch {
setErrors({ ...errors, submit: "Unable to connect, please try again" });
@@ -107,21 +82,10 @@ export default function ManagerSignupPage() {
{/* Logo */}
<div className="mb-8 flex flex-col items-center">
<div className="relative mb-4 h-20 w-20">
<Image
src={SHOP_INFO.logo}
alt={SHOP_INFO.name}
fill
className="object-contain"
sizes="80px"
priority
/>
<Image src={SHOP_INFO.logo} alt={SHOP_INFO.name} fill className="object-contain" sizes="80px" priority />
</div>
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
{SHOP_INFO.name}
</h1>
<p className="text-sm text-(--color-text-muted)">
Create a manager account
</p>
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">{SHOP_INFO.name}</h1>
<p className="text-sm text-(--color-text-muted)">Create a manager account</p>
</div>
{/* Checking */}
@@ -139,19 +103,14 @@ export default function ManagerSignupPage() {
<i className="fa-solid fa-lock text-2xl text-red-500"></i>
</div>
<div className="text-center">
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">
Registration closed
</h2>
<p className="text-sm text-(--color-text-muted)">
The system already has a restaurant. Registration is no longer
available.
</p>
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">Registration closed</h2>
<p className="text-sm text-(--color-text-muted)">The system already has a restaurant. Registration is no longer available.</p>
</div>
<Link
href="/login"
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white"
>
Quay lại đăng nhập
Back to Sign In
</Link>
</div>
)}
@@ -163,36 +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 . 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
<Button variant="primaryNoBorder" style="login" size="lg" onClick={() => { setPageState("checking"); fetch("/api/manager/signup").then((r) => { if (r.ok) setPageState("available"); else if (r.status === 403) setPageState("closed"); else setPageState("error"); }).catch(() => setPageState("error")); }}>
Retry
</Button>
<Link
href="/login"
className="text-sm text-(--color-primary) underline"
>
Quay lại đăng nhập
</Link>
<Link href="/login" className="text-sm text-(--color-primary) underline">Back to Sign In</Link>
</div>
)}
@@ -200,50 +136,17 @@ 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)"
>
<label htmlFor={id} className="mb-2 block text-sm font-medium text-(--color-text-secondary)">
{label}
</label>
<div className="relative">
<i
className={`fa-solid ${icon} absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block`}
></i>
<i className={`fa-solid ${icon} absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block`}></i>
<input
id={id}
type={type}
@@ -271,27 +174,18 @@ export default function ManagerSignupPage() {
)}
<div className="space-y-3 pt-2">
<Button
variant="primaryNoBorder"
type="submit"
style="login"
size="lg"
disabled={isLoading}
>
<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ử
...
</>
<><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"
>
Đã tài khoản? Đăng nhập
Already have an account? Sign In
</Link>
</div>
</form>
+26 -35
View File
@@ -3,21 +3,10 @@
import { SearchBar } from "@/components/molecules/search-bar";
import { CategorySidebar } from "@/components/organisms/navigation";
import { ProductGrid } from "@/components/organisms/product-grid";
import { eateryClient } from "@/lib/apollo-clients";
import { allEateriesQuery } from "@/lib/types";
import { gql } from "@apollo/client";
import { useQuery } from "@apollo/client/react";
import { useRouter } from "next/navigation";
import { MENU_CATEGORIES } from "@/lib/constants";
import { useMenu } from "@/lib/menu-context";
import { useEffect, useState } from "react";
const GET_EATERY_COUNT = gql`
{
allEateries {
id
}
}
`;
/**
* Main page — sidebar + product grid layout.
*
@@ -29,30 +18,11 @@ const GET_EATERY_COUNT = gql`
* - Mobile (< 1024px): collapsed by default
*/
export default function Home() {
const router = useRouter();
const { activeCategory, setActiveCategory } = useMenu();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const { data, loading, error } = useQuery<allEateriesQuery>(
GET_EATERY_COUNT,
{
client: eateryClient,
fetchPolicy: "no-cache",
},
);
useEffect(() => {
if (!loading && data) {
console.log(data);
const count = data.allEateries.length ?? 0;
if (count === 0) {
router.push("/manager-signup");
}
}
}, [data, loading, router]);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1024px)");
// eslint-disable-next-line react-hooks/set-state-in-effect
@@ -62,19 +32,40 @@ export default function Home() {
return () => mq.removeEventListener("change", handler);
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSearchQuery("");
}, [activeCategory]);
const activeCategoryLabel =
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "All";
return (
<div className="bg-background flex min-h-[calc(100vh-var(--spacing-header-height))] items-start">
{/* ── Sidebar ── */}
<CategorySidebar
isOpen={isSidebarOpen}
onToggle={() => setIsSidebarOpen((prev) => !prev)}
activeCategory={activeCategory}
onCategoryChange={setActiveCategory}
/>
{/* ── Main content ── */}
<main className="min-w-0 flex-1 px-4 py-6 md:px-6 lg:px-8">
{/* ── Section heading + search bar ── */}
<div className="mb-5 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
{/* Title + count */}
<div className="shrink-0">
<h1 className="text-foreground text-xl font-bold">
{activeCategoryLabel}
</h1>
</div>
{/* Search bar */}
<SearchBar
value={searchQuery}
onChange={(q) => {
if (q && activeCategory !== "all") setActiveCategory("all");
setSearchQuery(q);
}}
onClear={() => setSearchQuery("")}
+61 -75
View File
@@ -5,27 +5,22 @@ import PaymentSummaryCard from "@/components/molecules/cards/PaymentSummaryCard"
import ReviewModal from "@/components/organisms/modals/ReviewModal";
import { useAuth } from "@/lib/auth-context";
import { useCart } from "@/lib/cart-context";
import { useManager } from "@/lib/manager-context";
import { MenuItemEntity } from "@/lib/types";
import { useState } from "react";
export const formatPrice = (value?: number) =>
(value ?? 0).toLocaleString("vi-VN", { style: "currency", currency: "VND" });
const formatPrice = (value: number) =>
value.toLocaleString("en-US", { style: "currency", currency: "VND" });
export default function PaymentPage() {
const {
items,
totalPrice,
eateryId,
increaseQty,
decreaseQty,
removeFromCart,
setQuantity,
} = useCart();
const { user } = useAuth();
const { products } = useManager();
const findProduct = (id: string): MenuItemEntity =>
products.find((i) => i.id == id)!;
const [isReviewOpen, setIsReviewOpen] = useState(false);
const isCustomer = user?.role === "customer";
@@ -38,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 ? (
{items.length === 0 ? (
<div className="px-4 py-10 text-center text-(--color-text-muted)">
Chưa sản phẩm nào trong giỏ hàng.
There are no products in the shopping cart yet.
</div>
) : (
<div className="overflow-x-auto">
@@ -72,71 +67,61 @@ export default function PaymentPage() {
</tr>
</thead>
<tbody>
{items?.map(
({
productId: id,
priceAtTimeOfAdding: price,
quantity,
}) => {
const { name, description } = findProduct(id);
return (
<tr
key={id}
className="border-t border-(--color-border-light)"
{items.map((item) => (
<tr
key={item.id}
className="border-t border-(--color-border-light)"
>
<td className="text-foreground px-4 py-3 font-medium">
{item.name}
</td>
<td className="px-4 py-3 font-semibold text-(--color-primary)">
{formatPrice(item.price)}
</td>
<td className="max-w-70 px-4 py-3 text-(--color-text-muted)">
<p className="line-clamp-2">{item.description}</p>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<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={`Decrease quantity of ${item.name}`}
>
-
</button>
<input
type="number"
min={1}
value={item.quantity}
onChange={(e) =>
setQuantity(item.id, Number(e.target.value))
}
className="h-8 w-16 rounded-lg border border-(--color-border) bg-transparent text-center"
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={`Increase quantity of ${item.name}`}
>
+
</button>
</div>
</td>
<td className="px-4 py-3 text-right">
<Button
onClick={() => removeFromCart(item.id)}
variant="danger"
size="md"
style="payment"
aria-label={`Remove ${item.name} from cart`}
>
<td className="text-foreground px-4 py-3 font-medium">
{name}
</td>
<td className="px-4 py-3 font-semibold text-(--color-primary)">
{formatPrice(price)}
</td>
<td className="max-w-70 px-4 py-3 text-(--color-text-muted)">
<p className="line-clamp-2">{description}</p>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => decreaseQty(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 ${name}`}
>
-
</button>
<input
type="number"
min={1}
value={quantity}
onChange={(e) =>
setQuantity(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"
/>
<button
onClick={() => increaseQty(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 ${name}`}
>
+
</button>
</div>
</td>
<td className="px-4 py-3 text-right">
<Button
onClick={() => removeFromCart(id)}
variant="danger"
size="md"
style="payment"
aria-label={`Xóa ${name} khỏi giỏ hàng`}
>
Delete product
</Button>
</td>
</tr>
);
},
)}
Delete product
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
@@ -148,6 +133,7 @@ export default function PaymentPage() {
totalPrice={totalPrice}
isCustomer={isCustomer}
backHref="/"
eateryId={eateryId ?? undefined}
/>
</div>
</div>
+3 -3
View File
@@ -91,8 +91,7 @@ export default function RegisterPage() {
ExistedUser: "Phone number already registered",
InvalidPhoneNumber: "Invalid phone number",
};
const msg =
phoneErrorMap[errorCode] ?? "An error occurred, please try again";
const msg = phoneErrorMap[errorCode] ?? "An error occurred, please try again";
setErrors({ phone: msg, otp: "" });
}
} catch {
@@ -117,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 }),
});
@@ -365,7 +365,7 @@ export default function RegisterPage() {
type="button"
onClick={sendOtp}
disabled={otpSending || isLoading}
className="text-sm text-(--color-primary) underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-50"
className="text-sm text-(--color-primary) underline disabled:cursor-not-allowed disabled:opacity-50 disabled:no-underline"
>
{otpSending ? (
<>
+433
View File
@@ -0,0 +1,433 @@
"use client";
import {
BarChart,
LineChart,
PieChart,
ProductTable,
SummaryCard,
} from "@/components/organisms/analytics";
import type { PieSlice } from "@/components/organisms/analytics";
import {
calcChange,
formatCurrency,
formatCurrencyFull,
} from "@/lib/analytics-utils";
import {
MENU_CATEGORIES,
MOCK_PRODUCT_SALES,
MOCK_REVENUE_DAILY,
MOCK_REVENUE_MONTHLY,
MOCK_REVENUE_WEEKLY,
MOCK_REVENUE_YEARLY,
} from "@/lib/constants";
import type { AnalyticsPeriod, RevenueDataPoint } from "@/lib/types";
import Link from "next/link";
import { useMemo, useState } from "react";
// ─── Constants ────────────────────────────────────────────────────────────────
const PERIOD_LABELS: Record<AnalyticsPeriod, string> = {
day: "By day",
week: "By week",
month: "By month",
year: "By year",
};
const CATEGORY_COLORS = [
"#6F4E37",
"#C8973A",
"#A0785A",
"#8B6914",
"#D4A96A",
"#4A3728",
"#F0D9A8",
"#A08060",
"#3D2B1F",
];
const REVENUE_MAP: Record<AnalyticsPeriod, RevenueDataPoint[]> = {
day: MOCK_REVENUE_DAILY,
week: MOCK_REVENUE_WEEKLY,
month: MOCK_REVENUE_MONTHLY,
year: MOCK_REVENUE_YEARLY,
};
const CHART_TYPES = ["line", "bar", "pie"] as const;
type ChartType = (typeof CHART_TYPES)[number];
const CHART_META: Record<ChartType, { icon: string; label: string }> = {
line: { icon: "fa-chart-line", label: "Line" },
bar: { icon: "fa-chart-bar", label: "Bar" },
pie: { icon: "fa-chart-pie", label: "Pie" },
};
// ─── Category filter select ───────────────────────────────────────────────────
function CategorySelect({
value,
onChange,
label = "Category:",
}: {
value: string;
onChange: (v: string) => void;
label?: string;
}) {
const categories = MENU_CATEGORIES.filter((c) => c.id !== "all");
return (
<div className="flex items-center gap-2">
<label className="text-xs text-(--color-text-muted)">{label}</label>
<select
title="Category"
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-background text-foreground rounded-lg border border-(--color-border) px-2 py-1.5 text-xs"
>
<option value="all">All</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function AnalyticsPage() {
const [period, setPeriod] = useState<AnalyticsPeriod>("month");
const [activeChart, setActiveChart] = useState<ChartType>("line");
const [categoryFilter, setCategoryFilter] = useState("all");
// Revenue data for selected period
const revenueData = REVENUE_MAP[period];
// Split into halves for bar comparison
const half = Math.floor(revenueData.length / 2);
const barCurrent = revenueData.slice(half);
const barPrevious = revenueData.slice(0, half).slice(0, barCurrent.length);
// Filtered product sales
const filteredSales = useMemo(
() =>
categoryFilter === "all"
? MOCK_PRODUCT_SALES
: MOCK_PRODUCT_SALES.filter((p) => p.category === categoryFilter),
[categoryFilter],
);
// Summary stats
const totalRevenue = revenueData.reduce((s, d) => s + d.revenue, 0);
const totalOrders = revenueData.reduce((s, d) => s + d.orders, 0);
const totalProfit = filteredSales.reduce((s, d) => s + d.profit, 0);
const avgOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0;
// Period-over-period comparisons
const curRevenue = barCurrent.reduce((s, d) => s + d.revenue, 0);
const prevRevenue = barPrevious.reduce((s, d) => s + d.revenue, 0);
const curOrders = barCurrent.reduce((s, d) => s + d.orders, 0);
const prevOrders = barPrevious.reduce((s, d) => s + d.orders, 0);
const revComp = calcChange(curRevenue, prevRevenue);
const ordComp = calcChange(curOrders, prevOrders);
const proComp = calcChange(curRevenue * 0.65, prevRevenue * 0.65);
// Pie data: revenue by category
const pieData = useMemo((): PieSlice[] => {
const byCategory: Record<string, number> = {};
MOCK_PRODUCT_SALES.forEach((p) => {
byCategory[p.category] = (byCategory[p.category] ?? 0) + p.revenue;
});
return Object.entries(byCategory)
.map(([catId, rev], i) => ({
label: MENU_CATEGORIES.find((c) => c.id === catId)?.name ?? catId,
value: rev,
color: CATEGORY_COLORS[i % CATEGORY_COLORS.length],
}))
.sort((a, b) => b.value - a.value);
}, []);
// Top 5 products
const top5 = useMemo(
() => [...filteredSales].sort((a, b) => b.revenue - a.revenue).slice(0, 5),
[filteredSales],
);
// Totals for summary row
const filteredRevenue = filteredSales.reduce((s, d) => s + d.revenue, 0);
const filteredProfit = filteredSales.reduce((s, d) => s + d.profit, 0);
const filteredUnits = filteredSales.reduce((s, d) => s + d.unitsSold, 0);
const avgMargin =
filteredSales.length > 0
? filteredSales.reduce((s, d) => s + d.profitMargin, 0) /
filteredSales.length
: 0;
return (
<div className="bg-background min-h-screen">
{/* ── Page Header ── */}
<header className="sticky top-0 z-30 border-b border-(--color-border-light) bg-(--color-bg-header) shadow-sm">
<div className="mx-auto flex max-w-screen-2xl items-center gap-4 px-4 py-3">
<Link
href="/manager"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-(--color-text-muted) transition-colors hover:bg-(--color-accent-light) hover:text-(--color-primary)"
>
<i className="fa-solid fa-arrow-left"></i>
</Link>
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-(--color-accent-light) text-(--color-primary)">
<i className="fa-solid fa-chart-line"></i>
</span>
<div>
<h1 className="text-foreground text-lg leading-tight font-bold">
Financial Statistics & Analytics
</h1>
<p className="text-xs text-(--color-text-muted)">
Financial Analytics Dashboard
</p>
</div>
</div>
{/* Period selector */}
<div className="ml-auto flex items-center gap-2">
{(Object.keys(PERIOD_LABELS) as AnalyticsPeriod[]).map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`hidden rounded-lg px-3 py-1.5 text-xs font-medium transition-colors sm:block ${
period === p
? "bg-(--color-primary) text-white"
: "bg-background text-(--color-text-muted) hover:bg-(--color-accent-light)"
}`}
>
{PERIOD_LABELS[p]}
</button>
))}
<select
title="Select period"
value={period}
onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)}
className="text-foreground block rounded-lg border border-(--color-border) bg-(--color-bg-card) px-2 py-1.5 text-xs sm:hidden"
>
{(
Object.entries(PERIOD_LABELS) as [AnalyticsPeriod, string][]
).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
</div>
</div>
</header>
<main className="mx-auto max-w-screen-2xl space-y-6 p-4 pb-10">
{/* ── Summary Cards ── */}
<section>
<h2 className="mb-3 text-sm font-semibold tracking-wider text-(--color-text-muted) uppercase">
Overview
</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<SummaryCard
icon="fa-solid fa-sack-dollar"
title="Total Revenue"
value={formatCurrency(totalRevenue)}
subtitle={PERIOD_LABELS[period]}
change={revComp.change}
changePercent={revComp.changePercent}
isPositive={revComp.isPositive}
/>
<SummaryCard
icon="fa-solid fa-receipt"
title="Total Orders"
value={totalOrders.toLocaleString()}
subtitle="Total orders in period"
change={ordComp.change}
changePercent={ordComp.changePercent}
isPositive={ordComp.isPositive}
/>
<SummaryCard
icon="fa-solid fa-circle-dollar-to-slot"
title="Total Profit"
value={formatCurrency(totalProfit)}
subtitle="Estimated from sales data"
change={proComp.change}
changePercent={proComp.changePercent}
isPositive={proComp.isPositive}
/>
<SummaryCard
icon="fa-solid fa-basket-shopping"
title="Avg. Order Value"
value={formatCurrency(avgOrderValue)}
subtitle="Revenue / number of orders"
change={0}
changePercent={0}
isPositive={true}
/>
</div>
</section>
{/* ── Revenue Chart ── */}
<section className="bg-background rounded-2xl border border-(--color-border-light) p-5 shadow-sm">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-foreground text-base font-semibold">
<i className="fa-solid fa-chart-area mr-2 text-(--color-primary)"></i>
Revenue Chart
</h2>
<div className="flex gap-2">
{CHART_TYPES.map((t) => (
<button
key={t}
onClick={() => setActiveChart(t)}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
activeChart === t
? "bg-(--color-primary) text-white"
: "bg-background text-(--color-text-muted) hover:bg-(--color-accent-light)"
}`}
>
<i className={`fa-solid text-xs ${CHART_META[t].icon}`}></i>
<span className="hidden sm:inline">
{CHART_META[t].label}
</span>
</button>
))}
</div>
</div>
{activeChart === "line" && (
<>
<p className="mb-3 text-xs text-(--color-text-muted)">
Revenue over time {PERIOD_LABELS[period]}
</p>
<LineChart data={revenueData} height={220} />
</>
)}
{activeChart === "bar" && (
<>
<p className="mb-3 text-xs text-(--color-text-muted)">
Comparing revenue of the first and second half of the current period
</p>
<BarChart
current={barCurrent}
previous={barPrevious}
height={220}
/>
</>
)}
{activeChart === "pie" && (
<>
<p className="mb-3 text-xs text-(--color-text-muted)">
Revenue share by product category
</p>
<PieChart data={pieData} />
</>
)}
</section>
{/* ── Top 5 Products ── */}
<section className="rounded-2xl border border-(--color-border-light) bg-(--color-bg-card) p-5 shadow-sm">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-foreground text-base font-semibold">
<i className="fa-solid fa-fire mr-2 text-orange-500"></i>
Top best-selling products
</h2>
<CategorySelect
value={categoryFilter}
onChange={setCategoryFilter}
/>
</div>
<div className="space-y-3">
{top5.map((p, i) => {
const pct = (p.revenue / top5[0].revenue) * 100;
return (
<div key={p.productId}>
<div className="mb-1 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light) text-xs font-bold text-(--color-primary)">
{i + 1}
</span>
<span className="text-foreground truncate text-sm font-medium">
{p.name}
</span>
</div>
<div className="flex shrink-0 items-center gap-3 text-xs">
<span className="text-(--color-text-muted) tabular-nums">
{p.unitsSold} cups
</span>
<span className="font-semibold text-(--color-primary) tabular-nums">
{formatCurrency(p.revenue)}
</span>
</div>
</div>
<div className="bg-background h-2 overflow-hidden rounded-full">
<div
className="h-full rounded-full bg-(--color-primary) transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
</section>
{/* ── Full Product Table ── */}
<section className="rounded-2xl border border-(--color-border-light) bg-(--color-bg-card) p-5 shadow-sm">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-foreground text-base font-semibold">
<i className="fa-solid fa-table text-foreground mr-2"></i>
Detailed product analytics
</h2>
<CategorySelect
value={categoryFilter}
onChange={setCategoryFilter}
label="Filter category:"
/>
</div>
<p className="mb-3 text-xs text-(--color-text-muted)">
Click column headers to sort. Showing {filteredSales.length}{" "}
products.
</p>
<ProductTable data={filteredSales} />
{/* Summary row */}
<div className="bg-background mt-4 flex flex-wrap gap-4 rounded-xl p-4 text-sm">
<div>
<span className="text-(--color-text-muted)">
Total revenue:{" "}
</span>
<span className="font-semibold text-(--color-primary)">
{formatCurrencyFull(filteredRevenue)}
</span>
</div>
<div>
<span className="text-(--color-text-muted)">
Total profit:{" "}
</span>
<span className="font-semibold text-green-600">
{formatCurrencyFull(filteredProfit)}
</span>
</div>
<div>
<span className="text-(--color-text-muted)">
Total units:{" "}
</span>
<span className="text-foreground font-semibold">
{filteredUnits.toLocaleString()} cups
</span>
</div>
<div>
<span className="text-(--color-text-muted)">
Avg. profit margin:{" "}
</span>
<span className="font-semibold text-yellow-700">
{avgMargin.toFixed(1)}%
</span>
</div>
</div>
</section>
</main>
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { Button, TextInput } from "@/components/atoms";
import Link from "next/link";
import { useState } from "react";
interface FormState {
name: string;
phone: string;
password: string;
}
interface FormErrors {
name?: string;
phone?: string;
password?: string;
}
const PHONE_RE = /^0\d{9}$/;
function validate(data: FormState): FormErrors {
const errors: FormErrors = {};
if (!data.name.trim()) errors.name = "Name is required";
if (!data.phone.trim()) {
errors.phone = "Phone number is required";
} else if (!PHONE_RE.test(data.phone.trim())) {
errors.phone = "Must be 10 digits starting with 0 (e.g. 0912345678)";
}
if (!data.password) {
errors.password = "Password is required";
} else if (data.password.length < 6) {
errors.password = "Password must be at least 6 characters";
}
return errors;
}
export default function CreateStaffPage() {
const [form, setForm] = useState<FormState>({ name: "", phone: "", password: "" });
const [errors, setErrors] = useState<FormErrors>({});
const [apiError, setApiError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
setErrors((prev) => ({ ...prev, [name]: undefined }));
setApiError(null);
setSuccess(false);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const validation = validate(form);
if (Object.keys(validation).length > 0) {
setErrors(validation);
return;
}
setLoading(true);
setApiError(null);
try {
const res = await fetch("/api/Staff/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: form.name.trim(),
phone: form.phone.trim(),
password: form.password,
}),
});
if (res.ok) {
setSuccess(true);
setForm({ name: "", phone: "", password: "" });
setErrors({});
} else {
const text = await res.text();
setApiError(text || "Failed to create account. Please try again.");
}
} catch {
setApiError("Connection error. Please check your network and try again.");
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-start justify-center bg-(--color-background) px-4 py-10">
<div className="w-full max-w-md">
<div className="mb-6">
<Link
href="/manager"
className="mb-4 inline-flex items-center gap-1.5 text-sm text-(--color-text-muted) no-underline transition hover:text-(--color-primary)"
>
<i className="fa-solid fa-arrow-left"></i>
Back to Dashboard
</Link>
<h1 className="text-foreground text-2xl font-bold">Create Staff Account</h1>
<p className="mt-1 text-sm text-(--color-text-muted)">
Fill in the details below to register a new staff member.
</p>
</div>
{success && (
<div className="mb-4 flex items-center gap-2 rounded-xl bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
<i className="fa-solid fa-circle-check"></i>
Staff account created successfully.
</div>
)}
{apiError && (
<div className="mb-4 flex items-center gap-2 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
<i className="fa-solid fa-circle-exclamation"></i>
{apiError}
</div>
)}
<div className="rounded-2xl border border-(--color-border-light) bg-white p-8 shadow-sm">
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
<TextInput
label="Full Name"
name="name"
value={form.name}
onChange={handleChange}
placeholder="Enter staff's full name"
error={errors.name}
autoComplete="name"
/>
<TextInput
label="Phone Number"
name="phone"
value={form.phone}
onChange={handleChange}
placeholder="0xxxxxxxxx"
error={errors.phone}
inputMode="tel"
autoComplete="tel"
/>
<TextInput
label="Password"
name="password"
type={showPassword ? "text" : "password"}
value={form.password}
onChange={handleChange}
placeholder="At least 6 characters"
error={errors.password}
icon={showPassword ? "fa-eye-slash" : "fa-eye"}
onIconClick={() => setShowPassword((v) => !v)}
autoComplete="new-password"
/>
<Button
type="submit"
variant="primary"
size="lg"
icon="fa-user-plus"
disabled={loading}
className="mt-2 w-full"
>
{loading ? "Creating..." : "Create Account"}
</Button>
</form>
</div>
</div>
</div>
);
}
+220 -5
View File
@@ -1,15 +1,21 @@
"use client";
import {
CategoriesTab,
CombosTab,
MenuItemsTab,
ProductsTab,
} from "@/components/organisms/manager";
import { useAuth } from "@/lib/auth-context";
import { useManager } from "@/lib/manager-context";
import Link from "next/link";
import { useState } from "react";
export default function ManagerPage() {
const { user, logout } = useAuth();
const { activeTab, setActiveTab, products } = useManager();
const { activeTab, setActiveTab, products, combos, categories } =
useManager();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const tabs = [
{
@@ -18,6 +24,24 @@ export default function ManagerPage() {
icon: "fa-solid fa-utensils",
count: products.length,
},
{
id: "combos" as const,
label: "Combo",
icon: "fa-solid fa-layer-group",
count: combos.length,
},
{
id: "categories" as const,
label: "Categories",
icon: "fa-solid fa-tags",
count: categories.length,
},
{
id: "menu-items" as const,
label: "Menu",
icon: "fa-solid fa-bowl-food",
count: null,
},
];
return (
@@ -88,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">
@@ -141,8 +177,8 @@ export default function ManagerPage() {
</p>
</div>
{/* Mobile tabs */}
<div className="flex items-center gap-1 lg:hidden">
{/* smlg: icon row */}
<div className="hidden items-center gap-1 sm:flex lg:hidden">
{tabs.map((tab) => (
<button
key={tab.id}
@@ -154,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
@@ -162,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 (smlg) */}
<div className="relative">
<button
onClick={() => setMobileMenuOpen((o) => !o)}
className={`flex cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-xs font-medium transition ${
mobileMenuOpen
? "bg-(--color-primary) text-white"
: "bg-background text-(--color-text-secondary) hover:text-(--color-primary)"
}`}
>
<i className="fa-solid fa-user-tie"></i>
</button>
{mobileMenuOpen && (
<div className="absolute right-0 top-full z-50 mt-1 w-44 rounded-xl border border-(--color-border-light) bg-white p-2 shadow-lg">
<div className="flex items-center gap-2 px-3 py-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)">
<i className="fa-solid fa-user-tie text-xs text-(--color-primary)"></i>
</div>
<div className="min-w-0">
<p className="text-foreground truncate text-xs font-semibold">
{user?.name ?? "Manager"}
</p>
<p className="text-[11px] text-(--color-text-muted)">
Store Manager
</p>
</div>
</div>
<div className="my-1 border-t border-(--color-border-light)" />
<Link
href="/"
onClick={() => setMobileMenuOpen(false)}
className="hover:bg-background flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
>
<i className="fa-solid fa-house w-4 text-center"></i>
Return to home
</Link>
<button
onClick={() => {
logout();
setMobileMenuOpen(false);
}}
className="flex w-full cursor-pointer items-center gap-2 rounded-lg border-none bg-transparent px-3 py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
>
<i className="fa-solid fa-right-from-bracket w-4 text-center"></i>
Log out
</button>
</div>
)}
</div>
</div>
{/* xs (< sm): single hamburger → full dropdown */}
<div className="relative sm:hidden">
<button
onClick={() => setMobileMenuOpen((o) => !o)}
className={`flex cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-sm font-medium transition ${
mobileMenuOpen
? "bg-(--color-primary) text-white"
: "bg-background text-(--color-text-secondary) hover:text-(--color-primary)"
}`}
>
<i
className={
mobileMenuOpen ? "fa-solid fa-xmark" : "fa-solid fa-bars"
}
></i>
</button>
{mobileMenuOpen && (
<div className="absolute right-0 top-full z-50 mt-1 w-52 rounded-xl border border-(--color-border-light) bg-white p-2 shadow-lg">
{/* User info */}
<div className="flex items-center gap-2 px-3 py-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)">
<i className="fa-solid fa-user-tie text-xs text-(--color-primary)"></i>
</div>
<div className="min-w-0">
<p className="text-foreground truncate text-xs font-semibold">
{user?.name ?? "Manager"}
</p>
<p className="text-[11px] text-(--color-text-muted)">
Store Manager
</p>
</div>
</div>
<div className="my-1 border-t border-(--color-border-light)" />
{/* Tabs */}
<p className="px-3 py-1 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Menu Management
</p>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
setMobileMenuOpen(false);
}}
className={`flex w-full cursor-pointer items-center gap-2 rounded-lg border-none px-3 py-2 text-xs font-medium transition ${
activeTab === tab.id
? "bg-(--color-primary) text-white"
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-background)"
}`}
>
<i className={`${tab.icon} w-4 text-center`}></i>
<span className="flex-1 text-left">{tab.label}</span>
</button>
))}
<div className="my-1 border-t border-(--color-border-light)" />
{/* Analytics */}
<p className="px-3 py-1 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Analytics
</p>
<Link
href="/manager/analytics"
onClick={() => setMobileMenuOpen(false)}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:bg-(--color-accent-light)"
>
<i className="fa-solid fa-chart-line w-4 text-center"></i>
Finance
</Link>
<Link
href="/staff/schedule"
onClick={() => setMobileMenuOpen(false)}
className="hover:bg-background flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
>
<i className="fa-solid fa-calendar-days w-4 text-center"></i>
Shifts
</Link>
<div className="my-1 border-t border-(--color-border-light)" />
{/* Staff Management */}
<p className="px-3 py-1 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Staff Management
</p>
<Link
href="/manager/create-staff"
onClick={() => setMobileMenuOpen(false)}
className="hover:bg-background flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
>
<i className="fa-solid fa-user-plus w-4 text-center"></i>
Create Staff Account
</Link>
<div className="my-1 border-t border-(--color-border-light)" />
{/* Actions */}
<Link
href="/"
onClick={() => setMobileMenuOpen(false)}
className="hover:bg-background flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
>
<i className="fa-solid fa-house w-4 text-center"></i>
Return to home
</Link>
<button
onClick={() => {
logout();
setMobileMenuOpen(false);
}}
className="flex w-full cursor-pointer items-center gap-2 rounded-lg border-none bg-transparent px-3 py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
>
<i className="fa-solid fa-right-from-bracket w-4 text-center"></i>
Log out
</button>
</div>
)}
</div>
{/* Desktop actions */}
@@ -175,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"
@@ -187,6 +399,9 @@ export default function ManagerPage() {
<main className="flex-1 p-5 md:p-8">
{activeTab === "products" && <ProductsTab />}
{activeTab === "combos" && <CombosTab />}
{activeTab === "categories" && <CategoriesTab />}
{activeTab === "menu-items" && <MenuItemsTab />}
</main>
</div>
</div>
+3 -3
View File
@@ -18,8 +18,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Coffee Shop — Hệ thống đặt món",
description: "Đặt món cà phê, trà, nước ép và nhiều hơn nữa tại Coffee Shop.",
title: "Coffee Shop — Order System",
description: "Order coffee, tea, juice and more at Coffee Shop.",
icons: {
icon: "/favicon/favicon.ico",
shortcut: "/favicon/favicon.ico",
@@ -33,7 +33,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="vi">
<html lang="en">
<head>
{/* FontAwesome 6 — icons used throughout the app */}
<link
+4 -1
View File
@@ -2,6 +2,7 @@
import { AuthProvider } from "@/lib/auth-context";
import { CartProvider } from "@/lib/cart-context";
import { MenuProvider } from "@/lib/menu-context";
/**
* Client-side providers wrapper.
@@ -11,7 +12,9 @@ import { CartProvider } from "@/lib/cart-context";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<CartProvider>{children}</CartProvider>
<MenuProvider>
<CartProvider>{children}</CartProvider>
</MenuProvider>
</AuthProvider>
);
}
+4
View File
@@ -1,3 +1,5 @@
import type { Product } from "@/lib/types";
export interface ProductCardProps {
image: string;
imageAlt?: string;
@@ -9,6 +11,7 @@ export interface ProductCardProps {
export interface ShopCardProps {
id: number;
eateryId: string;
name: string;
address: string;
image: string;
@@ -19,5 +22,6 @@ export interface PaymentSummaryCardProps {
totalPrice: number;
isCustomer: boolean;
backHref: string;
eateryId?: string;
onPay?: () => void;
}
@@ -1,4 +1,3 @@
import { formatPrice } from "@/app/(main)/payment/page";
import Button from "@/components/atoms/buttons/Button";
import { ReviewModal } from "@/components/organisms/modals";
import Link from "next/link";
@@ -6,10 +5,14 @@ import { useState } from "react";
import type { PaymentSummaryCardProps } from "./Card.types";
const formatPrice = (value: number) =>
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
export default function PaymentSummaryCard({
totalPrice,
isCustomer = false,
backHref,
eateryId,
}: PaymentSummaryCardProps) {
const [isReviewOpen, setIsReviewOpen] = useState(false);
const handlePayment = () => {
@@ -84,6 +87,7 @@ export default function PaymentSummaryCard({
<ReviewModal
isOpen={isReviewOpen}
onClose={() => setIsReviewOpen(false)}
eateryId={eateryId}
/>
</aside>
);
+3 -3
View File
@@ -17,7 +17,7 @@ import type { ProductCardProps } from "./Card.types";
*/
export default function ProductCard({
image,
imageAlt = "Ảnh sản phẩm",
imageAlt = "Product image",
productName,
price,
description,
@@ -38,7 +38,7 @@ export default function ProductCard({
</div>
{/* Product image */}
<Image
src={image || "/"}
src={image}
alt={imageAlt}
fill
className="z-1 object-cover"
@@ -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>
+6 -6
View File
@@ -11,22 +11,22 @@ const STATUS_STYLES: Record<
available: {
bg: "bg-blue-50 border-blue-200",
text: "text-blue-700",
label: "Còn trống",
label: "Available",
},
registered: {
bg: "bg-blue-100 border-blue-400",
text: "text-blue-900",
label: "Đã đăng ký",
label: "Registered",
},
approved_leave: {
bg: "bg-purple-50 border-purple-300",
text: "text-purple-700",
label: "Nghỉ phép",
label: "On Leave",
},
absent: {
bg: "bg-red-50 border-red-300",
text: "text-red-700",
label: "Vắng mặt",
label: "Absent",
},
};
@@ -94,7 +94,7 @@ export default function ShiftCard({
{shift.registeredStaff.length > 0 && (
<div className="mt-2 border-t border-current/10 pt-2">
<p className="text-[10px] font-medium tracking-wide uppercase opacity-60">
Nhân viên ({shift.registeredStaff.length}/{shift.maxStaff})
Staff ({shift.registeredStaff.length}/{shift.maxStaff})
</p>
<div className="mt-1 flex flex-wrap gap-1">
{shift.registeredStaff.map((s) => (
@@ -111,7 +111,7 @@ export default function ShiftCard({
{shift.status === "available" && shift.registeredStaff.length === 0 && (
<p className="mt-2 text-[10px] italic opacity-50">
{shift.maxStaff} vị trí còn trống
{shift.maxStaff} spots available
</p>
)}
</button>
+6 -1
View File
@@ -1,9 +1,13 @@
"use client";
import { useCart } from "@/lib/cart-context";
import Image from "next/image";
import Link from "next/link";
import type { ShopCardProps } from "./Card.types";
export default function ShopCard({ name, address, image }: ShopCardProps) {
export default function ShopCard({ name, address, image, eateryId }: ShopCardProps) {
const { setEateryId } = useCart();
return (
<div className="overflow-hidden rounded-2xl border border-(--color-border) bg-(--color-bg-card) shadow-[0_2px_12px_var(--color-shadow-sm)] transition-all duration-250 hover:-translate-y-1 hover:shadow-[0_4px_20px_var(--color-shadow-md)]">
{/* Shop image */}
@@ -25,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>
+209
View File
@@ -0,0 +1,209 @@
"use client";
import { formatCurrency } from "@/lib/analytics-utils";
import type { RevenueDataPoint } from "@/lib/types";
import { useState } from "react";
interface BarChartProps {
current: RevenueDataPoint[];
previous: RevenueDataPoint[];
height?: number;
}
/**
* Pure-SVG grouped bar chart comparing current vs previous period revenue.
* Hover bars show tooltip with label, revenue, and order count.
* Tooltip auto-flips above/below bar top to stay inside the viewBox.
*/
export function BarChart({ current, previous, height = 200 }: BarChartProps) {
const [hovered, setHovered] = useState<{
set: "cur" | "prev";
idx: number;
} | null>(null);
const W = 800;
const H = height;
const padL = 56,
padR = 16,
padT = 16,
padB = 40;
const chartW = W - padL - padR;
const chartH = H - padT - padB;
const n = current.length;
const maxVal =
Math.max(
...current.map((d) => d.revenue),
...previous.map((d) => d.revenue),
) || 1;
const groupW = chartW / n;
const barW = groupW * 0.35;
const gap = groupW * 0.05;
const yTicks = 5;
const gridLines = Array.from({ length: yTicks + 1 }, (_, i) => ({
val: (maxVal / yTicks) * (yTicks - i),
y: padT + (i / yTicks) * chartH,
}));
const step = Math.ceil(n / 8);
return (
<div className="relative w-full overflow-x-auto">
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full"
style={{ height: H, minWidth: 320 }}
onMouseLeave={() => setHovered(null)}
>
{gridLines.map((g, i) => (
<g key={i}>
<line
x1={padL}
y1={g.y}
x2={W - padR}
y2={g.y}
stroke="#E2C9A8"
strokeWidth="1"
strokeDasharray={i === yTicks ? "0" : "4 3"}
/>
<text
x={padL - 6}
y={g.y + 4}
textAnchor="end"
fontSize="10"
fill="#A08060"
>
{formatCurrency(g.val)}
</text>
</g>
))}
{current.map((d, i) => {
const groupX = padL + i * groupW;
const curH = (d.revenue / maxVal) * chartH;
const prevH = ((previous[i]?.revenue ?? 0) / maxVal) * chartH;
const curX = groupX + gap;
const prevX = curX + barW + gap;
const isHovCur = hovered?.set === "cur" && hovered.idx === i;
const isHovPrev = hovered?.set === "prev" && hovered.idx === i;
return (
<g key={i}>
<rect
x={prevX}
y={padT + chartH - prevH}
width={barW}
height={prevH}
rx="3"
fill={isHovPrev ? "#A0785A" : "#E2C9A8"}
style={{ cursor: "pointer", transition: "fill 150ms" }}
onMouseEnter={() => setHovered({ set: "prev", idx: i })}
/>
<rect
x={curX}
y={padT + chartH - curH}
width={barW}
height={curH}
rx="3"
fill={isHovCur ? "#4A3728" : "#6F4E37"}
style={{ cursor: "pointer", transition: "fill 150ms" }}
onMouseEnter={() => setHovered({ set: "cur", idx: i })}
/>
{i % step === 0 && (
<text
x={groupX + groupW / 2}
y={H - 8}
textAnchor="middle"
fontSize="10"
fill="#A08060"
>
{d.label}
</text>
)}
</g>
);
})}
{hovered !== null &&
(() => {
const d =
hovered.set === "cur"
? current[hovered.idx]
: previous[hovered.idx];
if (!d) return null;
const groupX = padL + hovered.idx * groupW;
const tipW = 130,
tipH = 50;
const tipX = Math.min(
Math.max(groupX - tipW / 2, padL),
W - padR - tipW,
);
const barH = (d.revenue / maxVal) * chartH;
const barTopY = padT + chartH - barH;
const aboveY = barTopY - tipH - 8;
const tipY = Math.min(
Math.max(aboveY >= padT ? aboveY : barTopY + 8, padT),
padT + chartH - tipH,
);
return (
<g>
<rect
x={tipX}
y={tipY}
width={tipW}
height={tipH}
rx="6"
fill="#3D2B1F"
opacity="0.92"
/>
<text
x={tipX + tipW / 2}
y={tipY + 15}
textAnchor="middle"
fontSize="10"
fill="#F0D9A8"
>
{d.label} ({hovered.set === "cur" ? "Current" : "Previous"})
</text>
<text
x={tipX + tipW / 2}
y={tipY + 30}
textAnchor="middle"
fontSize="11"
fontWeight="600"
fill="#C8973A"
>
{formatCurrency(d.revenue)}
</text>
<text
x={tipX + tipW / 2}
y={tipY + 44}
textAnchor="middle"
fontSize="10"
fill="#A08060"
>
{d.orders} orders
</text>
</g>
);
})()}
{/* Legend */}
<rect x={padL} y={4} width={10} height={10} rx="2" fill="#6F4E37" />
<text x={padL + 13} y={13} fontSize="10" fill="#6F4E37">
Current
</text>
<rect
x={padL + 65}
y={4}
width={10}
height={10}
rx="2"
fill="#E2C9A8"
/>
<text x={padL + 78} y={13} fontSize="10" fill="#A08060">
Previous
</text>
</svg>
</div>
);
}
@@ -0,0 +1,191 @@
"use client";
import { formatCurrency } from "@/lib/analytics-utils";
import type { RevenueDataPoint } from "@/lib/types";
import { useState } from "react";
interface LineChartProps {
data: RevenueDataPoint[];
height?: number;
}
/**
* Pure-SVG interactive line chart for revenue over time.
* Hover dots show tooltip with label, revenue, and order count.
* Tooltip auto-flips above/below the dot to stay inside the viewBox.
*/
export function LineChart({ data, height = 200 }: LineChartProps) {
const [hovered, setHovered] = useState<number | null>(null);
const W = 800;
const H = height;
const padL = 56,
padR = 16,
padT = 16,
padB = 40;
const chartW = W - padL - padR;
const chartH = H - padT - padB;
const maxRev = Math.max(...data.map((d) => d.revenue));
const range = maxRev || 1;
const points = data.map((d, i) => ({
x: padL + (i / (data.length - 1)) * chartW,
y: padT + chartH - (d.revenue / range) * chartH,
data: d,
index: i,
}));
const pathD = points
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`)
.join(" ");
const areaD =
pathD +
` L ${points[points.length - 1].x.toFixed(1)} ${(padT + chartH).toFixed(1)}` +
` L ${points[0].x.toFixed(1)} ${(padT + chartH).toFixed(1)} Z`;
const yTicks = 5;
const gridLines = Array.from({ length: yTicks + 1 }, (_, i) => ({
val: (range / yTicks) * (yTicks - i),
y: padT + (i / yTicks) * chartH,
}));
const step = Math.ceil(data.length / 10);
return (
<div className="relative w-full overflow-x-auto">
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full"
style={{ height: H, minWidth: 320 }}
onMouseLeave={() => setHovered(null)}
>
<defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#6F4E37" stopOpacity="0.25" />
<stop offset="100%" stopColor="#6F4E37" stopOpacity="0.02" />
</linearGradient>
</defs>
{gridLines.map((g, i) => (
<g key={i}>
<line
x1={padL}
y1={g.y}
x2={W - padR}
y2={g.y}
stroke="#E2C9A8"
strokeWidth="1"
strokeDasharray={i === yTicks ? "0" : "4 3"}
/>
<text
x={padL - 6}
y={g.y + 4}
textAnchor="end"
fontSize="10"
fill="#A08060"
>
{formatCurrency(g.val)}
</text>
</g>
))}
<path d={areaD} fill="url(#areaGrad)" />
<path
d={pathD}
fill="none"
stroke="#6F4E37"
strokeWidth="2.5"
strokeLinejoin="round"
strokeLinecap="round"
/>
{points.map((p, i) =>
i % step === 0 ? (
<text
key={i}
x={p.x}
y={H - 8}
textAnchor="middle"
fontSize="10"
fill="#A08060"
>
{p.data.label}
</text>
) : null,
)}
{points.map((p) => (
<circle
key={p.index}
cx={p.x}
cy={p.y}
r={hovered === p.index ? 5 : 3}
fill={hovered === p.index ? "#C8973A" : "#6F4E37"}
stroke="#FDF6EC"
strokeWidth="2"
style={{ cursor: "pointer", transition: "r 150ms" }}
onMouseEnter={() => setHovered(p.index)}
/>
))}
{hovered !== null &&
(() => {
const p = points[hovered];
const tipW = 120,
tipH = 48;
const tipX = Math.min(
Math.max(p.x - tipW / 2, padL),
W - padR - tipW,
);
const aboveY = p.y - tipH - 10;
const tipY = Math.min(
Math.max(aboveY >= padT ? aboveY : p.y + 10, padT),
padT + chartH - tipH,
);
return (
<g>
<rect
x={tipX}
y={tipY}
width={tipW}
height={tipH}
rx="6"
fill="#3D2B1F"
opacity="0.92"
/>
<text
x={tipX + tipW / 2}
y={tipY + 16}
textAnchor="middle"
fontSize="10"
fill="#F0D9A8"
>
{p.data.label}
</text>
<text
x={tipX + tipW / 2}
y={tipY + 30}
textAnchor="middle"
fontSize="11"
fontWeight="600"
fill="#C8973A"
>
{formatCurrency(p.data.revenue)}
</text>
<text
x={tipX + tipW / 2}
y={tipY + 44}
textAnchor="middle"
fontSize="10"
fill="#A08060"
>
{p.data.orders} orders
</text>
</g>
);
})()}
</svg>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useMemo, useState } from "react";
export interface PieSlice {
label: string;
value: number;
color: string;
}
interface PieChartProps {
data: PieSlice[];
}
/**
* Pure-SVG interactive pie chart.
* Hover a slice or legend item to highlight it and show its percentage.
*/
export function PieChart({ data }: PieChartProps) {
const [hovered, setHovered] = useState<number | null>(null);
const R = 80;
const CX = 110;
const CY = 110;
const total = data.reduce((s, d) => s + d.value, 0) || 1;
const slices = useMemo(() => {
type Acc = { items: ReturnType<typeof makeSlice>[]; angle: number };
const makeSlice = (d: PieSlice, i: number, startAngle: number) => {
const angle = (d.value / total) * 2 * Math.PI;
const endAngle = startAngle + angle;
const midAngle = startAngle + angle / 2;
const x1 = CX + R * Math.cos(startAngle);
const y1 = CY + R * Math.sin(startAngle);
const x2 = CX + R * Math.cos(endAngle);
const y2 = CY + R * Math.sin(endAngle);
const largeArc = angle > Math.PI ? 1 : 0;
const pathD = `M ${CX} ${CY} L ${x1.toFixed(2)} ${y1.toFixed(2)} A ${R} ${R} 0 ${largeArc} 1 ${x2.toFixed(2)} ${y2.toFixed(2)} Z`;
return {
...d,
pathD,
labelX: CX + R * 0.65 * Math.cos(midAngle),
labelY: CY + R * 0.65 * Math.sin(midAngle),
percent: (d.value / total) * 100,
index: i,
endAngle,
};
};
const { items } = data.reduce<Acc>(
(acc, d, i) => {
const slice = makeSlice(d, i, acc.angle);
return { items: [...acc.items, slice], angle: slice.endAngle };
},
{ items: [], angle: -Math.PI / 2 },
);
return items;
}, [data, total]);
return (
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
<svg
viewBox="0 0 220 220"
className="w-full max-w-55 shrink-0"
style={{ height: 220 }}
onMouseLeave={() => setHovered(null)}
>
{slices.map((s) => (
<path
key={s.index}
d={s.pathD}
fill={s.color}
stroke="#FDF6EC"
strokeWidth="2"
style={{ cursor: "pointer", transition: "opacity 200ms" }}
onMouseEnter={() => setHovered(s.index)}
opacity={hovered !== null && hovered !== s.index ? 0.65 : 1}
/>
))}
{hovered !== null && (
<text
x={CX}
y={CY + 5}
textAnchor="middle"
fontSize="12"
fontWeight="bold"
fill="#3D2B1F"
>
{slices[hovered].percent.toFixed(1)}%
</text>
)}
</svg>
{/* Legend */}
<div className="flex flex-wrap gap-x-4 gap-y-2 sm:flex-col">
{slices.map((s) => (
<div
key={s.index}
className="flex cursor-pointer items-center gap-2 text-sm"
onMouseEnter={() => setHovered(s.index)}
onMouseLeave={() => setHovered(null)}
>
<span
className="inline-block h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: s.color }}
/>
<span
className="max-w-35 truncate"
style={{
color: hovered === s.index ? "#3D2B1F" : "#6F4E37",
fontWeight: hovered === s.index ? 600 : 400,
}}
>
{s.label}
</span>
<span className="text-xs text-(--color-text-muted)">
{s.percent.toFixed(1)}%
</span>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,145 @@
"use client";
import { formatCurrencyFull } from "@/lib/analytics-utils";
import { MENU_CATEGORIES } from "@/lib/constants";
import type { ProductSalesStats } from "@/lib/types";
import { useMemo, useState } from "react";
interface ProductTableProps {
data: ProductSalesStats[];
}
const categoryName = (id: string) =>
MENU_CATEGORIES.find((c) => c.id === id)?.name ?? id;
/**
* Sortable product sales table.
* Click column headers to sort ascending/descending.
*/
export function ProductTable({ data }: ProductTableProps) {
const [sortKey, setSortKey] = useState<keyof ProductSalesStats>("revenue");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const sorted = useMemo(
() =>
[...data].sort((a, b) => {
const av = a[sortKey] as number;
const bv = b[sortKey] as number;
return sortDir === "desc" ? bv - av : av - bv;
}),
[data, sortKey, sortDir],
);
const handleSort = (key: keyof ProductSalesStats) => {
if (key === sortKey) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
else {
setSortKey(key);
setSortDir("desc");
}
};
const sortIcon = (col: keyof ProductSalesStats) => (
<i
className={`fa-solid ml-1 text-xs ${
sortKey === col
? sortDir === "desc"
? "fa-sort-down text-(--color-primary)"
: "fa-sort-up text-(--color-primary)"
: "fa-sort text-(--color-text-muted)"
}`}
/>
);
const SortTh = ({
col,
label,
className = "",
}: {
col: keyof ProductSalesStats;
label: string;
className?: string;
}) => (
<th
className={`cursor-pointer px-4 py-3 font-semibold text-(--color-text-secondary) hover:text-(--color-primary) ${className}`}
onClick={() => handleSort(col)}
>
{label} {sortIcon(col)}
</th>
);
return (
<div className="overflow-x-auto rounded-xl border border-(--color-border-light)">
<table className="w-full min-w-175 text-sm">
<thead>
<tr className="bg-background border-b border-(--color-border-light)">
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
#
</th>
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
Product
</th>
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
Category
</th>
<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)">
Cost Price
</th>
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
Selling Price
</th>
<SortTh col="profit" label="Profit" className="text-right" />
<SortTh col="profitMargin" label="Margin" className="text-right" />
</tr>
</thead>
<tbody>
{sorted.map((row, i) => (
<tr
key={row.productId}
className="border-b border-(--color-border-light) bg-(--color-bg-card) transition-colors hover:bg-(--color-accent-light)/30"
>
<td className="px-4 py-3 text-(--color-text-muted)">{i + 1}</td>
<td className="text-foreground px-4 py-3 font-medium">
{row.name}
</td>
<td className="px-4 py-3">
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs text-(--color-primary)">
{categoryName(row.category)}
</span>
</td>
<td className="text-foreground px-4 py-3 text-right tabular-nums">
{row.unitsSold.toLocaleString()}
</td>
<td className="px-4 py-3 text-right font-medium text-(--color-primary) tabular-nums">
{formatCurrencyFull(row.revenue)}
</td>
<td className="px-4 py-3 text-right text-(--color-text-muted) tabular-nums">
{formatCurrencyFull(row.costPrice)}
</td>
<td className="px-4 py-3 text-right text-(--color-text-secondary) tabular-nums">
{formatCurrencyFull(row.sellingPrice)}
</td>
<td className="px-4 py-3 text-right font-medium text-green-600 tabular-nums">
{formatCurrencyFull(row.profit)}
</td>
<td className="px-4 py-3 text-right">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
row.profitMargin >= 70
? "bg-green-100 text-green-700"
: row.profitMargin >= 60
? "bg-yellow-100 text-yellow-700"
: "bg-red-100 text-red-600"
}`}
>
{row.profitMargin.toFixed(1)}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,61 @@
import { formatCurrency } from "@/lib/analytics-utils";
export interface SummaryCardProps {
icon: string;
title: string;
value: string;
change: number;
changePercent: number;
isPositive: boolean;
subtitle?: string;
}
/**
* Summary metric card with period-over-period comparison indicator.
* Used in the Financial Analytics dashboard header row.
*/
export function SummaryCard({
icon,
title,
value,
change,
changePercent,
isPositive,
subtitle,
}: SummaryCardProps) {
return (
<div className="rounded-2xl border border-(--color-border-light) bg-(--color-bg-card) p-5 shadow-sm">
<div className="mb-3 flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-(--color-accent-light) text-lg text-(--color-primary)">
<i className={icon}></i>
</span>
<span className="text-sm font-medium text-(--color-text-muted)">
{title}
</span>
</div>
<p className="text-foreground text-2xl font-bold tabular-nums">{value}</p>
{subtitle && (
<p className="mt-0.5 text-xs text-(--color-text-muted)">{subtitle}</p>
)}
<div
className={`mt-3 flex items-center gap-1.5 text-sm font-medium ${
isPositive ? "text-green-600" : "text-red-500"
}`}
>
<i
className={`fa-solid text-xs ${
isPositive ? "fa-arrow-trend-up" : "fa-arrow-trend-down"
}`}
></i>
<span>
{isPositive ? "+" : ""}
{changePercent.toFixed(1)}%
</span>
<span className="text-xs font-normal text-(--color-text-muted)">
({isPositive ? "+" : ""}
{formatCurrency(change)}) vs previous period
</span>
</div>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
export { BarChart } from "./BarChart";
export { LineChart } from "./LineChart";
export { PieChart } from "./PieChart";
export type { PieSlice } from "./PieChart";
export { ProductTable } from "./ProductTable";
export { SummaryCard } from "./SummaryCard";
export type { SummaryCardProps } from "./SummaryCard";
+1 -1
View File
@@ -11,7 +11,7 @@ export default function CartFab() {
return (
<Link
href="/payment"
aria-label="Đi đến trang thanh toán"
aria-label="Go to payment page"
className="fixed right-5 bottom-6 z-70 flex h-14 w-14 items-center justify-center rounded-full bg-(--color-primary) text-white shadow-xl transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-cart-shopping text-lg"></i>
+2 -8
View File
@@ -21,10 +21,7 @@ export default function LoginForm() {
return false;
}
if (!PHONE_REGEX.test(phone)) {
setErrors({
phone: "Invalid phone number (e.g. 0987654321)",
general: "",
});
setErrors({ phone: "Invalid phone number (e.g. 0987654321)", general: "" });
return false;
}
return true;
@@ -53,10 +50,7 @@ export default function LoginForm() {
} else if (res.status === 404) {
setErrors({ phone: "", general: "Phone number not registered" });
} else {
setErrors({
phone: "",
general: "An error occurred, please try again",
});
setErrors({ phone: "", general: "An error occurred, please try again" });
}
} catch {
setErrors({ phone: "", general: "Unable to connect, please try again" });
+6
View File
@@ -23,10 +23,16 @@ export {
StatusBadge,
DeleteConfirm,
ProductModal,
CategoryModal,
ComboModal,
ProductsTab,
CategoriesTab,
CombosTab,
} from "./manager";
export type {
ProductModalProps,
CategoryModalProps,
ComboModalProps,
DeleteConfirmProps,
StatusBadgeProps,
} from "./manager";
@@ -0,0 +1,110 @@
"use client";
import { useManager } from "@/lib/manager-context";
import type { MenuCategory } from "@/lib/types";
import { useState } from "react";
import CategoryModal from "./CategoryModal";
import DeleteConfirm from "./DeleteConfirm";
export default function CategoriesTab() {
const { categories, products, addCategory, updateCategory, deleteCategory } =
useManager();
const [modalCategory, setModalCategory] = useState<
MenuCategory | null | "new"
>(null);
const [deleteTarget, setDeleteTarget] = useState<MenuCategory | null>(null);
const getProductCount = (catId: string) =>
products.filter((p) => p.category === catId).length;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-(--color-text-muted)">
<strong className="text-foreground">{categories.length}</strong>{" "}
categories
</p>
<button
onClick={() => setModalCategory("new")}
className="flex cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-plus"></i>
<span className="hidden sm:inline">Add category</span>
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{categories.map((cat) => {
const count = getProductCount(cat.id);
return (
<div
key={cat.id}
className="group relative flex items-center gap-4 rounded-2xl border border-(--color-border-light) bg-white p-4 shadow-sm transition hover:shadow-md"
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-(--color-accent-light)">
<i className={`${cat.icon} text-xl text-(--color-primary)`}></i>
</div>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate font-semibold">
{cat.name}
</p>
<p className="text-xs text-(--color-text-muted)">{count} items</p>
</div>
<div className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={() => setModalCategory(cat)}
title="Edit"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:border-(--color-primary-light) hover:text-(--color-primary)"
>
<i className="fa-solid fa-pen text-[11px]"></i>
</button>
<button
onClick={() => setDeleteTarget(cat)}
title="Delete"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
>
<i className="fa-solid fa-trash text-[11px]"></i>
</button>
</div>
</div>
);
})}
{categories.length === 0 && (
<div className="col-span-full flex flex-col items-center gap-3 py-16 text-(--color-text-muted)">
<i className="fa-solid fa-tag text-4xl opacity-30"></i>
<p className="text-sm">No categories yet</p>
</div>
)}
</div>
{modalCategory !== null && (
<CategoryModal
category={modalCategory === "new" ? null : modalCategory}
onSave={(data) => {
if ("id" in data) {
updateCategory(data as MenuCategory);
} else {
addCategory(data);
}
setModalCategory(null);
}}
onClose={() => setModalCategory(null)}
/>
)}
{deleteTarget !== null && (
<DeleteConfirm
name={deleteTarget.name}
onConfirm={() => {
deleteCategory(deleteTarget.id);
setDeleteTarget(null);
}}
onClose={() => setDeleteTarget(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,123 @@
"use client";
import type { MenuCategory } from "@/lib/types";
import { useState } from "react";
import type { CategoryModalProps } from "./Manager.types";
const FA_ICONS = [
"fa-solid fa-mug-hot",
"fa-solid fa-leaf",
"fa-solid fa-jar",
"fa-solid fa-blender",
"fa-solid fa-mug-saucer",
"fa-solid fa-ice-cream",
"fa-solid fa-layer-group",
"fa-solid fa-burger",
"fa-solid fa-pizza-slice",
"fa-solid fa-bowl-food",
"fa-solid fa-candy-cane",
"fa-solid fa-cookie",
"fa-solid fa-cake-candles",
"fa-solid fa-drumstick-bite",
"fa-solid fa-fish",
"fa-solid fa-carrot",
];
export default function CategoryModal({
category,
onSave,
onClose,
}: CategoryModalProps) {
const isEdit = category !== null;
const [form, setForm] = useState<Omit<MenuCategory, "id">>({
name: category?.name ?? "",
icon: category?.icon ?? FA_ICONS[0],
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isEdit && category) {
onSave({ ...form, id: category.id });
} else {
onSave(form);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
<h2 className="text-foreground text-lg font-bold">
{isEdit ? "Edit category" : "Add new category"}
</h2>
<button
title="Close"
onClick={onClose}
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>
</button>
</div>
<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)">
Category name <span className="text-red-500">*</span>
</label>
<input
required
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
placeholder="e.g. Coffee"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-(--color-text-secondary)">
Icon
</label>
<div className="grid grid-cols-8 gap-2">
{FA_ICONS.map((icon) => (
<button
key={icon}
type="button"
onClick={() => setForm({ ...form, icon })}
title={icon}
className={`flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border transition ${
form.icon === icon
? "border-(--color-primary) bg-(--color-primary) text-white"
: "bg-background border-(--color-border-light) text-(--color-text-secondary) hover:border-(--color-primary-light) hover:text-(--color-primary)"
}`}
>
<i className={`${icon} text-sm`}></i>
</button>
))}
</div>
</div>
<div className="flex gap-3 pt-1">
<button
type="button"
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)"
>
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 ? "Save change" : "Add category"}
</button>
</div>
</form>
</div>
</div>
);
}
+237
View File
@@ -0,0 +1,237 @@
"use client";
import type { Combo, Product } from "@/lib/types";
import { useState } from "react";
import type { ComboModalProps } from "./Manager.types";
function formatPrice(price: number) {
return price.toLocaleString("en-US") + "₫";
}
export default function ComboModal({
combo,
products,
onSave,
onClose,
}: ComboModalProps) {
const isEdit = combo !== null;
const [form, setForm] = useState<Omit<Combo, "id">>({
name: combo?.name ?? "",
description: combo?.description ?? "",
price: combo?.price ?? 0,
image: combo?.image ?? "/imgs/products/placeholder.jpg",
items: combo?.items ?? [],
available: combo?.available ?? true,
});
const updateItemQty = (productId: number, qty: number) => {
if (qty <= 0) {
setForm((prev) => ({
...prev,
items: prev.items.filter((i) => i.productId !== productId),
}));
} else {
setForm((prev) => {
const existing = prev.items.find((i) => i.productId === productId);
if (existing) {
return {
...prev,
items: prev.items.map((i) =>
i.productId === productId ? { ...i, quantity: qty } : i,
),
};
}
return {
...prev,
items: [...prev.items, { productId, quantity: qty }],
};
});
}
};
const getQty = (productId: number) =>
form.items.find((i) => i.productId === productId)?.quantity ?? 0;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (form.items.length === 0) return;
if (isEdit && combo) {
onSave({ ...form, id: combo.id });
} else {
onSave(form);
}
};
const inputCls =
"w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20";
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<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 ? "Edit Combo" : "Add New Combo"}
</h2>
<button
title="Close"
onClick={onClose}
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>
</button>
</div>
<form
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<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)">
Combo Name <span className="text-red-500">*</span>
</label>
<input
required
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className={inputCls}
placeholder="e.g. Double Coffee Combo"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Price (VND) <span className="text-red-500">*</span>
</label>
<input
title="Price in Vietnamese đồng"
required
type="number"
min={0}
step={1000}
value={form.price}
onChange={(e) =>
setForm({ ...form, price: Number(e.target.value) })
}
className={inputCls}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Description
</label>
<textarea
title="Description"
rows={2}
value={form.description}
onChange={(e) =>
setForm({ ...form, description: e.target.value })
}
className={`${inputCls} resize-none`}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-(--color-text-secondary)">
Items in the combo{" "}
{form.items.length === 0 && (
<span className="text-xs text-red-500">
(Choose at least one item.)
</span>
)}
</label>
<div className="bg-background max-h-48 space-y-1.5 overflow-y-auto rounded-xl border border-(--color-border-light) p-2">
{products.map((p) => {
const qty = getQty(p.id);
return (
<div
key={p.id}
className="flex items-center justify-between rounded-lg bg-white px-3 py-2 text-sm"
>
<span className="text-foreground flex-1 truncate">
{p.name}
</span>
<span className="mr-3 text-xs text-(--color-text-muted)">
{formatPrice(p.price)}
</span>
<div className="flex items-center gap-1">
<button
title="Decrease"
type="button"
onClick={() => updateItemQty(p.id, qty - 1)}
disabled={qty === 0}
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) disabled:cursor-not-allowed disabled:opacity-40"
>
<i className="fa-solid fa-minus"></i>
</button>
<span className="text-foreground w-5 text-center text-sm font-semibold">
{qty}
</span>
<button
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)"
>
<i className="fa-solid fa-plus"></i>
</button>
</div>
</div>
);
})}
</div>
</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">
Status
</p>
<p className="text-xs text-(--color-text-muted)">
{form.available ? "In stock" : "Temporarily out of stock"}
</p>
</div>
<button
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 ${
form.available ? "bg-(--color-primary)" : "bg-gray-300"
}`}
>
<span
className={`absolute top-0.5 left-0 h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 ${
form.available ? "translate-x-5.5" : "translate-x-0.5"
}`}
/>
</button>
</div>
</div>
<div className="flex gap-3 border-t border-(--color-border-light) px-6 py-4">
<button
type="button"
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)"
>
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 ? "Save change" : "Add combo"}
</button>
</div>
</form>
</div>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useManager } from "@/lib/manager-context";
import type { Combo } from "@/lib/types";
import { useState } from "react";
import ComboModal from "./ComboModal";
import DeleteConfirm from "./DeleteConfirm";
import StatusBadge from "./StatusBadge";
function formatPrice(price: number) {
return price.toLocaleString("en-US") + "₫";
}
export default function CombosTab() {
const {
combos,
products,
addCombo,
updateCombo,
deleteCombo,
toggleComboAvailability,
} = useManager();
const [modalCombo, setModalCombo] = useState<Combo | null | "new">(null);
const [deleteTarget, setDeleteTarget] = useState<Combo | null>(null);
const getProductName = (id: number) =>
products.find((p) => p.id === id)?.name ?? `Item #${id}`;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-(--color-text-muted)">
<strong className="text-foreground">{combos.length}</strong> combo
</p>
<button
onClick={() => setModalCombo("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">Add new combo</span>
</button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{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">No combos available yet.</p>
</div>
) : (
combos.map((combo) => (
<div
key={combo.id}
className="flex flex-col rounded-2xl border border-(--color-border-light) bg-white shadow-sm transition hover:shadow-md"
>
<div className="flex items-start justify-between p-4">
<div className="min-w-0 flex-1">
<h3 className="text-foreground truncate font-semibold">
{combo.name}
</h3>
{combo.description && (
<p className="mt-1 line-clamp-2 text-xs text-(--color-text-muted)">
{combo.description}
</p>
)}
</div>
<button
onClick={() => toggleComboAvailability(combo.id)}
className="ml-3 shrink-0 cursor-pointer border-none bg-transparent"
title="Toggle status"
>
<StatusBadge available={combo.available} />
</button>
</div>
<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">
Include
</p>
<ul className="space-y-0.5">
{combo.items.map((item) => (
<li
key={item.productId}
className="flex items-center justify-between text-xs text-(--color-text-secondary)"
>
<span>{getProductName(item.productId)}</span>
<span className="font-medium">×{item.quantity}</span>
</li>
))}
</ul>
</div>
<div className="flex items-center justify-between border-t border-(--color-border-light) px-4 py-3">
<span className="text-base font-bold text-(--color-primary)">
{formatPrice(combo.price)}
</span>
<div className="flex gap-1.5">
<button
onClick={() => setModalCombo(combo)}
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="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>
</button>
</div>
</div>
</div>
))
)}
</div>
{modalCombo !== null && (
<ComboModal
combo={modalCombo === "new" ? null : modalCombo}
products={products}
onSave={(data) => {
if ("id" in data) {
updateCombo(data as Combo);
} else {
addCombo(data);
}
setModalCombo(null);
}}
onClose={() => setModalCombo(null)}
/>
)}
{deleteTarget !== null && (
<DeleteConfirm
name={deleteTarget.name}
onConfirm={() => {
deleteCombo(deleteTarget.id);
setDeleteTarget(null);
}}
onClose={() => setDeleteTarget(null)}
/>
)}
</div>
);
}
@@ -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>
+17 -3
View File
@@ -1,8 +1,22 @@
import type { MenuItemEntity } from "@/lib/types";
import type { Combo, MenuCategory, Product } from "@/lib/types";
export interface ProductModalProps {
product: MenuItemEntity | null;
onSave: (p: MenuItemEntity) => void;
product: Product | null; // null = add mode
categories: MenuCategory[];
onSave: (p: Omit<Product, "id"> | Product) => void;
onClose: () => void;
}
export interface CategoryModalProps {
category: MenuCategory | null;
onSave: (c: Omit<MenuCategory, "id"> | MenuCategory) => void;
onClose: () => void;
}
export interface ComboModalProps {
combo: Combo | null;
products: Product[];
onSave: (c: Omit<Combo, "id"> | Combo) => void;
onClose: () => void;
}
+290 -91
View File
@@ -1,96 +1,100 @@
"use client";
import { eateryClient } from "@/lib/apollo-clients";
import { useAuth } from "@/lib/auth-context";
import {
MenuItemEntity,
addMenuItemMutation,
allEateriesQuery,
} from "@/lib/types";
import { gql } from "@apollo/client";
import { useMutation, useQuery } from "@apollo/client/react";
import { useEffect, useState } from "react";
addMenuItem,
deleteMenuItem,
gqlFetch,
getMenuItemsByEatery,
updateMenuItem,
} from "@/lib/graphql";
import type { MenuItem } from "@/lib/types";
import { useCallback, useEffect, useState } from "react";
const GET_EATERY_MENU = gql`
query GetEateryMenu {
allEateries {
id
menuItems {
id
name
price
}
}
}
`;
const ADD_MENU_ITEM = gql`
mutation addMenuItem($menuItem: AddMenuItemInput!) {
addMenuItem(menuItem: $menuItem) {
id
name
price
}
}
`;
interface Eatery {
id: string;
ownerId: string;
}
function formatPrice(price: number) {
return price.toLocaleString("vi-VN") + "đ";
return price.toLocaleString("en-US") + "";
}
export default function MenuItemsTab() {
const { user } = useAuth();
const [menuItems, setMenuItems] = useState<MenuItemEntity[]>([]);
const {
data,
loading: gqlLoading,
error: gqlError,
} = useQuery<allEateriesQuery>(GET_EATERY_MENU, {
client: eateryClient,
fetchPolicy: "network-only",
});
const [mutateAddMenuItem] = useMutation<addMenuItemMutation>(ADD_MENU_ITEM, {
client: eateryClient,
});
const [eateryId, setEateryId] = useState<string | null>(null);
const [menuItems, setMenuItems] = useState<MenuItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Add state
const [showForm, setShowForm] = useState(false);
const [newName, setNewName] = useState("");
const [newPrice, setNewPrice] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
useEffect(() => {
if (data?.allEateries?.[0]) {
setMenuItems(data.allEateries[0].menuItems);
}
}, [data]);
// Edit state
const [editItem, setEditItem] = useState<MenuItem | null>(null);
const [editName, setEditName] = useState("");
const [editPrice, setEditPrice] = useState("");
const [editSubmitting, setEditSubmitting] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
// Delete state
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const loadEateryId = useCallback(async (): Promise<string | null> => {
if (!user) return null;
const data = await gqlFetch<{ allEateries: Eatery[] }>(
"{ allEateries { id ownerId } }",
);
return (
data.allEateries.find((e) => e.ownerId === String(user.id))?.id ?? null
);
}, [user]);
const loadMenuItems = useCallback(
(id: string) => getMenuItemsByEatery(id),
[],
);
useEffect(() => {
async function init() {
setLoading(true);
setError(null);
try {
const id = await loadEateryId();
setEateryId(id);
if (id) setMenuItems(await loadMenuItems(id));
else setMenuItems([]);
} catch {
setError("Cannot load menu data. Please check the backend connection.");
} finally {
setLoading(false);
}
}
init();
}, [loadEateryId, loadMenuItems]);
// --- Add ---
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim() || !newPrice) return;
setSubmitting(true);
setSubmitError(null);
try {
const { data: mutationResult } = await mutateAddMenuItem({
variables: {
menuItem: {
name: newName,
price: parseFloat(newPrice),
},
},
const added = await addMenuItem({
name: newName.trim(),
price: parseFloat(newPrice),
});
if (mutationResult?.addMenuItem) {
setMenuItems((prev) => [...prev, mutationResult.addMenuItem]);
closeForm();
}
} catch (err: any) {
console.error("Mutation Error:", err);
setSubmitError(err.message || "Không thể thêm món. Vui lòng thử lại.");
if (added) setMenuItems((prev) => [...prev, added]);
setNewName("");
setNewPrice("");
setShowForm(false);
} catch {
setSubmitError("Cannot add dish. Please try again.");
} finally {
setSubmitting(false);
}
@@ -103,29 +107,99 @@ export default function MenuItemsTab() {
setNewPrice("");
};
if (gqlLoading && menuItems.length === 0)
return <div className="py-16 text-center">Đang tải menu...</div>;
if (gqlError)
// --- 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="py-16 text-center text-red-500">
Lỗi: {gqlError.message}
<div className="flex items-center justify-center py-16 text-(--color-text-muted)">
<i className="fa-solid fa-spinner mr-2 animate-spin"></i>
Loading menu...
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-16 text-red-500">
<i className="fa-solid fa-circle-exclamation mb-2 text-2xl"></i>
<p className="text-sm">{error}</p>
</div>
);
}
if (!eateryId) {
return (
<div className="flex flex-col items-center justify-center py-16 text-(--color-text-muted)">
<i className="fa-solid fa-store mb-2 block text-3xl opacity-30"></i>
<p className="text-sm">Your eatery has not been initialized in the system.</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between">
<p className="text-sm text-(--color-text-muted)">
<strong className="text-foreground">{menuItems.length}</strong> 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>
@@ -135,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>
@@ -146,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 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>
@@ -165,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>
))
)}
@@ -177,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)"
@@ -189,31 +308,31 @@ 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 transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm outline-none transition focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
Giá (VNĐ) <span className="text-red-500">*</span>
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}
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm outline-none transition focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
/>
</div>
@@ -228,9 +347,9 @@ export default function MenuItemsTab() {
<button
type="button"
onClick={closeForm}
className="hover:bg-background flex-1 cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent py-2 text-sm font-medium text-(--color-text-secondary) transition"
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"
@@ -240,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>
+54 -32
View File
@@ -1,20 +1,22 @@
"use client";
import type { MenuItemEntity } from "@/lib/types";
import type { Product } from "@/lib/types";
import { useState } from "react";
import type { ProductModalProps } from "./Manager.types";
export default function ProductModal({
product,
categories,
onSave,
onClose,
}: ProductModalProps) {
const isEdit = product !== null;
const [form, setForm] = useState<MenuItemEntity>({
const [form, setForm] = useState<Omit<Product, "id">>({
name: product?.name ?? "",
category: product?.category ?? categories[0]?.id ?? "",
price: product?.price ?? 0,
imageUrl: product?.imageUrl ?? "/imgs/products/placeholder.jpg",
image: product?.image ?? "/imgs/products/placeholder.jpg",
description: product?.description ?? "",
available: product?.available ?? true,
});
@@ -39,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>
@@ -53,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
@@ -61,31 +63,51 @@ 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)">
Category <span className="text-red-500">*</span>
</label>
<select
required
title="Select category"
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
className={inputCls}
>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Price () <span className="text-red-500">*</span>
</label>
<input
required
type="number"
min={0}
step={1000}
value={form.price}
onChange={(e) =>
setForm({ ...form, price: Number(e.target.value) })
}
className={inputCls}
placeholder="25000"
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Giá (đ) <span className="text-red-500">*</span>
</label>
<input
required
type="number"
min={0}
step={1000}
value={form.price}
onChange={(e) =>
setForm({ ...form, price: Number(e.target.value) })
}
className={inputCls}
placeholder="25000"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
tả
Description
</label>
<textarea
rows={3}
@@ -94,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 ${
@@ -127,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>
+62 -30
View File
@@ -1,7 +1,7 @@
"use client";
import { useManager } from "@/lib/manager-context";
import type { MenuItemEntity } from "@/lib/types";
import type { Product } from "@/lib/types";
import { useState } from "react";
import DeleteConfirm from "./DeleteConfirm";
@@ -9,28 +9,31 @@ 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() {
const {
products,
categories,
addProduct,
updateProduct,
deleteProduct,
toggleProductAvailability,
} = useManager();
const [filterCategory, setFilterCategory] = useState("all");
const [filterStatus, setFilterStatus] = useState<
"all" | "available" | "unavailable"
>("all");
const [search, setSearch] = useState("");
const [modalProduct, setModalProduct] = useState<
MenuItemEntity | null | "new"
>(null);
const [deleteTarget, setDeleteTarget] = useState<MenuItemEntity>(null!);
const [modalProduct, setModalProduct] = useState<Product | null | "new">(
null,
);
const [deleteTarget, setDeleteTarget] = useState<Product | null>(null);
const filtered = products.filter((p) => {
if (filterCategory !== "all" && p.category !== filterCategory) return false;
if (filterStatus === "available" && p.available === false) return false;
if (filterStatus === "unavailable" && p.available !== false) return false;
if (
@@ -42,6 +45,9 @@ export default function ProductsTab() {
return true;
});
const getCategoryName = (id: string) =>
categories.find((c) => c.id === id)?.name ?? id;
return (
<div className="space-y-4">
{/* Toolbar */}
@@ -53,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)"
>
@@ -67,6 +73,20 @@ export default function ProductsTab() {
)}
</div>
<select
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="Filter by category"
>
<option value="all">All categories</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
<select
value={filterStatus}
onChange={(e) =>
@@ -75,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 */}
@@ -103,16 +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)">
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>
@@ -124,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>
) : (
@@ -143,13 +166,21 @@ export default function ProductsTab() {
)}
</div>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1.5 rounded-full bg-(--color-accent-light) px-2.5 py-0.5 text-xs font-medium text-(--color-primary-dark)">
<i
className={`${categories.find((c) => c.id === p.category)?.icon ?? "fa-solid fa-tag"} text-[10px]`}
></i>
{getCategoryName(p.category)}
</span>
</td>
<td className="px-4 py-3 text-right font-semibold text-(--color-primary)">
{formatPrice(p.price)}
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => toggleProductAvailability(p)}
title="Nhấn để đổi trạng thái"
onClick={() => toggleProductAvailability(p.id)}
title="Click to toggle status"
className="cursor-pointer border-none bg-transparent"
>
<StatusBadge available={p.available ?? true} />
@@ -159,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>
@@ -183,9 +214,10 @@ export default function ProductsTab() {
{modalProduct !== null && (
<ProductModal
product={modalProduct === "new" ? null : modalProduct}
onSave={(data: MenuItemEntity) => {
categories={categories}
onSave={(data) => {
if ("id" in data) {
updateProduct(data);
updateProduct(data as Product);
} else {
addProduct(data);
}
@@ -199,10 +231,10 @@ export default function ProductsTab() {
<DeleteConfirm
name={deleteTarget.name}
onConfirm={() => {
deleteProduct(deleteTarget.id!);
setDeleteTarget(null!);
deleteProduct(deleteTarget.id);
setDeleteTarget(null);
}}
onClose={() => setDeleteTarget(null!)}
onClose={() => setDeleteTarget(null)}
/>
)}
</div>
+6
View File
@@ -1,10 +1,16 @@
export { default as StatusBadge } from "./StatusBadge";
export { default as DeleteConfirm } from "./DeleteConfirm";
export { default as ProductModal } from "./ProductModal";
export { default as CategoryModal } from "./CategoryModal";
export { default as ComboModal } from "./ComboModal";
export { default as ProductsTab } from "./ProductsTab";
export { default as CategoriesTab } from "./CategoriesTab";
export { default as CombosTab } from "./CombosTab";
export { default as MenuItemsTab } from "./MenuItemsTab";
export type {
ProductModalProps,
CategoryModalProps,
ComboModalProps,
DeleteConfirmProps,
StatusBadgeProps,
} from "./Manager.types";
@@ -1,6 +1,7 @@
export interface ReviewModalProps {
isOpen: boolean;
onClose: () => void;
eateryId?: string;
}
export interface ConfirmModalProps {
+38 -7
View File
@@ -1,28 +1,52 @@
"use client";
import { Button, Heading, Text, Textarea } from "@/components/atoms";
import { createReview, hasAuthCookie } from "@/lib/api/review";
import { useAuth } from "@/lib/auth-context";
import { useState } from "react";
import type { ReviewModalProps } from "./Modal.types";
export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
export default function ReviewModal({ isOpen, onClose, eateryId }: ReviewModalProps) {
const { user } = useAuth();
const [rating, setRating] = useState(0);
const [hovered, setHovered] = useState(0);
const [review, setReview] = useState("");
const [submitted, setSubmitted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!isOpen) return null;
const handleSubmit = () => {
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>
</>
@@ -1,6 +1,7 @@
"use client";
import { SHOP_INFO } from "@/lib/constants";
import { MENU_CATEGORIES, SHOP_INFO } from "@/lib/constants";
import type { MenuCategory } from "@/lib/types";
import type React from "react";
import type { CategorySidebarProps } from "./Navigation.types";
@@ -17,6 +18,8 @@ import type { CategorySidebarProps } from "./Navigation.types";
export default function CategorySidebar({
isOpen,
onToggle,
activeCategory = "all",
onCategoryChange,
}: CategorySidebarProps) {
return (
<aside
@@ -37,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
@@ -55,6 +58,40 @@ export default function CategorySidebar({
</button>
</div>
{/* ── Category list ── */}
<nav className="flex-1 py-2">
<ul className="flex flex-col gap-0.5 px-2">
{MENU_CATEGORIES.map((cat: MenuCategory) => {
const isActive = activeCategory === cat.id;
return (
<li key={cat.id}>
<button
onClick={() => onCategoryChange?.(cat.id)}
title={!isOpen ? cat.name : undefined}
className={`flex w-full cursor-pointer items-center rounded-xl border-none text-sm font-medium transition-all duration-150 xl:justify-start xl:gap-3 xl:px-3 xl:py-2.5 ${isOpen ? "gap-3 px-3 py-2.5" : "justify-center px-0 py-2.5"} ${
isActive
? "bg-(--color-primary) text-white shadow-sm"
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light) hover:text-(--color-primary-dark)"
} `}
>
{/* Icon */}
<i
className={` ${cat.icon} w-5 shrink-0 text-center text-base ${isActive ? "text-white" : "text-(--color-primary)"} `}
></i>
{/* Label — hidden when collapsed, always shown on xl+ */}
<span
className={`overflow-hidden text-ellipsis whitespace-nowrap ${isOpen ? "block" : "hidden"} xl:block`}
>
{cat.name}
</span>
</button>
</li>
);
})}
</ul>
</nav>
{/* ── Sidebar footer: opening hours ── */}
<div
className={`shrink-0 border-t border-(--color-border) py-3 xl:px-4 ${isOpen ? "px-4" : "flex justify-center px-0"} `}
+111 -24
View File
@@ -2,24 +2,74 @@
import { ProductCard } from "@/components/molecules/cards";
import { useCart } from "@/lib/cart-context";
import { useManager } from "@/lib/manager-context";
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 { addToCart } = useCart();
const { products } = useManager();
const { activeCategory, setActiveCategory } = useMenu();
const { addToCart, eateryId } = useCart();
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;
const matchesSearch =
searchQuery.trim() === "" ||
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description.toLowerCase().includes(searchQuery.toLowerCase());
return isAvailable && matchesSearch;
return isAvailable && matchesCategory && matchesSearch;
});
const gridCols = isSidebarOpen
@@ -28,31 +78,68 @@ export default function ProductGrid({
return (
<>
{/* ── Product grid ── */}
{filteredProducts.length > 0 ? (
<div className={`grid gap-4 ${gridCols}`}>
{filteredProducts.map(
({ id, imageUrl, name, price, description }) => (
<ProductCard
key={id}
image={imageUrl}
imageAlt={name}
productName={name}
price={price}
description={description}
onBuy={() => addToCart({ productId: id!, quantity: 1 })}
/>
),
)}
{/* ── Mobile category menu — visible only on < md ── */}
<div className="bg-background sticky top-18 z-50 -mx-4 mb-4 overflow-x-auto px-4 pt-2 md:hidden">
<div className="flex items-center gap-1.5 pb-1">
{MENU_CATEGORIES.map((cat) => {
const isActive = activeCategory === cat.id;
return (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`flex shrink-0 cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-sm font-medium whitespace-nowrap transition-all duration-150 ${
isActive
? "bg-(--color-primary) text-white shadow-sm"
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light) hover:text-(--color-primary-dark)"
} `}
>
<i
className={` ${cat.icon} shrink-0 text-sm ${isActive ? "text-white" : "text-(--color-primary)"} `}
></i>
<span>{cat.name}</span>
</button>
);
})}
</div>
) : (
/* Empty state */
</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 ── */}
{!loading && filteredProducts.length > 0 && (
<div className={`grid gap-4 ${gridCols}`}>
{filteredProducts.map((product) => (
<ProductCard
key={product.id}
image={product.image}
imageAlt={product.name}
productName={product.name}
price={product.price}
description={product.description}
onBuy={() => addToCart(product)}
/>
))}
</div>
)}
{/* ── 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>
)}
@@ -178,9 +178,7 @@ export default function MobileShiftView({
</div>
<div className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500"></span>
<span className="text-[10px] text-(--color-text-muted)">
Registered
</span>
<span className="text-[10px] text-(--color-text-muted)">Registered</span>
</div>
<div className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
@@ -190,9 +188,7 @@ export default function MobileShiftView({
</div>
<div className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-red-400"></span>
<span className="text-[10px] text-(--color-text-muted)">
Absent
</span>
<span className="text-[10px] text-(--color-text-muted)">Absent</span>
</div>
</div>
</div>
@@ -203,8 +199,7 @@ export default function MobileShiftView({
{dayOfWeek}, {selectedDateObj.getDate()}/
{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()}
<span className="ml-2 text-xs font-normal text-(--color-text-muted)">
({selectedShifts.length} shift
{selectedShifts.length !== 1 ? "s" : ""})
({selectedShifts.length} shift{selectedShifts.length !== 1 ? "s" : ""})
</span>
</h3>
@@ -140,7 +140,7 @@ export default function ShiftCreateModal({
{/* Department */}
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Bộ phận
Department
</label>
<select
title="Department"
@@ -160,7 +160,7 @@ export default function ShiftCreateModal({
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Số nhân viên tối đa
Max Staff
</label>
<input
title="Max Staff"
@@ -174,7 +174,7 @@ export default function ShiftCreateModal({
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Lương ca (VND)
Wage (VND)
</label>
<input
title="Wage"
@@ -203,14 +203,14 @@ export default function ShiftCreateModal({
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
>
<i className="fa-solid fa-plus mr-2"></i>
Tạo ca làm
Create Shift
</button>
<button
type="button"
onClick={onClose}
className="cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
>
Hủy
Cancel
</button>
</div>
</form>
@@ -32,10 +32,10 @@ export default function ShiftDetailModal({
setSuccess(null);
const result = registerShift(shift.id, user.id, user.name);
if (result.success) {
setSuccess("Đăng ký ca thành công!");
setSuccess("Shift registered successfully!");
setTimeout(onClose, 1200);
} else {
setError(result.error ?? "Có lỗi xảy ra.");
setError(result.error ?? "An error occurred.");
}
};
@@ -43,13 +43,13 @@ export default function ShiftDetailModal({
if (!user) return;
setError(null);
unregisterShift(shift.id, user.id);
setSuccess("Đã hủy đăng ký ca.");
setSuccess("Shift registration cancelled.");
setTimeout(onClose, 1200);
};
const handleManagerUnregister = (staffId: number) => {
unregisterShift(shift.id, staffId);
setSuccess("Đã xóa nhân viên khỏi ca.");
setSuccess("Staff removed from shift.");
};
const handleDelete = () => {
@@ -58,10 +58,10 @@ export default function ShiftDetailModal({
};
const statusLabel = {
available: "Còn trống",
registered: "Đã đăng ký",
approved_leave: "Nghỉ phép",
absent: "Vắng mặt",
available: "Available",
registered: "Registered",
approved_leave: "On Leave",
absent: "Absent",
};
const statusColor = {
@@ -84,7 +84,7 @@ export default function ShiftDetailModal({
{dept && <i className={`${dept.icon} text-(--color-primary)`}></i>}
<div>
<h2 className="text-foreground text-base font-bold">
Chi tiết ca làm
Shift Details
</h2>
<p className="text-xs text-(--color-text-muted)">{dept?.name}</p>
</div>
@@ -114,11 +114,11 @@ export default function ShiftDetailModal({
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Ngày
Date
</p>
<p className="text-foreground mt-1 text-sm font-bold">
{new Date(shift.date + "T00:00:00").toLocaleDateString(
"vi-VN",
"en-US",
{
weekday: "long",
day: "2-digit",
@@ -130,7 +130,7 @@ export default function ShiftDetailModal({
</div>
<div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Giờ làm
Work Hours
</p>
<p className="text-foreground mt-1 text-sm font-bold">
{shift.startTime} {shift.endTime}
@@ -138,18 +138,18 @@ export default function ShiftDetailModal({
</div>
<div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Thời lượng
Duration
</p>
<p className="text-foreground mt-1 text-sm font-bold">
{shift.durationHours} giờ
{shift.durationHours} hrs
</p>
</div>
<div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Lương ca
Shift Wage
</p>
<p className="mt-1 text-sm font-bold text-(--color-primary)">
{shift.wage.toLocaleString("vi-VN")} VND
{shift.wage.toLocaleString("en-US")} VND
</p>
</div>
</div>
@@ -157,12 +157,12 @@ export default function ShiftDetailModal({
{/* Registered staff */}
<div>
<p className="mb-2 text-xs font-semibold text-(--color-text-secondary)">
Nhân viên đã đăng ({shift.registeredStaff.length}/
Registered Staff ({shift.registeredStaff.length}/
{shift.maxStaff})
</p>
{shift.registeredStaff.length === 0 ? (
<p className="text-xs text-(--color-text-muted) italic">
Chưa ai đăng
No one registered yet
</p>
) : (
<div className="space-y-2">
@@ -186,7 +186,7 @@ export default function ShiftDetailModal({
className="cursor-pointer rounded-lg border-none bg-transparent px-2 py-1 text-xs text-red-500 transition hover:bg-red-50"
>
<i className="fa-solid fa-user-minus mr-1"></i>
Xóa
Remove
</button>
)}
</div>
@@ -222,7 +222,7 @@ export default function ShiftDetailModal({
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
>
<i className="fa-solid fa-calendar-plus mr-2"></i>
Đăng ca
Register Shift
</button>
)}
{isRegistered && (
@@ -232,7 +232,7 @@ export default function ShiftDetailModal({
className="flex-1 cursor-pointer rounded-xl border border-red-200 bg-transparent px-4 py-2.5 text-sm font-semibold text-red-600 transition hover:bg-red-50"
>
<i className="fa-solid fa-calendar-minus mr-2"></i>
Hủy đăng
Cancel Registration
</button>
)}
{isManager && (
@@ -242,7 +242,7 @@ export default function ShiftDetailModal({
className="cursor-pointer rounded-xl border border-red-200 bg-transparent px-4 py-2.5 text-sm font-semibold text-red-600 transition hover:bg-red-50"
>
<i className="fa-solid fa-trash mr-2"></i>
Xóa ca
Delete Shift
</button>
)}
<button
@@ -250,7 +250,7 @@ export default function ShiftDetailModal({
onClick={onClose}
className="cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
>
Đóng
Close
</button>
</div>
</div>
+2 -3
View File
@@ -23,9 +23,7 @@ export default function ShopGrid({
return (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
<i className="fa-solid fa-store text-5xl opacity-30"></i>
<p className="text-base font-medium">
No shops found matching your search
</p>
<p className="text-base font-medium">No shops found matching your search</p>
</div>
);
}
@@ -36,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 tài khoản
Sign in if you don&apos;t have an account
</Link>
</div>
</div>
@@ -27,12 +27,12 @@ export default function StaffLayout({ children }: StaffLayoutProps) {
<div className="bg-background flex min-h-screen items-center justify-center">
<div className="flex flex-col items-center gap-4 text-(--color-text-muted)">
<i className="fa-solid fa-spinner fa-spin text-3xl text-(--color-primary)"></i>
<p className="text-sm">Đang kiểm tra quyền truy cập...</p>
<p className="text-sm">Checking access...</p>
<Link
href="/login"
className="text-sm font-medium text-(--color-primary) hover:underline"
>
Đăng nhập nếu chưa tài khoản
Sign in if you don&apos;t have an account
</Link>
</div>
</div>
+1 -1
View File
@@ -4,7 +4,7 @@ RUN apk add --no-cache libc6-compat
WORKDIR /app
# Cài đặt pnpm
RUN npm install -g pnpm@10.9.0
RUN npm install -g pnpm
# Copy file định nghĩa package để tận dụng cache của Docker
COPY package.json pnpm-lock.yaml ./
+1 -1
View File
@@ -16,7 +16,7 @@ spec:
spec:
containers:
- name: frontend-container
image: git.demonkernel.io.vn/foodsurf/frontend:1.2.4
image: git.demonkernel.io.vn/foodsurf/frontend:1.2.3
ports:
- containerPort: 3000
resources:
+4 -4
View File
@@ -5,10 +5,10 @@
* e.g. 1_500_000 → "1.5 tr", 25_000 → "25 k"
*/
export function formatCurrency(value: number): string {
if (value >= 1_000_000_000) return (value / 1_000_000_000).toFixed(1) + " tỷ";
if (value >= 1_000_000) return (value / 1_000_000).toFixed(1) + " tr";
if (value >= 1_000_000_000) return (value / 1_000_000_000).toFixed(1) + " B";
if (value >= 1_000_000) return (value / 1_000_000).toFixed(1) + " M";
if (value >= 1_000) return (value / 1_000).toFixed(0) + " k";
return value.toLocaleString("vi-VN") + "đ";
return value.toLocaleString() + "";
}
/**
@@ -16,7 +16,7 @@ export function formatCurrency(value: number): string {
* e.g. 25_000 → "25.000đ"
*/
export function formatCurrencyFull(value: number): string {
return value.toLocaleString("vi-VN") + "đ";
return value.toLocaleString("en-US") + "";
}
/**
+24
View File
@@ -0,0 +1,24 @@
export interface SendReview {
eateryId: string;
rating: number;
comment: string;
}
export function hasAuthCookie(): boolean {
if (typeof document === "undefined") return false;
return document.cookie.split(";").some((c) => c.trim().startsWith("auth="));
}
export async function createReview(input: SendReview): Promise<void> {
const res = await fetch("/api/eatery/review", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Failed to submit review (${res.status})`);
}
}
-52
View File
@@ -1,52 +0,0 @@
import {
ApolloClient,
ApolloLink,
HttpLink,
InMemoryCache,
Observable,
} from "@apollo/client";
const responseWrapperLink = new ApolloLink((operation, forward) => {
return new Observable((observer) => {
const handle = forward(operation).subscribe({
next: (response) => {
// Kiểm tra nếu dữ liệu trả về bị "trần" (thiếu data property)
if (
response &&
!Object.prototype.hasOwnProperty.call(response, "data")
) {
// Khởi tạo một object mới theo chuẩn GraphQL
const formattedResponse = {
data: response,
errors: (response as any).errors,
};
observer.next(formattedResponse);
} else {
observer.next(response);
}
},
error: observer.error.bind(observer),
complete: observer.complete.bind(observer),
});
return () => {
if (handle) handle.unsubscribe();
};
});
});
export const cartClient = new ApolloClient({
link: ApolloLink.from([
responseWrapperLink,
new HttpLink({ uri: "/api/cart/graphql" }),
]),
cache: new InMemoryCache(),
});
export const eateryClient = new ApolloClient({
link: ApolloLink.from([
responseWrapperLink,
new HttpLink({ uri: "/api/eatery/graphql" }),
]),
cache: new InMemoryCache(),
});
+4 -10
View File
@@ -14,10 +14,7 @@ interface AuthContextType {
user: User | null;
setUser: (user: User | null) => void;
isInitialized: boolean;
login: (
username: string,
password: string,
) => Promise<{ ok: boolean; status?: number }>;
login: (username: string, password: string) => Promise<{ ok: boolean; status?: number }>;
logout: () => void;
}
@@ -39,10 +36,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setIsInitialized(true);
}, []);
const login = async (
username: string,
password: string,
): Promise<{ ok: boolean; status?: number }> => {
const login = async (username: string, password: string): Promise<{ ok: boolean; status?: number }> => {
const isPhone = /^0\d{9}$/.test(username.trim());
const role = isPhone ? "customer" : "manager";
@@ -59,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 };
}
};
+193 -149
View File
@@ -1,192 +1,236 @@
"use client";
import { gql } from "@apollo/client";
import { useMutation, useQuery } from "@apollo/client/react";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { cartClient, eateryClient } from "./apollo-clients";
import {
CartEntity,
CartItemEntity,
addMenuItemMutation,
allEateriesQuery,
createCartMutation,
getCartQuery,
} from "./types";
addItem as addItemGql,
createCart as createCartGql,
} from "@/lib/graphql/cart";
import type { Product } from "@/lib/types";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
interface CartContextValue {
items: CartItemEntity[];
totalItems: number;
totalPrice: number;
addToCart: (product: CartItemEntity) => void;
increaseQty: (id: string) => void;
decreaseQty: (id: string) => void;
removeFromCart: (id: string) => void;
setQuantity: (id: string, quantity: number) => void;
export interface CartItem {
id: number;
name: string;
description: string;
price: number;
quantity: number;
menuItemId?: string; // backend UUID for GraphQL sync
}
const CART_ID = "cartId";
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;
removeFromCart: (id: number) => void;
setQuantity: (id: number, quantity: number) => void;
}
const STORAGE_KEY = "coffee-shop-cart";
const EATERY_KEY = "coffee-shop-eatery-id";
const CART_ID_KEY = "coffee-shop-cart-id";
const CartContext = createContext<CartContextValue | null>(null);
const GET_CART_ITEMS = gql`
query getCart($cartId: String!) {
getCart(cartId: $cartId) {
Id
userId
eateryId
items {
productId
quantity
priceAtTimeOfAdding
subTotal
}
totalAmount
paymentQrUrl
}
}
`;
const GET_EATERY = gql`
query GetEateryMenu {
allEateries {
id
}
}
`;
const CREATE_CART = gql`
mutation createCart($eateryId: String!) {
createCart(eateryId: $eateryId)
}
`;
const ADD_ITEM = gql`
mutation addItem(
$cartId: String!
$menuItemId: String!
$quantity: BigInteger!
) {
addItem(cartId: $cartId, menuItemId: $menuItemId, quantity: $quantity) {
Id
userId
eateryId
items {
productId
quantity
priceAtTimeOfAdding
subTotal
}
totalAmount
paymentQrUrl
}
}
`;
export function CartProvider({ children }: { children: React.ReactNode }) {
const [cart, setCart] = useState<CartEntity>(null!);
const [cartId, setCartId] = useState<string | null>(null);
const [items, setItems] = useState<CartItem[]>([]);
const [eateryId, setEateryIdState] = useState<string | null>(null);
const [backendCartId, setBackendCartId] = useState<string | null>(null);
const { data: eateryData } = useQuery<allEateriesQuery>(GET_EATERY, {
client: eateryClient,
});
const [createCart] = useMutation<createCartMutation>(CREATE_CART, {
client: cartClient,
});
const { data, loading, error } = useQuery<getCartQuery>(GET_CART_ITEMS, {
client: cartClient,
variables: { cartId },
});
const [addMenuItem] = useMutation<addMenuItemMutation>(ADD_ITEM, {
client: cartClient,
});
// 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(() => {
const createCartFunc = async () => {
if (eateryData && eateryData.allEateries?.length > 0) {
try {
const firstEateryId = eateryData.allEateries[0].id;
const { data: mutationResult } = await createCart({
variables: { eateryId: firstEateryId },
});
const newCartId = mutationResult!.createCart;
if (newCartId) {
localStorage.setItem(CART_ID, newCartId);
setCartId(newCartId);
}
} catch (err) {
console.error("Lỗi khi tạo giỏ hàng:", err);
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as CartItem[];
if (Array.isArray(parsed)) {
setItems(parsed.filter((i) => i && i.id && i.quantity > 0));
}
}
};
if (error) createCartFunc();
else {
const localCartId = localStorage.getItem(CART_ID);
if (localCartId) setCartId(localCartId);
} catch {
localStorage.removeItem(STORAGE_KEY);
}
}, [eateryData, createCart, data, loading, error]);
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(() => {
if (data?.getCart) setCart(data.getCart);
}, [data]);
backendCartIdRef.current = backendCartId;
}, [backendCartId]);
const addToCart = async (product: CartItemEntity) => {
if (!cartId) return;
useEffect(() => {
eateryIdRef.current = eateryId;
}, [eateryId]);
const { data: result } = await addMenuItem({
variables: {
cartId,
menuItemId: product.productId!,
quantity: product.quantity,
},
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) {
return [
...prev,
{
id: product.id,
name: product.name,
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 (result) setCart(result.addItem);
if (product.menuItemId) {
const mid = product.menuItemId;
ensureBackendCartId()
.then((cartId) => (cartId ? addItemGql(cartId, mid, 1) : undefined))
.catch(() => setItems(snapshot));
}
};
const setQuantity = async (id: string, quantity: number) => {
if (!cartId) return;
const { data: result } = await addMenuItem({
variables: {
cartId,
menuItemId: id,
quantity,
},
});
if (result) setCart(result.addItem);
const increaseQty = (id: number) => {
setItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, quantity: item.quantity + 1 } : item,
),
);
};
const removeFromCart = (id: string) => setQuantity(id, 0);
const decreaseQty = (id: number) => {
setItems((prev) =>
prev
.map((item) =>
item.id === id
? { ...item, quantity: Math.max(0, item.quantity - 1) }
: item,
)
.filter((item) => item.quantity > 0),
);
};
const increaseQty = (id: string) =>
setQuantity(id, cart.items.find((i) => i.productId == id)!.quantity + 1);
const setEateryId = (id: string) => {
setEateryIdState(id);
eateryIdRef.current = id;
localStorage.setItem(EATERY_KEY, id);
};
const decreaseQty = (id: string) =>
setQuantity(id, cart.items.find((i) => i.productId == id)!.quantity - 1);
const removeFromCart = (id: number) => {
const item = items.find((i) => i.id === id);
const snapshot = items;
setItems((prev) => prev.filter((i) => i.id !== id));
if (item?.menuItemId && backendCartIdRef.current) {
const cartId = backendCartIdRef.current;
addItemGql(cartId, item.menuItemId, -item.quantity).catch(() =>
setItems(snapshot),
);
}
};
const setQuantity = (id: number, quantity: number) => {
const safeQty = Number.isFinite(quantity)
? Math.max(0, Math.floor(quantity))
: 0;
if (safeQty === 0) {
removeFromCart(id);
return;
}
setItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, quantity: safeQty } : item,
),
);
};
const totalItems = useMemo(
() => cart?.items.reduce((sum, item) => sum + item.quantity, 0),
[cart],
() => items.reduce((sum, item) => sum + item.quantity, 0),
[items],
);
const totalPrice = useMemo(
() => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
[items],
);
const value = useMemo(
() => ({
items: cart?.items,
items,
totalItems,
totalPrice: cart?.totalAmount,
totalPrice,
eateryId,
backendCartId,
setEateryId,
addToCart,
increaseQty,
decreaseQty,
removeFromCart,
setQuantity,
}),
[cart?.items, cart?.totalAmount],
// eslint-disable-next-line react-hooks/exhaustive-deps
[items, totalItems, totalPrice, eateryId, backendCartId],
);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
+462 -9
View File
@@ -1,6 +1,8 @@
import type {
Combo,
Department,
MenuItemEntity,
MenuCategory,
Product,
ProductSalesStats,
RevenueDataPoint,
ShiftSlot,
@@ -33,10 +35,253 @@ export const SOCIAL_LINKS: SocialLinks = {
website: "/",
};
// ===== MENU CATEGORIES =====
// Each category has a unique FontAwesome icon representing the item type
export const MENU_CATEGORIES: MenuCategory[] = [
{ id: "all", name: "All", icon: "fa-solid fa-border-all" },
{ id: "cafe", name: "Coffee", icon: "fa-solid fa-mug-hot" },
{ id: "tra", name: "Tea", icon: "fa-solid fa-leaf" },
{ id: "sua-chua", name: "Yogurt", icon: "fa-solid fa-jar" },
{ id: "nuoc-ep", name: "Juice", icon: "fa-solid fa-blender" },
{ id: "latte", name: "Latte", icon: "fa-solid fa-mug-saucer" },
{
id: "giai-khat",
name: "Refreshments / Snacks",
icon: "fa-solid fa-ice-cream",
},
{ id: "topping", name: "Topping", icon: "fa-solid fa-layer-group" },
];
// ===== MOCK PRODUCTS =====
// Placeholder data replace with real API calls when backend is ready
export const MOCK_PRODUCTS: Product[] = [
{
id: 1,
name: "Black Coffee",
category: "cafe",
price: 25000,
image: "/imgs/products/placeholder.jpg",
description:
"Traditional Vietnamese black coffee, rich and bold, brewed by hand with a phin filter.",
available: true,
},
{
id: 2,
name: "Milk Coffee",
category: "cafe",
price: 30000,
image: "/imgs/products/placeholder.jpg",
description:
"Aromatic condensed milk coffee, rich and creamy — a perfect blend of strong coffee and sweet condensed milk.",
available: true,
},
{
id: 3,
name: "White Coffee",
category: "cafe",
price: 32000,
image: "/imgs/products/placeholder.jpg",
description:
"Light and mild, with less coffee and more milk — perfect for those just starting to enjoy coffee.",
available: true,
},
{
id: 4,
name: "Egg Coffee",
category: "cafe",
price: 45000,
image: "/imgs/products/placeholder.jpg",
description:
"A Hanoi specialty — smooth, velvety egg cream layered over a bold coffee base.",
available: true,
},
{
id: 5,
name: "Peach Orange Lemongrass Tea",
category: "tra",
price: 35000,
image: "/imgs/products/placeholder.jpg",
description:
"Fragrant peach tea with fresh orange and lemongrass — refreshing and wonderfully cooling.",
available: true,
},
{
id: 6,
name: "Matcha Green Tea",
category: "tra",
price: 40000,
image: "/imgs/products/placeholder.jpg",
description:
"Pure Japanese matcha with a signature light bitterness — aromatic, refreshing, and nutritious.",
available: true,
},
{
id: 7,
name: "Lychee Jasmine Tea",
category: "tra",
price: 38000,
image: "/imgs/products/placeholder.jpg",
description:
"Sweet lychee tea delicately infused with jasmine blossom — a calming and soulful sip.",
available: true,
},
{
id: 8,
name: "Yogurt with Tapioca Pearls",
category: "sua-chua",
price: 38000,
image: "/imgs/products/placeholder.jpg",
description:
"Smooth, creamy yogurt paired with chewy black tapioca pearls — a perfectly balanced sweet and tangy treat.",
available: true,
},
{
id: 9,
name: "Strawberry Yogurt",
category: "sua-chua",
price: 40000,
image: "/imgs/products/placeholder.jpg",
description:
"Chilled yogurt with fresh sweet-tart strawberries, packed with vitamins and minerals.",
available: true,
},
{
id: 10,
name: "Fresh Orange Juice",
category: "nuoc-ep",
price: 35000,
image: "/imgs/products/placeholder.jpg",
description:
"100% freshly squeezed orange juice, rich in vitamin C and great for your health.",
available: true,
},
{
id: 11,
name: "Watermelon Juice",
category: "nuoc-ep",
price: 30000,
image: "/imgs/products/placeholder.jpg",
description:
"Ice-cold watermelon juice — instantly refreshing on hot summer days.",
available: true,
},
{
id: 12,
name: "Caramel Latte",
category: "latte",
price: 45000,
image: "/imgs/products/placeholder.jpg",
description:
"Sweet and indulgent caramel latte with a velvety milk foam topping and a drizzle of caramel sauce.",
available: true,
},
{
id: 13,
name: "Vanilla Latte",
category: "latte",
price: 45000,
image: "/imgs/products/placeholder.jpg",
description:
"Smooth and gentle vanilla latte with a delicate natural vanilla fragrance.",
available: true,
},
{
id: 14,
name: "Toasted Butter Bread",
category: "giai-khat",
price: 20000,
image: "/imgs/products/placeholder.jpg",
description:
"Crispy toasted bread spread with fragrant butter and strawberry jam — the perfect coffee companion.",
available: true,
},
{
id: 15,
name: "Caramel Flan",
category: "giai-khat",
price: 25000,
image: "/imgs/products/placeholder.jpg",
description:
"Silky smooth flan with a golden caramel topping that melts in your mouth.",
available: true,
},
{
id: 16,
name: "Black Tapioca Pearls",
category: "topping",
price: 10000,
image: "/imgs/products/placeholder.jpg",
description:
"Chewy black tapioca pearls — add them to any drink for extra flavor and texture.",
available: true,
},
{
id: 17,
name: "Coffee Jelly",
category: "topping",
price: 10000,
image: "/imgs/products/placeholder.jpg",
description:
"Cool coffee jelly that adds a distinctive flavor boost to your drink.",
available: true,
},
{
id: 18,
name: "White Tapioca Pearls",
category: "topping",
price: 10000,
image: "/imgs/products/placeholder.jpg",
description:
"Chewy white tapioca pearls — add them to any drink for extra flavor and texture.",
available: true,
},
];
// ===== MOCK COMBOS =====
export const MOCK_COMBOS: Combo[] = [
{
id: 1,
name: "Double Coffee Combo",
description: "2 black coffees + 2 toasted butter breads, save 15%.",
price: 75000,
image: "/imgs/products/placeholder.jpg",
items: [
{ productId: 1, quantity: 2 },
{ productId: 14, quantity: 2 },
],
available: true,
},
{
id: 2,
name: "Group Tea Combo",
description: "2 peach orange lemongrass teas + 2 matcha green teas, perfect for a group.",
price: 130000,
image: "/imgs/products/placeholder.jpg",
items: [
{ productId: 5, quantity: 2 },
{ productId: 6, quantity: 2 },
],
available: true,
},
{
id: 3,
name: "Morning Combo",
description: "1 milk coffee + 1 caramel flan — start your day on a sweet note.",
price: 48000,
image: "/imgs/products/placeholder.jpg",
items: [
{ productId: 2, quantity: 1 },
{ productId: 15, quantity: 1 },
],
available: false,
},
];
// ===== MOCK SHOPS (for Feed page) =====
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:
@@ -44,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:
@@ -51,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:
@@ -58,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:
@@ -65,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:
@@ -149,6 +398,217 @@ export const MOCK_REVENUE_YEARLY: RevenueDataPoint[] = [
{ label: "2026", revenue: 180000000, orders: 6480 },
];
// Product sales statistics (with cost price for profit analysis)
export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
{
productId: 12,
name: "Caramel Latte",
category: "latte",
unitsSold: 487,
revenue: 21915000,
costPrice: 18000,
sellingPrice: 45000,
profit: 13185000,
profitMargin: 60.2,
},
{
productId: 6,
name: "Matcha Green Tea",
category: "tra",
unitsSold: 412,
revenue: 16480000,
costPrice: 15000,
sellingPrice: 40000,
profit: 10300000,
profitMargin: 62.5,
},
{
productId: 5,
name: "Peach Orange Lemongrass Tea",
category: "tra",
unitsSold: 398,
revenue: 13930000,
costPrice: 12000,
sellingPrice: 35000,
profit: 9153000,
profitMargin: 65.7,
},
{
productId: 13,
name: "Vanilla Latte",
category: "latte",
unitsSold: 356,
revenue: 16020000,
costPrice: 18000,
sellingPrice: 45000,
profit: 9612000,
profitMargin: 60.0,
},
{
productId: 4,
name: "Egg Coffee",
category: "cafe",
unitsSold: 340,
revenue: 15300000,
costPrice: 16000,
sellingPrice: 45000,
profit: 9860000,
profitMargin: 64.4,
},
{
productId: 2,
name: "Milk Coffee",
category: "cafe",
unitsSold: 325,
revenue: 9750000,
costPrice: 10000,
sellingPrice: 30000,
profit: 6500000,
profitMargin: 66.7,
},
{
productId: 3,
name: "White Coffee",
category: "cafe",
unitsSold: 298,
revenue: 9536000,
costPrice: 11000,
sellingPrice: 32000,
profit: 6259000,
profitMargin: 65.6,
},
{
productId: 1,
name: "Black Coffee",
category: "cafe",
unitsSold: 285,
revenue: 7125000,
costPrice: 8000,
sellingPrice: 25000,
profit: 4845000,
profitMargin: 68.0,
},
{
productId: 9,
name: "Strawberry Yogurt",
category: "sua-chua",
unitsSold: 267,
revenue: 10680000,
costPrice: 15000,
sellingPrice: 40000,
profit: 6675000,
profitMargin: 62.5,
},
{
productId: 10,
name: "Fresh Orange Juice",
category: "nuoc-ep",
unitsSold: 241,
revenue: 8435000,
costPrice: 12000,
sellingPrice: 35000,
profit: 5543000,
profitMargin: 65.7,
},
{
productId: 8,
name: "Yogurt with Tapioca Pearls",
category: "sua-chua",
unitsSold: 228,
revenue: 8664000,
costPrice: 14000,
sellingPrice: 38000,
profit: 5472000,
profitMargin: 63.2,
},
{
productId: 7,
name: "Lychee Jasmine Tea",
category: "tra",
unitsSold: 215,
revenue: 8170000,
costPrice: 13000,
sellingPrice: 38000,
profit: 5375000,
profitMargin: 65.8,
},
{
productId: 15,
name: "Caramel Flan",
category: "giai-khat",
unitsSold: 198,
revenue: 4950000,
costPrice: 8000,
sellingPrice: 25000,
profit: 3366000,
profitMargin: 68.0,
},
{
productId: 11,
name: "Watermelon Juice",
category: "nuoc-ep",
unitsSold: 182,
revenue: 5460000,
costPrice: 10000,
sellingPrice: 30000,
profit: 3640000,
profitMargin: 66.7,
},
{
productId: 14,
name: "Toasted Butter Bread",
category: "giai-khat",
unitsSold: 175,
revenue: 3500000,
costPrice: 6000,
sellingPrice: 20000,
profit: 2450000,
profitMargin: 70.0,
},
{
productId: 16,
name: "Black Tapioca Pearls",
category: "topping",
unitsSold: 456,
revenue: 4560000,
costPrice: 2000,
sellingPrice: 10000,
profit: 3648000,
profitMargin: 80.0,
},
{
productId: 17,
name: "Coffee Jelly",
category: "topping",
unitsSold: 389,
revenue: 3890000,
costPrice: 2000,
sellingPrice: 10000,
profit: 3112000,
profitMargin: 80.0,
},
{
productId: 18,
name: "White Tapioca Pearls",
category: "topping",
unitsSold: 342,
revenue: 3420000,
costPrice: 2000,
sellingPrice: 10000,
profit: 2736000,
profitMargin: 80.0,
},
];
// ===== SHIFT / SCHEDULE DATA =====
export const DEPARTMENTS: Department[] = [
{ id: "bar", name: "Bar Staff", icon: "fa-solid fa-martini-glass-citrus" },
{ id: "kitchen", name: "Kitchen", icon: "fa-solid fa-kitchen-set" },
{ id: "cashier", name: "Cashier", icon: "fa-solid fa-cash-register" },
{ id: "janitor", name: "Janitor", icon: "fa-solid fa-broom" },
];
/**
* Generate mock shift slots for the weeks around today (April 2026).
* Covers Mon 6 Apr Sun 26 Apr 2026.
@@ -219,7 +679,7 @@ function generateMockShifts(): ShiftSlot[] {
}
shifts.push({
id: "shiftId",
id: shiftId,
date: dateStr,
startTime: slot.start,
endTime: slot.end,
@@ -238,10 +698,3 @@ function generateMockShifts(): ShiftSlot[] {
}
export const MOCK_SHIFT_SLOTS: ShiftSlot[] = generateMockShifts();
export const DEPARTMENTS: Department[] = [
{ id: "bar", name: "Bar Staff", icon: "fa-solid fa-martini-glass-citrus" },
{ id: "kitchen", name: "Kitchen", icon: "fa-solid fa-kitchen-set" },
{ id: "cashier", name: "Cashier", icon: "fa-solid fa-cash-register" },
{ id: "janitor", name: "Janitor", icon: "fa-solid fa-broom" },
];
+86
View File
@@ -0,0 +1,86 @@
import type { Eatery, MenuItem } from "./types";
export async function gqlFetch<T = Record<string, unknown>>(
query: string,
variables?: Record<string, unknown>,
): Promise<T> {
const res = await fetch("/api/eatery/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ query, variables }),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
const message =
Array.isArray(body) && body[0]?.message
? body[0].message
: `GraphQL request failed: ${res.status}`;
throw new Error(message);
}
return res.json() as Promise<T>;
}
export async function getMenuItemsByEatery(
eateryId: string,
): Promise<MenuItem[]> {
const data = await gqlFetch<{ menuItemsByEatery: MenuItem[] }>(
`query menuItemsByEatery($eateryId: String!) {
menuItemsByEatery(eateryId: $eateryId) { id name price }
}`,
{ eateryId },
);
return data.menuItemsByEatery ?? [];
}
export async function addMenuItem(input: {
name: string;
price: number;
}): Promise<MenuItem | null> {
const data = await gqlFetch<{ addMenuItem: MenuItem | null }>(
`mutation addMenuItem($menuItem: AddMenuItemInput!) {
addMenuItem(menuItem: $menuItem) { id name price }
}`,
{ menuItem: input },
);
return data.addMenuItem;
}
export async function updateMenuItem(input: {
id: string;
name?: string;
price?: number;
}): Promise<MenuItem | null> {
const data = await gqlFetch<{ updateMenuItem: MenuItem | null }>(
`mutation updateMenuItem($menuItem: UpdateMenuItemInput!) {
updateMenuItem(menuItem: $menuItem) { id name price }
}`,
{ menuItem: input },
);
return data.updateMenuItem;
}
export async function deleteMenuItem(menuItemId: string): Promise<boolean> {
const data = await gqlFetch<{ deleteMenuItem: boolean }>(
`mutation deleteMenuItem($menuItemId: String!) {
deleteMenuItem(menuItemId: $menuItemId)
}`,
{ menuItemId },
);
return data.deleteMenuItem ?? false;
}
export async function fetchManagerEatery(): Promise<Eatery | null> {
const data = await gqlFetch<{ eateriesByOwner: Eatery | null }>(`
query {
eateriesByOwner {
id
ownerId
name
isVerified
}
}
`);
return data.eateriesByOwner;
}
+78
View File
@@ -0,0 +1,78 @@
import type { GqlCart } from "./cart.types";
async function cartFetch<T>(
query: string,
variables?: Record<string, unknown>,
): Promise<T> {
const res = await fetch("/api/cart/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ query, variables }),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
const message =
Array.isArray(body) && body[0]?.message
? (body[0].message as string)
: `Cart API error: ${res.status}`;
throw new Error(message);
}
return res.json() as Promise<T>;
}
export async function createCart(eateryId: string): Promise<string> {
const data = await cartFetch<{ createCart: string }>(
`mutation createCart($eateryId: String) {
createCart(eateryId: $eateryId)
}`,
{ eateryId },
);
return data.createCart;
}
export async function getCart(cartId: string): Promise<GqlCart> {
const data = await cartFetch<{ getCart: GqlCart }>(
`query getCart($cartId: String) {
getCart(cartId: $cartId) {
id
userId
eateryId
items { productId quantity }
}
}`,
{ cartId },
);
return data.getCart;
}
// quantity > 0: add; quantity < 0: decrease/remove (backend uses Redis hincrby)
export async function addItem(
cartId: string,
menuItemId: string,
quantity: number,
): Promise<GqlCart> {
const data = await cartFetch<{ addItem: GqlCart }>(
`mutation addItem($cartId: String, $menuItemId: String, $quantity: BigInteger) {
addItem(cartId: $cartId, menuItemId: $menuItemId, quantity: $quantity) {
id
userId
eateryId
items { productId quantity }
}
}`,
{ cartId, menuItemId, quantity },
);
return data.addItem;
}
// Backend has no removeItem — zero out via negative addItem
export async function removeItem(
cartId: string,
menuItemId: string,
currentQuantity: number,
): Promise<GqlCart> {
return addItem(cartId, menuItemId, -currentQuantity);
}
+11
View File
@@ -0,0 +1,11 @@
export interface GqlCartItem {
productId: string;
quantity: number;
}
export interface GqlCart {
id: string;
userId: string;
eateryId: string;
items: GqlCartItem[];
}
+101 -134
View File
@@ -1,182 +1,149 @@
"use client";
import { gql } from "@apollo/client";
import { useMutation, useQuery } from "@apollo/client/react";
import {
ReactNode,
createContext,
useContext,
useEffect,
useState,
} from "react";
import { ReactNode, createContext, useContext, useState } from "react";
import { eateryClient } from "./apollo-clients";
import {
type MenuItemEntity,
type addMenuItemMutation,
type allEateriesQuery,
deleteMenuItemMutation,
updateMenuItemMutation,
} from "./types";
import { MENU_CATEGORIES, MOCK_COMBOS, MOCK_PRODUCTS } from "./constants";
import type { Combo, ComboItem, MenuCategory, Product } from "./types";
// ─── Types ────────────────────────────────────────────────────────────────────
export type ManagerTab = "products";
export type ManagerTab = "products" | "combos" | "categories" | "menu-items";
interface ManagerContextType {
// Data
products: MenuItemEntity[];
products: Product[];
combos: Combo[];
categories: MenuCategory[];
// Active tab
activeTab: ManagerTab;
setActiveTab: (tab: ManagerTab) => void;
// MenuItemEntity actions
addProduct: (product: MenuItemEntity) => void;
updateProduct: (product: MenuItemEntity) => void;
deleteProduct: (id: string) => void;
toggleProductAvailability: (product: MenuItemEntity) => void;
// Product actions
addProduct: (product: Omit<Product, "id">) => void;
updateProduct: (product: Product) => void;
deleteProduct: (id: number) => void;
toggleProductAvailability: (id: number) => void;
// Combo actions
addCombo: (combo: Omit<Combo, "id">) => void;
updateCombo: (combo: Combo) => void;
deleteCombo: (id: number) => void;
toggleComboAvailability: (id: number) => void;
// Category actions
addCategory: (category: Omit<MenuCategory, "id">) => void;
updateCategory: (category: MenuCategory) => void;
deleteCategory: (id: string) => void;
}
// ─── Context ──────────────────────────────────────────────────────────────────
const ManagerContext = createContext<ManagerContextType | undefined>(undefined);
// ___ Graphql __________________________________________________________________
const GET_EATERY_MENU = gql`
query GetEateryMenu {
allEateries {
id
menuItems {
id
name
available
description
price
}
}
}
`;
const ADD_MENU_ITEM = gql`
mutation addMenuItem($menuItem: AddMenuItemInput!) {
addMenuItem(menuItem: $menuItem) {
id
name
available
description
price
}
}
`;
const UPDATE_MENU_ITEM = gql`
mutation updateMenuItem($menuItem: UpdateMenuItemInput!) {
updateMenuItem(menuItem: $menuItem) {
id
name
available
description
price
}
}
`;
const DELETE_MENU_ITEM = gql`
mutation deleteMenuItem($input: String!) {
deleteMenuItem(menuItemId: $input)
}
`;
// ─── Provider ─────────────────────────────────────────────────────────────────
export function ManagerProvider({ children }: { children: ReactNode }) {
const [products, setProducts] = useState<MenuItemEntity[]>([]);
const [products, setProducts] = useState<Product[]>(MOCK_PRODUCTS);
const [combos, setCombos] = useState<Combo[]>(MOCK_COMBOS);
// Filter out the "all" pseudo-category — managers manage real categories only
const [categories, setCategories] = useState<MenuCategory[]>(
MENU_CATEGORIES.filter((c) => c.id !== "all"),
);
const [activeTab, setActiveTab] = useState<ManagerTab>("products");
const { data } = useQuery<allEateriesQuery>(GET_EATERY_MENU, {
client: eateryClient,
fetchPolicy: "network-only",
});
// ── Product actions ──────────────────────────────────────────────────────
const [mutateAddMenuItem] = useMutation<addMenuItemMutation>(ADD_MENU_ITEM, {
client: eateryClient,
});
const [mutateUpdateMenuItem] = useMutation<updateMenuItemMutation>(
UPDATE_MENU_ITEM,
{ client: eateryClient },
);
const [mutateDeleteMenuItem] = useMutation<deleteMenuItemMutation>(
DELETE_MENU_ITEM,
{ client: eateryClient },
);
useEffect(() => {
if (data?.allEateries?.[0]) {
setProducts(data.allEateries[0].menuItems);
}
}, [data]);
// ── MenuItemEntity actions ──────────────────────────────────────────────────────
const addProduct = async (product: MenuItemEntity) => {
const { data } = await mutateAddMenuItem({
variables: {
menuItem: product,
},
});
if (data) setProducts((prev) => [...prev, data.addMenuItem]);
const addProduct = (product: Omit<Product, "id">) => {
const newProduct: Product = {
...product,
id: Date.now(),
};
setProducts((prev) => [...prev, newProduct]);
};
const updateProduct = async (product: MenuItemEntity) => {
const { data } = await mutateUpdateMenuItem({
variables: {
menuItem: product,
},
});
if (data)
setProducts((prev) =>
prev.map((p) => (p.id === product.id ? data.updateMenuItem : p)),
);
const updateProduct = (product: Product) => {
setProducts((prev) => prev.map((p) => (p.id === product.id ? product : p)));
};
const deleteProduct = async (id: string) => {
const { data } = await mutateDeleteMenuItem({ variables: { input: id } });
if (data)
setProducts((prev) =>
prev.filter((p) => !(p.id == id && data.deleteMenuItem)),
);
const deleteProduct = (id: number) => {
setProducts((prev) => prev.filter((p) => p.id !== id));
};
const toggleProductAvailability = async (product: MenuItemEntity) => {
const { data } = await mutateUpdateMenuItem({
variables: {
menuItem: { ...product, available: !product.available },
},
});
const toggleProductAvailability = (id: number) => {
setProducts((prev) =>
prev.map((p) =>
p.id === id ? { ...p, available: !(p.available ?? true) } : p,
),
);
};
if (data)
setProducts((prev) =>
prev.map((p) => (p.id === product.id ? data.updateMenuItem : p)),
);
// ── Combo actions ────────────────────────────────────────────────────────
const addCombo = (combo: Omit<Combo, "id">) => {
const newCombo: Combo = { ...combo, id: Date.now() };
setCombos((prev) => [...prev, newCombo]);
};
const updateCombo = (combo: Combo) => {
setCombos((prev) => prev.map((c) => (c.id === combo.id ? combo : c)));
};
const deleteCombo = (id: number) => {
setCombos((prev) => prev.filter((c) => c.id !== id));
};
const toggleComboAvailability = (id: number) => {
setCombos((prev) =>
prev.map((c) => (c.id === id ? { ...c, available: !c.available } : c)),
);
};
// ── Category actions ─────────────────────────────────────────────────────
const addCategory = (category: Omit<MenuCategory, "id">) => {
const slug = category.name
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
const newCategory: MenuCategory = {
...category,
id: `${slug}-${Date.now()}`,
};
setCategories((prev) => [...prev, newCategory]);
};
const updateCategory = (category: MenuCategory) => {
setCategories((prev) =>
prev.map((c) => (c.id === category.id ? category : c)),
);
};
const deleteCategory = (id: string) => {
setCategories((prev) => prev.filter((c) => c.id !== id));
};
return (
<ManagerContext.Provider
value={{
products,
combos,
categories,
activeTab,
setActiveTab,
addProduct,
updateProduct,
deleteProduct,
toggleProductAvailability,
addCombo,
updateCombo,
deleteCombo,
toggleComboAvailability,
addCategory,
updateCategory,
deleteCategory,
}}
>
{children}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { createContext, useContext, useState } from "react";
interface MenuContextType {
/** Currently selected category id */
activeCategory: string;
/** Update the active category */
setActiveCategory: (id: string) => void;
}
const MenuContext = createContext<MenuContextType>({
activeCategory: "all",
setActiveCategory: () => {},
});
/**
* Provides shared activeCategory state to both the Header (mobile scrollable menu)
* and the Navbar sidebar (md+), so both always reflect the same selection.
*/
export function MenuProvider({ children }: { children: React.ReactNode }) {
const [activeCategory, setActiveCategory] = useState("all");
return (
<MenuContext.Provider value={{ activeCategory, setActiveCategory }}>
{children}
</MenuContext.Provider>
);
}
/** Consume the shared menu state anywhere inside MenuProvider */
export function useMenu() {
return useContext(MenuContext);
}
+4 -4
View File
@@ -204,14 +204,14 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
staffName: string,
): { success: boolean; error?: string } => {
const shift = shifts.find((s) => s.id === shiftId);
if (!shift) return { success: false, error: "Ca làm không tồn tại." };
if (!shift) return { success: false, error: "Shift does not exist." };
if (shift.registeredStaff.length >= shift.maxStaff) {
return { success: false, error: "Ca làm đã đủ người." };
return { success: false, error: "Shift is already full." };
}
if (shift.registeredStaff.some((rs) => rs.id === staffId)) {
return { success: false, error: "Bạn đã đăng ký ca này rồi." };
return { success: false, error: "You have already registered for this shift." };
}
if (
@@ -225,7 +225,7 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
) {
return {
success: false,
error: "Xung đột lịch! Bạn đã có ca làm trùng giờ trong ngày này.",
error: "Schedule conflict! You already have an overlapping shift on this day.",
};
}
+50 -61
View File
@@ -9,6 +9,25 @@ export interface User {
phone?: string;
}
// ===== MENU TYPES =====
export interface MenuCategory {
id: string;
name: string;
icon: string; // FontAwesome class e.g. "fa-solid fa-mug-hot"
}
// ===== PRODUCT TYPES =====
export interface Product {
id: number;
name: string;
category: string; // matches MenuCategory.id
price: number;
image: string;
description: string;
available?: boolean;
menuItemId?: string; // backend UUID — present when product comes from eatery-service
}
// ===== SHOP INFO TYPES =====
export interface WifiInfo {
name: string;
@@ -34,9 +53,24 @@ export interface SocialLinks {
website: string;
}
// ===== EATERY (GRAPHQL) TYPES =====
export interface Eatery {
id: string;
ownerId: string;
name: string;
isVerified: boolean;
}
export interface MenuItem {
id: string;
name: string;
price: number;
}
// ===== SHOP (QUÁN NƯỚC) TYPES =====
export interface Shop {
id: number;
eateryId: string;
name: string;
address: string;
image: string;
@@ -81,6 +115,22 @@ export interface FinancialSummary {
profitComparison: PeriodComparison;
}
// ===== COMBO TYPES =====
export interface ComboItem {
productId: number;
quantity: number;
}
export interface Combo {
id: number;
name: string;
description: string;
price: number;
image: string;
items: ComboItem[]; // list of products + quantities in this combo
available: boolean;
}
// ===== SHIFT / SCHEDULE TYPES =====
export type ShiftStatus =
| "available"
@@ -111,64 +161,3 @@ export interface Department {
name: string;
icon: string; // FontAwesome class
}
export interface MenuItemEntity {
id?: string;
name: string;
price: number;
eatery?: EateryEntity;
imageUrl: string;
available: boolean;
description: string;
}
export interface ShiftEntity {}
export interface EateryEntity {
id: string;
ownerId: string;
name: string;
menuItems: MenuItemEntity[];
shifts: ShiftEntity[];
}
export interface allEateriesQuery {
allEateries: EateryEntity[];
}
export interface addMenuItemMutation {
addMenuItem: MenuItemEntity;
}
export interface updateMenuItemMutation {
updateMenuItem: MenuItemEntity;
}
export interface deleteMenuItemMutation {
deleteMenuItem: boolean;
}
export interface CartItemEntity {
productId: string;
quantity: number;
priceAtTimeOfAdding?: number;
}
export interface CartEntity {
Id: string;
items: CartItemEntity[];
totalAmount: number;
paymentQrUrl: string;
}
export interface getCartQuery {
getCart: CartEntity;
}
export interface createCartMutation {
createCart: string;
}
export interface addMenuItemMutation {
addItem: CartEntity;
}
+1 -1
View File
@@ -20,7 +20,7 @@ const nextConfig: NextConfig = {
],
},
async rewrites() {
const gatewayUrl = "http://host.docker.internal:32080";
const gatewayUrl = "http://localhost:32080";
return [
{
source: "/api/:path*",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "temp",
"version": "1.2.4",
"version": "1.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "temp",
"version": "1.2.4",
"version": "1.2.3",
"dependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "^20.19.37",
+4 -6
View File
@@ -1,6 +1,6 @@
{
"name": "temp",
"version": "1.2.4",
"version": "1.2.3",
"private": true,
"scripts": {
"dev": "next dev",
@@ -11,16 +11,14 @@
"release": "semantic-release"
},
"dependencies": {
"@apollo/client": "^4.1.9",
"@tailwindcss/postcss": "^4.2.4",
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "^20.19.39",
"@types/react": "^19.2.14",
"graphql": "^16.14.0",
"next": "16.1.7",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind": "^4.0.0",
"tailwindcss": "^4.2.4",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
},
"devDependencies": {
@@ -34,7 +32,7 @@
"prettier": "^3.8.3",
"prettier-plugin-embed": "^0.5.1",
"prettier-plugin-groovy": "^0.2.1",
"prettier-plugin-tailwindcss": "^0.7.4",
"prettier-plugin-tailwindcss": "^0.7.2",
"semantic-release": "^25.0.3"
}
}
+284 -363
View File
File diff suppressed because it is too large Load Diff