feat(dashboard): Next.js 16 merchant panel with offline POS and PWA
Complete merchant dashboard upgrade:
Next.js 16 compatibility:
- Fix params/searchParams typed as Promise<{}> throughout App Router
- Replace middleware.ts with proxy.ts (Next.js 16 convention)
- Remove unused @ts-expect-error directives caught by stricter TS
- Cast dynamic next-intl t() keys to fix TranslateArgs type errors
Offline POS:
- IndexedDB queue (meezi_pos_offline) for orders created while offline
- Zustand sync store tracking queueCount, isSyncing, isOnline
- useOfflineSync hook: auto-syncs on reconnect/visibility-change
- SyncStatusIndicator chip in topbar (amber=offline, blue=syncing)
- submitOrderToApi falls back to local order on network failure
- Local orders skip payment flow; sync on reconnect
PWA (installable):
- @ducanh2912/next-pwa with Workbox runtime caching rules
- Web App Manifest (manifest.ts) — RTL/Farsi, theme #0F6E56
- PWA icons: 192px, 512px, maskable 512px
- next.config.ts replaces next.config.mjs
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { BranchesScreen } from "@/components/branches/branches-screen";
|
||||
|
||||
export default function BranchesPage() {
|
||||
return <BranchesScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CouponsScreen } from "@/components/coupons/coupons-screen";
|
||||
|
||||
export default function CouponsPage() {
|
||||
return <CouponsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CrmScreen } from "@/components/crm/crm-screen";
|
||||
|
||||
export default function CrmPage() {
|
||||
return <CrmScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ExpensesScreen } from "@/components/expenses/expenses-screen";
|
||||
|
||||
export default function ExpensesPage() {
|
||||
return <ExpensesScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { HrScreen } from "@/components/hr/hr-screen";
|
||||
|
||||
export default function HrPage() {
|
||||
return <HrScreen />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { InventoryScreen } from "@/components/inventory/inventory-screen";
|
||||
|
||||
export default function InventoryPage() {
|
||||
return <InventoryScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { KdsScreen } from "@/components/kds/kds-screen";
|
||||
|
||||
export default function KdsPage() {
|
||||
return <KdsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Topbar } from "@/components/layout/topbar";
|
||||
import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useOfflineSync } from "@/lib/offline/use-offline-sync";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
useOfflineSync(); // register online/offline listeners + load queue count
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.accessToken) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
const isRtl = locale !== "en";
|
||||
|
||||
const mainColumn = (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<Topbar />
|
||||
<main className="min-h-0 flex-1 overflow-auto p-6 bg-background">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<CafeThemeProvider>
|
||||
<div
|
||||
className="flex h-screen min-h-0 overflow-hidden bg-background"
|
||||
dir={isRtl ? "rtl" : "ltr"}
|
||||
>
|
||||
{isRtl ? (
|
||||
<>
|
||||
<Sidebar side="right" />
|
||||
{mainColumn}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sidebar side="left" />
|
||||
{mainColumn}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CafeThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { MenuAdminScreen } from "@/components/menu/menu-admin-screen";
|
||||
|
||||
export default function MenuPage() {
|
||||
return <MenuAdminScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NotificationsScreen } from "@/components/notifications/notifications-screen";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
return <NotificationsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { OverviewScreen } from "@/components/overview/overview-screen";
|
||||
|
||||
export default function HomePage() {
|
||||
return <OverviewScreen />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Suspense } from "react";
|
||||
import { PosScreen } from "@/components/pos/pos-screen";
|
||||
|
||||
/** Full viewport height below topbar; no page scroll — only inner panes scroll. */
|
||||
export default function PosPage() {
|
||||
return (
|
||||
<div className="-m-6 flex h-full min-h-0 overflow-hidden p-4 md:p-6">
|
||||
<Suspense fallback={null}>
|
||||
<PosScreen />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { QueueScreen } from "@/components/queue/queue-screen";
|
||||
|
||||
export default function QueuePage() {
|
||||
return <QueueScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ReportsScreen } from "@/components/reports/reports-screen";
|
||||
|
||||
export default function ReportsPage() {
|
||||
return <ReportsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ReservationsScreen } from "@/components/reservations/reservations-screen";
|
||||
|
||||
export default function ReservationsPage() {
|
||||
return <ReservationsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ReviewsScreen } from "@/components/reviews/reviews-screen";
|
||||
|
||||
export default function ReviewsPage() {
|
||||
return <ReviewsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { SettingsScreen } from "@/components/settings/settings-screen";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SettingsScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ShiftsScreen } from "@/components/shifts/shifts-screen";
|
||||
|
||||
export default function ShiftsPage() {
|
||||
return <ShiftsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SmsScreen } from "@/components/sms/sms-screen";
|
||||
|
||||
export default function SmsPage() {
|
||||
return <SmsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { SubscriptionScreen } from "@/components/subscription/subscription-screen";
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SubscriptionScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SupportTicketDetailScreen } from "@/components/support/support-screen";
|
||||
|
||||
export default function SupportTicketPage() {
|
||||
return <SupportTicketDetailScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SupportScreen } from "@/components/support/support-screen";
|
||||
|
||||
export default function SupportPage() {
|
||||
return <SupportScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TablesScreen } from "@/components/tables/tables-screen";
|
||||
|
||||
export default function TablesPage() {
|
||||
return <TablesScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TaxesScreen } from "@/components/taxes/taxes-screen";
|
||||
|
||||
export default function TaxesPage() {
|
||||
return <TaxesScreen />;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
|
||||
/** Full-viewport routes (queue TV display) — auth only, no dashboard chrome. */
|
||||
export default function FullscreenLayout({ children }: { children: React.ReactNode }) {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.accessToken) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-svh" dir={locale === "en" ? "ltr" : "rtl"}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { QueueDisplayScreen } from "@/components/queue/queue-display-screen";
|
||||
|
||||
export default function QueueDisplayPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<QueueDisplayScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PublicCafeDetailScreen } from "@/components/discover/public-cafe-detail-screen";
|
||||
|
||||
export default async function PublicCafeDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
return <PublicCafeDetailScreen slug={slug} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PublicDiscoverScreen } from "@/components/discover/public-discover-screen";
|
||||
|
||||
export default function PublicDiscoverPage() {
|
||||
return <PublicDiscoverScreen />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Providers } from "@/components/providers";
|
||||
|
||||
/** Public consumer routes (discover) — no dashboard chrome. */
|
||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
return <Providers>{children}</Providers>;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import localFont from "next/font/local";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { Providers } from "@/components/providers";
|
||||
import "../globals.css";
|
||||
|
||||
const vazirmatn = localFont({
|
||||
src: "../../fonts/Vazirmatn-Variable.woff2",
|
||||
variable: "--font-vazirmatn",
|
||||
display: "swap",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
const inter = localFont({
|
||||
src: "../../fonts/Inter-Variable.woff2",
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
if (!routing.locales.includes(locale as "fa" | "ar" | "en")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
setRequestLocale(locale);
|
||||
const messages = await getMessages();
|
||||
const dir = locale === "en" ? "ltr" : "rtl";
|
||||
const fontClass =
|
||||
locale === "en"
|
||||
? inter.variable
|
||||
: vazirmatn.variable;
|
||||
|
||||
return (
|
||||
<html lang={locale} dir={dir}>
|
||||
<body
|
||||
className={`${fontClass} font-sans antialiased ${
|
||||
locale === "en" ? "font-[family-name:var(--font-inter)]" : "font-[family-name:var(--font-vazirmatn)]"
|
||||
}`}
|
||||
>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<Providers>{children}</Providers>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { apiPost, ApiClientError } from "@/lib/api/client";
|
||||
import type { AuthTokenResponse } from "@/lib/api/types";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("auth");
|
||||
const router = useRouter();
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
|
||||
const [phone, setPhone] = useState("09121234567");
|
||||
const [code, setCode] = useState("");
|
||||
const [step, setStep] = useState<"phone" | "otp">("phone");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const authErrorMessage = (err: unknown) => {
|
||||
if (err instanceof ApiClientError) {
|
||||
switch (err.code) {
|
||||
case "RATE_LIMITED":
|
||||
return t("rateLimited");
|
||||
case "NOT_FOUND":
|
||||
return t("notFound");
|
||||
case "SMS_FAILED":
|
||||
return t("smsFailed");
|
||||
case "INVALID_OTP":
|
||||
return t("invalidOtp");
|
||||
default:
|
||||
return err.message;
|
||||
}
|
||||
}
|
||||
return err instanceof Error ? err.message : t("title");
|
||||
};
|
||||
|
||||
const sendOtp = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiPost("/api/auth/send-otp", { phone });
|
||||
setStep("otp");
|
||||
} catch (e) {
|
||||
setError(authErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyOtp = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apiPost<AuthTokenResponse>("/api/auth/verify-otp", {
|
||||
phone,
|
||||
code,
|
||||
});
|
||||
setAuth(data);
|
||||
router.push("/pos");
|
||||
} catch (e) {
|
||||
setError(authErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-primary">{t("title")}</CardTitle>
|
||||
<p className="text-center text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{step === "phone" ? (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!loading) void sendOtp();
|
||||
}}
|
||||
>
|
||||
<LabeledField label={t("phone")} htmlFor="login-phone">
|
||||
<Input
|
||||
id="login-phone"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder={t("phonePlaceholder")}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
autoComplete="tel"
|
||||
/>
|
||||
</LabeledField>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "..." : t("sendOtp")}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!loading) void verifyOtp();
|
||||
}}
|
||||
>
|
||||
<LabeledField label={t("otp")} htmlFor="login-otp">
|
||||
<Input
|
||||
id="login-otp"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder={t("otpPlaceholder")}
|
||||
maxLength={6}
|
||||
dir="ltr"
|
||||
className="text-center tracking-widest"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</LabeledField>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "..." : t("verify")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={() => setStep("phone")}
|
||||
>
|
||||
{t("resend")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-center text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "@/i18n/routing";
|
||||
|
||||
export default async function HomePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
redirect({ href: "/pos", locale });
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Meezi brand */
|
||||
--meezi-green: 162 76% 25%;
|
||||
--meezi-green-tint: 162 52% 92%;
|
||||
--meezi-amber: 38 78% 41%;
|
||||
--meezi-danger: 0 58% 41%;
|
||||
--meezi-info: 210 82% 28%;
|
||||
|
||||
--background: 210 20% 98%;
|
||||
--foreground: 222 47% 11%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--primary: var(--meezi-green);
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 162 30% 94%;
|
||||
--secondary-foreground: 162 76% 20%;
|
||||
--muted: 210 25% 96%;
|
||||
--muted-foreground: 215 16% 47%;
|
||||
--accent: var(--meezi-green-tint);
|
||||
--accent-foreground: 162 76% 20%;
|
||||
--destructive: var(--meezi-danger);
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 214 24% 88%;
|
||||
--input: 214 24% 88%;
|
||||
--ring: var(--meezi-green);
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-vazirmatn), var(--font-inter), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html[lang="en"] body {
|
||||
font-family: var(--font-inter), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html[lang="fa"] body,
|
||||
html[lang="ar"] body {
|
||||
font-family: var(--font-vazirmatn), system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sonner portal — inherit Meezi fonts (toasts render outside main tree) */
|
||||
[data-sonner-toaster],
|
||||
[data-sonner-toast],
|
||||
[data-sonner-toast] [data-title],
|
||||
[data-sonner-toast] [data-description],
|
||||
[data-sonner-toast] [data-button],
|
||||
[data-sonner-toast] [data-close-button] {
|
||||
font-family: var(--font-vazirmatn), system-ui, sans-serif !important;
|
||||
}
|
||||
|
||||
html[lang="en"] [data-sonner-toaster],
|
||||
html[lang="en"] [data-sonner-toast],
|
||||
html[lang="en"] [data-sonner-toast] [data-title],
|
||||
html[lang="en"] [data-sonner-toast] [data-description],
|
||||
html[lang="en"] [data-sonner-toast] [data-button],
|
||||
html[lang="en"] [data-sonner-toast] [data-close-button] {
|
||||
font-family: var(--font-inter), system-ui, sans-serif !important;
|
||||
}
|
||||
|
||||
/* Per-café theme — panel + menu layout variants (data-* set by applyCafeTheme) */
|
||||
html[data-panel-style="glass"] .bg-card,
|
||||
html[data-panel-style="glass"] .theme-preview-sidebar,
|
||||
html[data-panel-style="glass"] .theme-preview-menu-card {
|
||||
background: color-mix(in srgb, hsl(var(--card)) 82%, transparent) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
html[data-panel-style="bold"] .bg-card {
|
||||
border-width: 2px;
|
||||
border-color: hsl(var(--primary) / 0.35);
|
||||
}
|
||||
|
||||
html[data-panel-style="elevated"] .bg-card,
|
||||
html[data-panel-style="elevated"] .theme-preview-menu-card {
|
||||
box-shadow: 0 8px 24px hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
html[data-panel-style="outline"] .bg-card {
|
||||
background: transparent !important;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
html[data-panel-style="soft"] .bg-card {
|
||||
box-shadow: 0 2px 12px hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
html[data-menu-style="compact"] .theme-preview-menu-card {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
html[data-menu-style="grid"] [data-menu-grid="true"] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
html[data-menu-style="list"] .theme-preview-menu-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
html[data-density="compact"] {
|
||||
--spacing-scale: 0.85;
|
||||
}
|
||||
|
||||
html[data-density="spacious"] {
|
||||
--spacing-scale: 1.15;
|
||||
}
|
||||
|
||||
html[data-density="compact"] main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
html[data-density="spacious"] main {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* QR guest menu — background textures (--qr-bg set inline from café theme) */
|
||||
[data-qr-texture] {
|
||||
background-color: var(--qr-bg, #f5f5f4);
|
||||
}
|
||||
|
||||
[data-qr-texture="none"] {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
[data-qr-texture="paper"] {
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 3px,
|
||||
color-mix(in srgb, var(--qr-bg, #f5f5f4) 88%, #000 12%) 3px,
|
||||
color-mix(in srgb, var(--qr-bg, #f5f5f4) 88%, #000 12%) 4px
|
||||
),
|
||||
radial-gradient(ellipse 120% 80% at 50% 0%, color-mix(in srgb, var(--qr-bg) 70%, #fff 30%), var(--qr-bg));
|
||||
}
|
||||
|
||||
[data-qr-texture="linen"] {
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent 6px,
|
||||
color-mix(in srgb, var(--qr-bg) 92%, #000 8%) 6px,
|
||||
color-mix(in srgb, var(--qr-bg) 92%, #000 8%) 7px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 6px,
|
||||
color-mix(in srgb, var(--qr-bg) 94%, #000 6%) 6px,
|
||||
color-mix(in srgb, var(--qr-bg) 94%, #000 6%) 7px
|
||||
);
|
||||
}
|
||||
|
||||
[data-qr-texture="dots"] {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--qr-bg) 75%, #000 25%) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 14px 14px;
|
||||
}
|
||||
|
||||
[data-qr-texture="grid"] {
|
||||
background-image:
|
||||
linear-gradient(color-mix(in srgb, var(--qr-bg) 80%, #000 20%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--qr-bg) 80%, #000 20%) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
[data-qr-texture="marble"] {
|
||||
background-image:
|
||||
radial-gradient(ellipse 70% 50% at 15% 20%, color-mix(in srgb, var(--qr-bg) 55%, #fff 45%), transparent 55%),
|
||||
radial-gradient(ellipse 60% 45% at 85% 75%, color-mix(in srgb, var(--qr-bg) 60%, #ddd 40%), transparent 50%),
|
||||
radial-gradient(ellipse 50% 40% at 50% 50%, color-mix(in srgb, var(--qr-bg) 75%, #eee 25%), transparent 60%);
|
||||
}
|
||||
|
||||
[data-qr-texture="wood"] {
|
||||
background-image: repeating-linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--qr-bg) 85%, #5c4033 15%),
|
||||
color-mix(in srgb, var(--qr-bg) 92%, #5c4033 8%) 2px,
|
||||
color-mix(in srgb, var(--qr-bg) 78%, #3e2723 22%) 4px,
|
||||
color-mix(in srgb, var(--qr-bg) 90%, #5c4033 10%) 6px
|
||||
);
|
||||
background-size: 100% 8px;
|
||||
}
|
||||
|
||||
[data-qr-texture="warm"] {
|
||||
background-image:
|
||||
radial-gradient(circle at 30% 40%, color-mix(in srgb, var(--qr-bg) 70%, #d4a574 30%) 0%, transparent 45%),
|
||||
radial-gradient(circle at 70% 60%, color-mix(in srgb, var(--qr-bg) 75%, #c9a87c 25%) 0%, transparent 40%),
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 8px,
|
||||
color-mix(in srgb, var(--qr-bg) 94%, #8b6914 6%) 8px,
|
||||
color-mix(in srgb, var(--qr-bg) 94%, #8b6914 6%) 9px
|
||||
);
|
||||
}
|
||||
|
||||
/* QR guest menu — themed surfaces/text (vars from buildQrThemeCssVars) */
|
||||
[data-qr-guest-menu] {
|
||||
color: var(--qr-text, #1c1917);
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-surface {
|
||||
background-color: var(--qr-surface, #fff);
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-muted {
|
||||
color: var(--qr-text-muted, #78716c);
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-text {
|
||||
color: var(--qr-text, #1c1917);
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-fill-muted {
|
||||
background-color: color-mix(in srgb, var(--qr-secondary, #e1f5ee) 45%, var(--qr-surface, #fff));
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-icon {
|
||||
color: var(--qr-primary, #0f6e56);
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-border {
|
||||
border-color: color-mix(in srgb, var(--qr-primary, #0f6e56) 22%, transparent);
|
||||
}
|
||||
|
||||
/* Texture swatches in settings appearance picker */
|
||||
.qr-texture-swatch[data-qr-texture] {
|
||||
min-height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "میزی — پنل مدیریت کافه",
|
||||
short_name: "میزی",
|
||||
description: "سیستم مدیریت کافه و رستوران میزی",
|
||||
start_url: "/fa/pos",
|
||||
display: "standalone",
|
||||
background_color: "#ffffff",
|
||||
theme_color: "#0F6E56",
|
||||
orientation: "any",
|
||||
categories: ["business", "productivity"],
|
||||
lang: "fa",
|
||||
dir: "rtl",
|
||||
icons: [
|
||||
{
|
||||
src: "/icons/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icons/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icons/icon-maskable-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
screenshots: [
|
||||
{
|
||||
src: "/screenshots/pos.png",
|
||||
sizes: "1280x800",
|
||||
type: "image/png",
|
||||
form_factor: "wide",
|
||||
label: "سیستم فروش حضوری",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { QrGuestMenu } from "@/components/qr/qr-guest-menu";
|
||||
|
||||
export default function QrLandingPage() {
|
||||
const params = useParams();
|
||||
const code = typeof params.code === "string" ? params.code : "";
|
||||
|
||||
return <QrGuestMenu code={code} />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
export default function QrError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-svh flex-col items-center justify-center gap-4 bg-[#f5f5f4] p-6 text-center"
|
||||
dir="rtl"
|
||||
>
|
||||
<p className="text-4xl">⚠️</p>
|
||||
<h1 className="text-lg font-semibold text-foreground">خطا در بارگذاری منو</h1>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
{error.message || "صفحه منوی میز قابل نمایش نیست."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="rounded-lg bg-[#0F6E56] px-4 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
تلاش مجدد
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import localFont from "next/font/local";
|
||||
import faMessages from "../../../messages/fa.json";
|
||||
import { MeeziToaster } from "@/components/ui/meezi-toaster";
|
||||
import "../globals.css";
|
||||
|
||||
const vazirmatn = localFont({
|
||||
src: "../../fonts/Vazirmatn-Variable.woff2",
|
||||
variable: "--font-vazirmatn",
|
||||
display: "swap",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
export default function QrRootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="fa" dir="rtl">
|
||||
<body
|
||||
className={`${vazirmatn.variable} min-h-svh bg-transparent font-sans antialiased font-[family-name:var(--font-vazirmatn)]`}
|
||||
>
|
||||
<NextIntlClientProvider locale="fa" messages={faMessages}>
|
||||
{children}
|
||||
<MeeziToaster />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Building2, RotateCcw, Trash2, Eye } from "lucide-react";
|
||||
import { apiDelete, apiGet, apiPost } from "@/lib/api/client";
|
||||
import { isCafeOwner } from "@/lib/auth-permissions";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useBranchStore } from "@/lib/stores/branch.store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Branch = {
|
||||
id: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
phone?: string;
|
||||
loginPhone?: string;
|
||||
managerName?: string;
|
||||
isPendingDeletion?: boolean;
|
||||
deletedAt?: string | null;
|
||||
scheduledPermanentDeleteAt?: string | null;
|
||||
daysUntilPermanentDelete?: number | null;
|
||||
};
|
||||
|
||||
function purgeCountdownLabel(
|
||||
branch: Branch,
|
||||
t: (key: string, values?: Record<string, number | string>) => string
|
||||
): string {
|
||||
const days = branch.daysUntilPermanentDelete ?? 0;
|
||||
if (days <= 0) return t("purgeToday");
|
||||
if (days === 1) return t("purgeInOneDay");
|
||||
return t("purgeInDays", { days });
|
||||
}
|
||||
|
||||
export function BranchesScreen() {
|
||||
const t = useTranslations("branchesPage");
|
||||
const tCommon = useTranslations("common");
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const role = useAuthStore((s) => s.user?.role);
|
||||
const setBranchId = useBranchStore((s) => s.setBranchId);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const [loginPhone, setLoginPhone] = useState("");
|
||||
const [managerName, setManagerName] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Branch | null>(null);
|
||||
const [reviewBranch, setReviewBranch] = useState<Branch | null>(null);
|
||||
|
||||
const { data: branches = [], isLoading } = useQuery({
|
||||
queryKey: ["branches", cafeId, "manage"],
|
||||
queryFn: () =>
|
||||
apiGet<Branch[]>(
|
||||
`/api/cafes/${cafeId}/branches?includePendingDeletion=true`
|
||||
),
|
||||
enabled: !!cafeId && isCafeOwner(role),
|
||||
});
|
||||
|
||||
const activeBranches = useMemo(
|
||||
() => branches.filter((b) => !b.isPendingDeletion),
|
||||
[branches]
|
||||
);
|
||||
const pendingBranches = useMemo(
|
||||
() => branches.filter((b) => b.isPendingDeletion),
|
||||
[branches]
|
||||
);
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["branches", cafeId] });
|
||||
};
|
||||
|
||||
const createBranch = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost(`/api/cafes/${cafeId}/branches`, {
|
||||
name: newBranchName.trim(),
|
||||
loginPhone: loginPhone.trim(),
|
||||
managerName: managerName.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setNewBranchName("");
|
||||
setLoginPhone("");
|
||||
setManagerName("");
|
||||
setError(null);
|
||||
setMessage(t("created"));
|
||||
invalidate();
|
||||
},
|
||||
onError: () => setError(t("createError")),
|
||||
});
|
||||
|
||||
const deleteBranch = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiDelete(`/api/cafes/${cafeId}/branches/${id}`),
|
||||
onSuccess: (_, id) => {
|
||||
setDeleteTarget(null);
|
||||
setMessage(t("deleteScheduled"));
|
||||
setError(null);
|
||||
const current = useBranchStore.getState().branchId;
|
||||
if (current === id) setBranchId(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
setDeleteTarget(null);
|
||||
setError(t("deleteError"));
|
||||
},
|
||||
});
|
||||
|
||||
const restoreBranch = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiPost<Branch>(`/api/cafes/${cafeId}/branches/${id}/restore`, {}),
|
||||
onSuccess: () => {
|
||||
setMessage(t("restored"));
|
||||
setError(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: () => setError(t("restoreError")),
|
||||
});
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
if (!isCafeOwner(role)) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
<p className="text-sm text-muted-foreground">{t("ownerOnly")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
newBranchName.trim().length > 0 &&
|
||||
loginPhone.trim().length >= 10 &&
|
||||
!createBranch.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
{message ? (
|
||||
<p className="rounded-xl border border-[#0F6E56]/30 bg-[#E1F5EE] px-4 py-3 text-sm text-[#0F6E56]">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{pendingBranches.length > 0 ? (
|
||||
<Card className="rounded-xl border border-border/60 bg-muted/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base text-muted-foreground">
|
||||
{t("pendingTitle")}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{t("pendingHint")}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y divide-border rounded-lg border border-dashed border-border">
|
||||
{pendingBranches.map((b) => (
|
||||
<li
|
||||
key={b.id}
|
||||
className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Building2
|
||||
className="mt-0.5 h-8 w-8 shrink-0 text-muted-foreground/50"
|
||||
aria-hidden
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-muted-foreground">{b.name}</p>
|
||||
<p className="mt-1 text-xs font-medium text-amber-800">
|
||||
{purgeCountdownLabel(b, t)}
|
||||
</p>
|
||||
{b.scheduledPermanentDeleteAt ? (
|
||||
<p className="text-[10px] text-muted-foreground" dir="ltr">
|
||||
{new Date(b.scheduledPermanentDeleteAt).toLocaleString()}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setReviewBranch(b)}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 me-1.5" />
|
||||
{t("review")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={restoreBranch.isPending}
|
||||
onClick={() => restoreBranch.mutate(b.id)}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 me-1.5" />
|
||||
{t("restore")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="rounded-xl border border-border/80 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("listTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
||||
) : activeBranches.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{activeBranches.map((b) => (
|
||||
<li
|
||||
key={b.id}
|
||||
className="flex flex-col gap-2 px-4 py-3 text-start sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">{b.name}</span>
|
||||
{b.managerName ? (
|
||||
<p className="text-xs text-muted-foreground">{b.managerName}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{b.loginPhone ? (
|
||||
<span dir="ltr" className="font-medium text-foreground">
|
||||
{t("loginPhone")}: {b.loginPhone}
|
||||
</span>
|
||||
) : null}
|
||||
{(b.city || b.address) && (
|
||||
<p>{[b.city, b.address].filter(Boolean).join(" · ")}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setReviewBranch(b)}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 me-1.5" />
|
||||
{t("review")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-destructive/40 text-destructive hover:bg-destructive/10"
|
||||
)}
|
||||
disabled={activeBranches.length <= 1}
|
||||
onClick={() => setDeleteTarget(b)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 me-1.5" />
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form
|
||||
className="space-y-3 border-t border-border pt-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (canSubmit) createBranch.mutate();
|
||||
}}
|
||||
>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{t("addSection")}
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<LabeledField label={t("newName")} htmlFor="new-branch-name">
|
||||
<Input
|
||||
id="new-branch-name"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("loginPhone")} htmlFor="branch-login-phone">
|
||||
<Input
|
||||
id="branch-login-phone"
|
||||
value={loginPhone}
|
||||
onChange={(e) => setLoginPhone(e.target.value)}
|
||||
placeholder="09121234567"
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
autoComplete="tel"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField
|
||||
label={t("managerName")}
|
||||
htmlFor="branch-manager-name"
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<Input
|
||||
id="branch-manager-name"
|
||||
value={managerName}
|
||||
onChange={(e) => setManagerName(e.target.value)}
|
||||
placeholder={t("managerNamePlaceholder")}
|
||||
/>
|
||||
</LabeledField>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{createBranch.isPending ? "..." : t("add")}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">{t("masterPlanHint")}</p>
|
||||
</form>
|
||||
<p className="text-xs text-muted-foreground">{t("branchSelectHint")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("deleteWarning")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
onClick={() => deleteTarget && deleteBranch.mutate(deleteTarget.id)}
|
||||
>
|
||||
{t("deleteConfirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog
|
||||
open={!!reviewBranch}
|
||||
onOpenChange={(open) => !open && setReviewBranch(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("reviewTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div>
|
||||
{reviewBranch ? (
|
||||
<dl className="mt-2 space-y-2 text-start text-sm text-foreground">
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{t("newName")}</dt>
|
||||
<dd className="font-medium">{reviewBranch.name}</dd>
|
||||
</div>
|
||||
{reviewBranch.managerName ? (
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{t("managerName")}</dt>
|
||||
<dd>{reviewBranch.managerName}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{reviewBranch.loginPhone ? (
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{t("loginPhone")}</dt>
|
||||
<dd dir="ltr">{reviewBranch.loginPhone}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{(reviewBranch.city || reviewBranch.address) && (
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{t("location")}</dt>
|
||||
<dd>
|
||||
{[reviewBranch.city, reviewBranch.address]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{reviewBranch.isPendingDeletion ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
{purgeCountdownLabel(reviewBranch, t)}
|
||||
</p>
|
||||
) : null}
|
||||
</dl>
|
||||
) : null}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={() => setReviewBranch(null)}>
|
||||
{tCommon("confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus } from "lucide-react";
|
||||
import { apiGet, apiPost } from "@/lib/api/client";
|
||||
import type { Coupon, CouponType } from "@/lib/api/types";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export function CouponsScreen() {
|
||||
const t = useTranslations("coupons");
|
||||
const tCommon = useTranslations("common");
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [type, setType] = useState<CouponType>("Percentage");
|
||||
const [value, setValue] = useState("10");
|
||||
|
||||
const { data: coupons = [], isLoading } = useQuery({
|
||||
queryKey: ["coupons", cafeId],
|
||||
queryFn: () => apiGet<Coupon[]>(`/api/cafes/${cafeId}/coupons`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const createCoupon = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<Coupon>(`/api/cafes/${cafeId}/coupons`, {
|
||||
code,
|
||||
type,
|
||||
value: Number(value),
|
||||
isActive: true,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["coupons", cafeId] });
|
||||
setShowForm(false);
|
||||
setCode("");
|
||||
setValue("10");
|
||||
},
|
||||
});
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">{t("title")}</h2>
|
||||
<Button onClick={() => setShowForm(!showForm)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("addCoupon")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardContent className="grid gap-3 pt-6 sm:grid-cols-3">
|
||||
<LabeledField label={t("code")} htmlFor="coupon-code">
|
||||
<Input
|
||||
id="coupon-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("type")} htmlFor="coupon-type">
|
||||
<select
|
||||
id="coupon-type"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as CouponType)}
|
||||
>
|
||||
<option value="Percentage">{t("types.Percentage")}</option>
|
||||
<option value="FixedAmount">{t("types.FixedAmount")}</option>
|
||||
</select>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("value")} htmlFor="coupon-value">
|
||||
<Input
|
||||
id="coupon-value"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
type="number"
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<div className="flex gap-2 sm:col-span-3">
|
||||
<Button onClick={() => createCoupon.mutate()} disabled={createCoupon.isPending}>
|
||||
{tCommon("save")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowForm(false)}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">{tCommon("loading")}</p>
|
||||
) : coupons.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t("noCoupons")}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{coupons.map((c) => (
|
||||
<Card key={c.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="font-mono text-lg">{c.code}</CardTitle>
|
||||
<Badge variant={c.isActive ? "default" : "secondary"}>
|
||||
{c.isActive ? t("active") : t("inactive")}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
<p>
|
||||
{t("type")}: {t(`types.${c.type}`)}
|
||||
</p>
|
||||
<p>
|
||||
{t("value")}: {formatNumber(c.value)}
|
||||
{c.type === "Percentage" ? "%" : " ت"}
|
||||
</p>
|
||||
<p>
|
||||
{t("usage")}: {formatNumber(c.usedCount)}
|
||||
{c.usageLimit ? ` / ${formatNumber(c.usageLimit)}` : ""}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus, Pencil, Search } from "lucide-react";
|
||||
import { apiGet } from "@/lib/api/client";
|
||||
import type { Customer } from "@/lib/api/types";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CustomerWizard, type CustomerWizardMode } from "@/components/crm/customer-wizard";
|
||||
|
||||
export function CrmScreen() {
|
||||
const t = useTranslations("crm");
|
||||
const tCommon = useTranslations("common");
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [wizardMode, setWizardMode] = useState<CustomerWizardMode>("create");
|
||||
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
||||
|
||||
const { data: customers = [], isLoading } = useQuery({
|
||||
queryKey: ["customers", cafeId, debouncedSearch],
|
||||
queryFn: () =>
|
||||
apiGet<Customer[]>(
|
||||
`/api/cafes/${cafeId}/customers${debouncedSearch ? `?q=${encodeURIComponent(debouncedSearch)}` : ""}`
|
||||
),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const openWizard = (mode: CustomerWizardMode, customer?: Customer) => {
|
||||
setWizardMode(mode);
|
||||
setEditingCustomer(customer ?? null);
|
||||
setWizardOpen(true);
|
||||
};
|
||||
|
||||
const refreshCustomers = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["customers", cafeId] });
|
||||
};
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-xl font-bold">{t("title")}</h2>
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
onClick={() => openWizard("create")}
|
||||
>
|
||||
<Plus className="me-2 h-4 w-4" />
|
||||
{t("addCustomer")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<LabeledField label={tCommon("search")} htmlFor="crm-search" className="min-w-[12rem] flex-1">
|
||||
<Input
|
||||
id="crm-search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && setDebouncedSearch(search)}
|
||||
/>
|
||||
</LabeledField>
|
||||
<Button variant="outline" onClick={() => setDebouncedSearch(search)}>
|
||||
<Search className="h-4 w-4" />
|
||||
{tCommon("search")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">{tCommon("loading")}</p>
|
||||
) : customers.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t("noCustomers")}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{customers.map((c) => (
|
||||
<Card key={c.id} className="rounded-xl border border-border/80 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-base">{c.name}</CardTitle>
|
||||
<Badge variant="secondary">{t(`groups.${c.group}`)}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p dir="ltr" className="text-end font-mono">
|
||||
{c.phone}
|
||||
</p>
|
||||
{c.nationalId ? (
|
||||
<p>
|
||||
{t("nationalId")}: {c.nationalId}
|
||||
</p>
|
||||
) : null}
|
||||
<p>
|
||||
{t("loyaltyPoints")}: {formatNumber(c.loyaltyPoints)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => openWizard("edit", c)}
|
||||
>
|
||||
<Pencil className="me-1 h-3.5 w-3.5" />
|
||||
{tCommon("edit")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CustomerWizard
|
||||
open={wizardOpen}
|
||||
mode={wizardMode}
|
||||
cafeId={cafeId}
|
||||
customer={editingCustomer}
|
||||
onClose={() => setWizardOpen(false)}
|
||||
onSaved={refreshCustomers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import { apiPatch, apiPost, ApiClientError } from "@/lib/api/client";
|
||||
import type { Customer, CustomerGroup } from "@/lib/api/types";
|
||||
import { useIsRtl } from "@/lib/use-is-rtl";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const GROUPS: CustomerGroup[] = ["Regular", "Vip", "New", "Employee"];
|
||||
const STEP_COUNT = 4;
|
||||
|
||||
export type CustomerWizardMode = "create" | "edit";
|
||||
|
||||
type CustomerWizardProps = {
|
||||
open: boolean;
|
||||
mode: CustomerWizardMode;
|
||||
cafeId: string;
|
||||
customer?: Customer | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
phone: string;
|
||||
nationalId: string;
|
||||
birthDateJalali: string;
|
||||
group: CustomerGroup;
|
||||
loyaltyPoints: string;
|
||||
referredBy: string;
|
||||
};
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return {
|
||||
name: "",
|
||||
phone: "",
|
||||
nationalId: "",
|
||||
birthDateJalali: "",
|
||||
group: "Regular",
|
||||
loyaltyPoints: "0",
|
||||
referredBy: "",
|
||||
};
|
||||
}
|
||||
|
||||
function fromCustomer(c: Customer): FormState {
|
||||
return {
|
||||
name: c.name,
|
||||
phone: c.phone,
|
||||
nationalId: c.nationalId ?? "",
|
||||
birthDateJalali: c.birthDateJalali ?? "",
|
||||
group: c.group,
|
||||
loyaltyPoints: String(c.loyaltyPoints),
|
||||
referredBy: c.referredBy ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function CustomerWizard({
|
||||
open,
|
||||
mode,
|
||||
cafeId,
|
||||
customer,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: CustomerWizardProps) {
|
||||
const t = useTranslations("crm.wizard");
|
||||
const tCrm = useTranslations("crm");
|
||||
const tCommon = useTranslations("common");
|
||||
const isRtl = useIsRtl();
|
||||
const numberLocale = isRtl ? "fa-IR" : "en-US";
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStep(1);
|
||||
setError(null);
|
||||
setForm(mode === "edit" && customer ? fromCustomer(customer) : emptyForm());
|
||||
}, [open, mode, customer]);
|
||||
|
||||
const stepLabels = useMemo(
|
||||
() => [t("steps.contact"), t("steps.profile"), t("steps.loyalty"), t("steps.confirm")],
|
||||
[t]
|
||||
);
|
||||
|
||||
const canNext = () => {
|
||||
if (step === 1) return form.name.trim().length > 0 && form.phone.trim().length >= 10;
|
||||
return true;
|
||||
};
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const loyalty = parseInt(form.loyaltyPoints, 10) || 0;
|
||||
if (mode === "create") {
|
||||
return apiPost<Customer>(`/api/cafes/${cafeId}/customers`, {
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
nationalId: form.nationalId.trim() || undefined,
|
||||
birthDateJalali: form.birthDateJalali.trim() || undefined,
|
||||
group: form.group,
|
||||
referredBy: form.referredBy.trim() || undefined,
|
||||
});
|
||||
}
|
||||
if (!customer?.id) throw new Error("missing customer");
|
||||
return apiPatch<Customer>(`/api/cafes/${cafeId}/customers/${customer.id}`, {
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
nationalId: form.nationalId.trim() || undefined,
|
||||
birthDateJalali: form.birthDateJalali.trim() || undefined,
|
||||
group: form.group,
|
||||
loyaltyPoints: loyalty,
|
||||
referredBy: form.referredBy.trim() || undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSaved();
|
||||
onClose();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
const code = err instanceof ApiClientError ? err.code : "";
|
||||
if (code === "DUPLICATE_PHONE") setError(t("errors.duplicatePhone"));
|
||||
else setError(t("errors.generic"));
|
||||
},
|
||||
});
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const BackIcon = isRtl ? ChevronRight : ChevronLeft;
|
||||
const NextIcon = isRtl ? ChevronLeft : ChevronRight;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="customer-wizard-title"
|
||||
>
|
||||
<Card className="flex max-h-[min(90dvh,640px)] w-full max-w-lg flex-col overflow-hidden shadow-xl">
|
||||
<CardHeader className="shrink-0 space-y-3 border-b border-border/80 pb-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle id="customer-wizard-title" className="text-lg">
|
||||
{mode === "create" ? t("titleCreate") : t("titleEdit")}
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("stepOf", { current: step, total: STEP_COUNT })}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" size="icon" variant="ghost" onClick={onClose} aria-label={tCommon("cancel")}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{stepLabels.map((label, i) => {
|
||||
const n = i + 1;
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className={cn(
|
||||
"h-1 flex-1 rounded-full transition-colors",
|
||||
n <= step ? "bg-[#0F6E56]" : "bg-muted"
|
||||
)}
|
||||
title={label}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{stepLabels[step - 1]}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="min-h-0 flex-1 overflow-y-auto py-4">
|
||||
{error ? (
|
||||
<p className="mb-3 rounded-md border border-[#A32D2D]/30 bg-red-50 px-3 py-2 text-sm text-[#A32D2D]">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-3">
|
||||
<LabeledField label={tCrm("name")} htmlFor="wiz-name">
|
||||
<Input
|
||||
id="wiz-name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
autoFocus
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={tCrm("phone")} htmlFor="wiz-phone">
|
||||
<Input
|
||||
id="wiz-phone"
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
inputMode="tel"
|
||||
/>
|
||||
</LabeledField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-3">
|
||||
<LabeledField label={tCrm("nationalId")} htmlFor="wiz-national-id">
|
||||
<Input
|
||||
id="wiz-national-id"
|
||||
value={form.nationalId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, nationalId: e.target.value }))}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
maxLength={10}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={tCrm("birthDate")} htmlFor="wiz-birth" hint={t("birthHint")}>
|
||||
<Input
|
||||
id="wiz-birth"
|
||||
value={form.birthDateJalali}
|
||||
onChange={(e) => setForm((f) => ({ ...f, birthDateJalali: e.target.value }))}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
placeholder="1400/01/01"
|
||||
/>
|
||||
</LabeledField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-3">
|
||||
<LabeledField label={tCrm("group")} htmlFor="wiz-group">
|
||||
<select
|
||||
id="wiz-group"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={form.group}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, group: e.target.value as CustomerGroup }))
|
||||
}
|
||||
>
|
||||
{GROUPS.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{tCrm(`groups.${g}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
<LabeledField label={tCrm("loyaltyPoints")} htmlFor="wiz-points">
|
||||
<Input
|
||||
id="wiz-points"
|
||||
value={form.loyaltyPoints}
|
||||
onChange={(e) => setForm((f) => ({ ...f, loyaltyPoints: e.target.value }))}
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
disabled={mode === "create"}
|
||||
/>
|
||||
</LabeledField>
|
||||
{mode === "create" ? (
|
||||
<p className="text-xs text-muted-foreground">{t("loyaltyCreateHint")}</p>
|
||||
) : null}
|
||||
<LabeledField label={t("referredBy")} htmlFor="wiz-referred">
|
||||
<Input
|
||||
id="wiz-referred"
|
||||
value={form.referredBy}
|
||||
onChange={(e) => setForm((f) => ({ ...f, referredBy: e.target.value }))}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div className="flex justify-between gap-2 border-b border-border/60 pb-2">
|
||||
<dt className="text-muted-foreground">{tCrm("name")}</dt>
|
||||
<dd className="font-medium">{form.name}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2 border-b border-border/60 pb-2">
|
||||
<dt className="text-muted-foreground">{tCrm("phone")}</dt>
|
||||
<dd className="font-mono" dir="ltr">
|
||||
{form.phone}
|
||||
</dd>
|
||||
</div>
|
||||
{form.nationalId ? (
|
||||
<div className="flex justify-between gap-2 border-b border-border/60 pb-2">
|
||||
<dt className="text-muted-foreground">{tCrm("nationalId")}</dt>
|
||||
<dd dir="ltr">{form.nationalId}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{form.birthDateJalali ? (
|
||||
<div className="flex justify-between gap-2 border-b border-border/60 pb-2">
|
||||
<dt className="text-muted-foreground">{tCrm("birthDate")}</dt>
|
||||
<dd dir="ltr">{form.birthDateJalali}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-between gap-2 border-b border-border/60 pb-2">
|
||||
<dt className="text-muted-foreground">{tCrm("group")}</dt>
|
||||
<dd>
|
||||
<Badge variant="secondary">{tCrm(`groups.${form.group}`)}</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt className="text-muted-foreground">{tCrm("loyaltyPoints")}</dt>
|
||||
<dd>{formatNumber(parseInt(form.loyaltyPoints, 10) || 0, numberLocale)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<div className="flex shrink-0 gap-2 border-t border-border/80 p-4">
|
||||
{step > 1 ? (
|
||||
<Button type="button" variant="outline" onClick={() => setStep((s) => s - 1)}>
|
||||
<BackIcon className="me-1 h-4 w-4" />
|
||||
{t("back")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{step < STEP_COUNT ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={!canNext()}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setStep((s) => s + 1);
|
||||
}}
|
||||
>
|
||||
{t("next")}
|
||||
<NextIcon className="ms-1 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={save.isPending}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
save.mutate();
|
||||
}}
|
||||
>
|
||||
{save.isPending ? tCommon("loading") : tCommon("save")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
DISCOVER_TAXONOMY,
|
||||
type CafeDiscoverProfile,
|
||||
type DiscoverListField,
|
||||
type DiscoverSingleField,
|
||||
toggleListValue,
|
||||
} from "@/lib/cafe-discover-profile";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CafeDiscoverProfileEditorProps = {
|
||||
value: CafeDiscoverProfile;
|
||||
onChange: (next: CafeDiscoverProfile) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function CafeDiscoverProfileEditor({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: CafeDiscoverProfileEditorProps) {
|
||||
const t = useTranslations("discoverProfile");
|
||||
|
||||
const setList = (field: DiscoverListField, id: string) => {
|
||||
onChange({ ...value, [field]: toggleListValue(value[field], id) });
|
||||
};
|
||||
|
||||
const setSingle = (field: DiscoverSingleField, id: string) => {
|
||||
onChange({ ...value, [field]: value[field] === id ? null : id });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<ProfileSection label={t("sections.themes")} hint={t("hints.themes")}>
|
||||
<ChipGrid
|
||||
ids={DISCOVER_TAXONOMY.themes}
|
||||
selected={value.themes}
|
||||
label={(id) => t(`themes.${id}`)}
|
||||
onToggle={(id) => setList("themes", id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</ProfileSection>
|
||||
|
||||
<ProfileSection label={t("sections.occasions")} hint={t("hints.occasions")}>
|
||||
<ChipGrid
|
||||
ids={DISCOVER_TAXONOMY.occasions}
|
||||
selected={value.occasions}
|
||||
label={(id) => t(`occasions.${id}`)}
|
||||
onToggle={(id) => setList("occasions", id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</ProfileSection>
|
||||
|
||||
<ProfileSection label={t("sections.spaceFeatures")} hint={t("hints.spaceFeatures")}>
|
||||
<ChipGrid
|
||||
ids={DISCOVER_TAXONOMY.spaceFeatures}
|
||||
selected={value.spaceFeatures}
|
||||
label={(id) => t(`spaceFeatures.${id}`)}
|
||||
onToggle={(id) => setList("spaceFeatures", id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</ProfileSection>
|
||||
|
||||
<ProfileSection label={t("sections.vibes")} hint={t("hints.vibes")}>
|
||||
<ChipGrid
|
||||
ids={DISCOVER_TAXONOMY.vibes}
|
||||
selected={value.vibes}
|
||||
label={(id) => t(`vibes.${id}`)}
|
||||
onToggle={(id) => setList("vibes", id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</ProfileSection>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<ProfileSection label={t("sections.size")}>
|
||||
<ChipGrid
|
||||
ids={DISCOVER_TAXONOMY.sizes}
|
||||
selected={value.size ? [value.size] : []}
|
||||
label={(id) => t(`sizes.${id}`)}
|
||||
onToggle={(id) => setSingle("size", id)}
|
||||
disabled={disabled}
|
||||
single
|
||||
/>
|
||||
</ProfileSection>
|
||||
<ProfileSection label={t("sections.floors")}>
|
||||
<ChipGrid
|
||||
ids={DISCOVER_TAXONOMY.floors}
|
||||
selected={value.floors ? [value.floors] : []}
|
||||
label={(id) => t(`floors.${id}`)}
|
||||
onToggle={(id) => setSingle("floors", id)}
|
||||
disabled={disabled}
|
||||
single
|
||||
/>
|
||||
</ProfileSection>
|
||||
<ProfileSection label={t("sections.noiseLevel")}>
|
||||
<ChipGrid
|
||||
ids={DISCOVER_TAXONOMY.noiseLevels}
|
||||
selected={value.noiseLevel ? [value.noiseLevel] : []}
|
||||
label={(id) => t(`noiseLevels.${id}`)}
|
||||
onToggle={(id) => setSingle("noiseLevel", id)}
|
||||
disabled={disabled}
|
||||
single
|
||||
/>
|
||||
</ProfileSection>
|
||||
<ProfileSection label={t("sections.priceTier")}>
|
||||
<ChipGrid
|
||||
ids={DISCOVER_TAXONOMY.priceTiers}
|
||||
selected={value.priceTier ? [value.priceTier] : []}
|
||||
label={(id) => t(`priceTiers.${id}`)}
|
||||
onToggle={(id) => setSingle("priceTier", id)}
|
||||
disabled={disabled}
|
||||
single
|
||||
/>
|
||||
</ProfileSection>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSection({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipGrid({
|
||||
ids,
|
||||
selected,
|
||||
label,
|
||||
onToggle,
|
||||
disabled,
|
||||
single,
|
||||
}: {
|
||||
ids: readonly string[];
|
||||
selected: string[];
|
||||
label: (id: string) => string;
|
||||
onToggle: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
single?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ids.map((id) => {
|
||||
const active = selected.includes(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onToggle(id)}
|
||||
className={cn(
|
||||
"rounded-lg border px-2.5 py-1.5 text-xs font-medium transition active:scale-[0.98]",
|
||||
active
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
||||
: "border-border/80 bg-card text-foreground hover:border-[#0F6E56]/40",
|
||||
disabled && "pointer-events-none opacity-50"
|
||||
)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{label(id)}
|
||||
{!single && active ? (
|
||||
<span className="ms-1 opacity-70" aria-hidden>
|
||||
✓
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { apiGet, apiPut } from "@/lib/api/client";
|
||||
import { adminGet, adminPut } from "@/lib/api/admin-client";
|
||||
import {
|
||||
EMPTY_DISCOVER_PROFILE,
|
||||
type CafeDiscoverProfile,
|
||||
} from "@/lib/cafe-discover-profile";
|
||||
import { CafeDiscoverProfileEditor } from "@/components/discover/cafe-discover-profile-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { notify } from "@/lib/notify";
|
||||
|
||||
type ApiDiscoverProfile = {
|
||||
themes: string[];
|
||||
size?: string | null;
|
||||
floors?: string | null;
|
||||
vibes: string[];
|
||||
occasions: string[];
|
||||
spaceFeatures: string[];
|
||||
noiseLevel?: string | null;
|
||||
priceTier?: string | null;
|
||||
};
|
||||
|
||||
function fromApi(d: ApiDiscoverProfile): CafeDiscoverProfile {
|
||||
return {
|
||||
themes: d.themes ?? [],
|
||||
size: d.size ?? null,
|
||||
floors: d.floors ?? null,
|
||||
vibes: d.vibes ?? [],
|
||||
occasions: d.occasions ?? [],
|
||||
spaceFeatures: d.spaceFeatures ?? [],
|
||||
noiseLevel: d.noiseLevel ?? null,
|
||||
priceTier: d.priceTier ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function toApiBody(p: CafeDiscoverProfile) {
|
||||
return {
|
||||
themes: p.themes,
|
||||
size: p.size,
|
||||
floors: p.floors,
|
||||
vibes: p.vibes,
|
||||
occasions: p.occasions,
|
||||
spaceFeatures: p.spaceFeatures,
|
||||
noiseLevel: p.noiseLevel,
|
||||
priceTier: p.priceTier,
|
||||
};
|
||||
}
|
||||
|
||||
type CafeDiscoverProfilePanelProps = {
|
||||
cafeId: string;
|
||||
mode: "merchant" | "admin";
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function CafeDiscoverProfilePanel({
|
||||
cafeId,
|
||||
mode,
|
||||
compact,
|
||||
}: CafeDiscoverProfilePanelProps) {
|
||||
const t = useTranslations(
|
||||
mode === "admin" ? "admin.cafes.discoverProfile" : "settings.discoverProfile"
|
||||
);
|
||||
const qc = useQueryClient();
|
||||
const [profile, setProfile] = useState<CafeDiscoverProfile>(EMPTY_DISCOVER_PROFILE);
|
||||
|
||||
const queryKey =
|
||||
mode === "admin"
|
||||
? ["admin", "cafe-discover-profile", cafeId]
|
||||
: ["cafe-discover-profile", cafeId];
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
if (mode === "admin") {
|
||||
const res = await adminGet<ApiDiscoverProfile & { cafeId: string; cafeName: string }>(
|
||||
`/api/admin/cafes/${cafeId}/discover-profile`
|
||||
);
|
||||
return fromApi(res);
|
||||
}
|
||||
const res = await apiGet<ApiDiscoverProfile>(`/api/cafes/${cafeId}/discover-profile`);
|
||||
return fromApi(res);
|
||||
},
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setProfile(data);
|
||||
}, [data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body = toApiBody(profile);
|
||||
return mode === "admin"
|
||||
? adminPut(`/api/admin/cafes/${cafeId}/discover-profile`, body)
|
||||
: apiPut(`/api/cafes/${cafeId}/discover-profile`, body);
|
||||
},
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey });
|
||||
notify.success(t("saved"));
|
||||
},
|
||||
});
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{!compact ? (
|
||||
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
||||
) : (
|
||||
<CafeDiscoverProfileEditor
|
||||
value={profile}
|
||||
onChange={setProfile}
|
||||
disabled={save.isPending}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={save.isPending || isLoading}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return <div className="space-y-4">{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="rounded-xl border border-border/80 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">{content}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchCafePublicProfile,
|
||||
removeGalleryPhoto,
|
||||
updateCafePublicProfile,
|
||||
uploadGalleryPhoto,
|
||||
type CafeProfileEdit,
|
||||
} from "@/lib/api/cafe-public-profile";
|
||||
import type { WorkingHours } from "@/lib/api/public-discover";
|
||||
import { resolveMediaUrl } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Props = { cafeId: string };
|
||||
|
||||
type Tab = "info" | "gallery" | "hours" | "social";
|
||||
|
||||
const DAY_KEYS: (keyof WorkingHours)[] = ["sat", "sun", "mon", "tue", "wed", "thu", "fri"];
|
||||
|
||||
export function CafePublicProfilePanel({ cafeId }: Props) {
|
||||
const t = useTranslations("cafePublicProfile");
|
||||
const qc = useQueryClient();
|
||||
const [tab, setTab] = useState<Tab>("info");
|
||||
const [saved, setSaved] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── Server state ──────────────────────────────────────────────────────────
|
||||
const { data: profile, isLoading } = useQuery({
|
||||
queryKey: ["cafe-public-profile", cafeId],
|
||||
queryFn: () => fetchCafePublicProfile(cafeId),
|
||||
});
|
||||
|
||||
// ── Local edit state ──────────────────────────────────────────────────────
|
||||
const [description, setDescription] = useState<string>("");
|
||||
const [instagram, setInstagram] = useState<string>("");
|
||||
const [website, setWebsite] = useState<string>("");
|
||||
const [hours, setHours] = useState<WorkingHours>(emptyHours());
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// Populate local state once we get server data
|
||||
if (profile && !initialized) {
|
||||
setDescription(profile.description ?? "");
|
||||
setInstagram(profile.instagramHandle ?? "");
|
||||
setWebsite(profile.websiteUrl ?? "");
|
||||
setHours(profile.workingHours ?? emptyHours());
|
||||
setInitialized(true);
|
||||
}
|
||||
|
||||
// ── Save info/social/hours ────────────────────────────────────────────────
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateCafePublicProfile(cafeId, {
|
||||
description,
|
||||
instagramHandle: instagram || null,
|
||||
websiteUrl: website || null,
|
||||
workingHours: hours,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(["cafe-public-profile", cafeId], data);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Gallery upload ────────────────────────────────────────────────────────
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setUploadError(null);
|
||||
try {
|
||||
const gallery = await uploadGalleryPhoto(cafeId, file);
|
||||
qc.setQueryData<CafeProfileEdit>(["cafe-public-profile", cafeId], (old) =>
|
||||
old ? { ...old, galleryUrls: gallery } : old
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : t("uploadFailed");
|
||||
setUploadError(msg.includes("GALLERY_FULL") ? t("galleryFull") : t("uploadFailed"));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (url: string) => removeGalleryPhoto(cafeId, url),
|
||||
onSuccess: (gallery) => {
|
||||
qc.setQueryData<CafeProfileEdit>(["cafe-public-profile", cafeId], (old) =>
|
||||
old ? { ...old, galleryUrls: gallery } : old
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Hours helpers ─────────────────────────────────────────────────────────
|
||||
const setDayField = (
|
||||
day: keyof WorkingHours,
|
||||
field: "isOpen" | "open" | "close",
|
||||
value: string | boolean
|
||||
) => {
|
||||
setHours((prev) => ({
|
||||
...prev,
|
||||
[day]: {
|
||||
...((prev[day] as object) ?? { isOpen: false, open: "", close: "" }),
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-muted-foreground p-4">{t("loading")}</p>;
|
||||
}
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: "info", label: t("tabs.info") },
|
||||
{ id: "gallery", label: t("tabs.gallery") },
|
||||
{ id: "hours", label: t("tabs.hours") },
|
||||
{ id: "social", label: t("tabs.social") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">{t("title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 rounded-xl border border-border/80 bg-muted/40 p-1">
|
||||
{tabs.map((tb) => (
|
||||
<button
|
||||
key={tb.id}
|
||||
type="button"
|
||||
onClick={() => setTab(tb.id)}
|
||||
className={cn(
|
||||
"flex-1 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer",
|
||||
tab === tb.id
|
||||
? "bg-white shadow-sm text-[#0F6E56]"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{tb.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Info tab ─────────────────────────────────────────────────────── */}
|
||||
{tab === "info" && (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("description")}</Label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value)}
|
||||
placeholder={t("descriptionPlaceholder")}
|
||||
rows={5}
|
||||
className="w-full resize-none rounded-lg border border-border/80 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#0F6E56]"
|
||||
/>
|
||||
</div>
|
||||
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Gallery tab ──────────────────────────────────────────────────── */}
|
||||
{tab === "gallery" && (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("gallery")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("galleryHint")}</p>
|
||||
</div>
|
||||
|
||||
{/* Existing photos */}
|
||||
{profile?.galleryUrls && profile.galleryUrls.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{profile.galleryUrls.map((url) => {
|
||||
const src = resolveMediaUrl(url);
|
||||
return (
|
||||
<div key={url} className="group relative">
|
||||
<div
|
||||
className="aspect-square rounded-lg bg-cover bg-center"
|
||||
style={{ backgroundImage: src ? `url(${src})` : undefined }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMutation.mutate(url)}
|
||||
disabled={removeMutation.isPending}
|
||||
className="absolute end-1 top-1 rounded-md bg-black/60 px-2 py-0.5 text-[10px] text-white opacity-0 transition group-hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
{t("removePhoto")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">هنوز عکسی آپلود نشده</p>
|
||||
)}
|
||||
|
||||
{/* Upload button */}
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={uploading || (profile?.galleryUrls?.length ?? 0) >= 8}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{uploading ? t("uploading") : t("uploadPhoto")}
|
||||
</Button>
|
||||
{uploadError && (
|
||||
<p className="mt-1 text-xs text-red-500">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Working hours tab ─────────────────────────────────────────────── */}
|
||||
{tab === "hours" && (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<p className="text-sm font-medium">{t("workingHours")}</p>
|
||||
<div className="space-y-2">
|
||||
{DAY_KEYS.map((day) => {
|
||||
const d = hours[day] as { isOpen: boolean; open?: string; close?: string } | null;
|
||||
return (
|
||||
<div key={day} className="flex flex-wrap items-center gap-3 rounded-lg border border-border/60 px-3 py-2">
|
||||
<span className="w-20 text-sm font-medium">{t(`days.${day}`)}</span>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={d?.isOpen ?? false}
|
||||
onChange={(e) => setDayField(day, "isOpen", e.target.checked)}
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs">{t("isOpen")}</span>
|
||||
</label>
|
||||
{d?.isOpen && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="time"
|
||||
value={d.open ?? ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDayField(day, "open", e.target.value)}
|
||||
className="rounded border border-border/80 px-2 py-1 text-xs"
|
||||
dir="ltr"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
<input
|
||||
type="time"
|
||||
value={d.close ?? ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDayField(day, "close", e.target.value)}
|
||||
className="rounded border border-border/80 px-2 py-1 text-xs"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Social tab ───────────────────────────────────────────────────── */}
|
||||
{tab === "social" && (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("instagram")}</Label>
|
||||
<div className="flex items-center rounded-lg border border-border/80 px-3">
|
||||
<span className="text-sm text-muted-foreground">@</span>
|
||||
<Input
|
||||
value={instagram}
|
||||
onChange={(e) => setInstagram(e.target.value.replace(/^@/, ""))}
|
||||
placeholder={t("instagramPlaceholder")}
|
||||
className="border-0 ps-1 shadow-none"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("website")}</Label>
|
||||
<Input
|
||||
value={website}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
placeholder={t("websitePlaceholder")}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Save button shared sub-component ─────────────────────────────────────────
|
||||
|
||||
function SaveButton({
|
||||
saving,
|
||||
saved,
|
||||
onSave,
|
||||
t,
|
||||
}: {
|
||||
saving: boolean;
|
||||
saved: boolean;
|
||||
onSave: () => void;
|
||||
t: ReturnType<typeof useTranslations<"cafePublicProfile">>;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="bg-[#0F6E56]"
|
||||
>
|
||||
{saving ? "…" : saved ? t("saved") : t("save")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function emptyHours(): WorkingHours {
|
||||
const day = () => ({ isOpen: false, open: null, close: null });
|
||||
return { sat: day(), sun: day(), mon: day(), tue: day(), wed: day(), thu: day(), fri: day() };
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { apiPostPublic, ApiClientError } from "@/lib/api/client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type CoffeeAdvisorPick = {
|
||||
name: string;
|
||||
reason: string;
|
||||
menuItemId?: string | null;
|
||||
};
|
||||
|
||||
type CoffeeAdvisorResult = {
|
||||
summary: string;
|
||||
picks: CoffeeAdvisorPick[];
|
||||
};
|
||||
|
||||
type CoffeeAdvisorPanelProps = {
|
||||
cafeSlug: string;
|
||||
};
|
||||
|
||||
export function CoffeeAdvisorPanel({ cafeSlug }: CoffeeAdvisorPanelProps) {
|
||||
const t = useTranslations("discoverPublic.coffeeAdvisor");
|
||||
const [purpose, setPurpose] = useState("");
|
||||
const [result, setResult] = useState<CoffeeAdvisorResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
const trimmed = purpose.trim();
|
||||
if (trimmed.length < 3) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const data = await apiPostPublic<CoffeeAdvisorResult>(
|
||||
"/api/public/coffee-advisor",
|
||||
{ purpose: trimmed, cafeSlug }
|
||||
);
|
||||
setResult(data);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiClientError && e.code === "AI_NOT_CONFIGURED") {
|
||||
setError(t("notConfigured"));
|
||||
} else {
|
||||
setError(t("failed"));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="rounded-xl border border-primary/20 bg-gradient-to-b from-[#E1F5EE]/40 to-card">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Sparkles className="h-4 w-4 text-primary" aria-hidden />
|
||||
{t("title")}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{t("subtitle")}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={purpose}
|
||||
onChange={(e) => setPurpose(e.target.value)}
|
||||
placeholder={t("placeholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !loading) void submit();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
className="shrink-0 bg-primary hover:bg-primary/90"
|
||||
disabled={loading || purpose.trim().length < 3}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{loading ? t("loading") : t("submit")}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : null}
|
||||
{result ? (
|
||||
<div className="space-y-3 rounded-lg border border-primary/15 bg-card/80 p-3">
|
||||
<p className="text-sm leading-relaxed">{result.summary}</p>
|
||||
{result.picks.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{result.picks.map((pick) => (
|
||||
<li
|
||||
key={`${pick.name}-${pick.menuItemId ?? "x"}`}
|
||||
className="rounded-lg border border-border/60 bg-background px-3 py-2"
|
||||
>
|
||||
<p className="text-sm font-medium text-primary">{pick.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{pick.reason}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchPublicCafe,
|
||||
fetchPublicCafeReviews,
|
||||
type WorkingHours,
|
||||
} from "@/lib/api/public-discover";
|
||||
import { resolveMediaUrl } from "@/lib/api/client";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CoffeeAdvisorPanel } from "@/components/discover/coffee-advisor-panel";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Props = { slug: string };
|
||||
|
||||
const DAY_KEYS: (keyof WorkingHours)[] = ["sat", "sun", "mon", "tue", "wed", "thu", "fri"];
|
||||
|
||||
export function PublicCafeDetailScreen({ slug }: Props) {
|
||||
const t = useTranslations("discoverPublic");
|
||||
const tProfile = useTranslations("discoverProfile");
|
||||
const locale = useLocale();
|
||||
const [galleryIndex, setGalleryIndex] = useState(0);
|
||||
|
||||
const { data: cafe, isLoading, error } = useQuery({
|
||||
queryKey: ["public-cafe", slug],
|
||||
queryFn: () => fetchPublicCafe(slug),
|
||||
});
|
||||
|
||||
const { data: reviews = [] } = useQuery({
|
||||
queryKey: ["public-cafe-reviews", slug],
|
||||
queryFn: () => fetchPublicCafeReviews(slug),
|
||||
enabled: !!slug,
|
||||
});
|
||||
|
||||
const label = (key: string) => {
|
||||
const groups = ["themes", "vibes", "occasions", "spaceFeatures", "noiseLevels", "priceTiers"] as const;
|
||||
for (const g of groups) {
|
||||
try { return tProfile(`${g}.${key}` as "themes.modern"); } catch { /* next */ }
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const mapSrc =
|
||||
cafe?.address || cafe?.city
|
||||
? `https://map.neshan.org/search?term=${encodeURIComponent(
|
||||
[cafe.address, cafe.city].filter(Boolean).join("، ")
|
||||
)}`
|
||||
: null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center bg-[#f5f5f4]">
|
||||
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !cafe) {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-4 bg-[#f5f5f4] p-6">
|
||||
<p className="text-sm text-muted-foreground">{t("notFound")}</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${locale}/discover`}>{t("backToList")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Build image list: gallery first, then cover/logo fallback
|
||||
const allImages = cafe.galleryUrls?.length
|
||||
? cafe.galleryUrls
|
||||
: [cafe.coverImageUrl ?? cafe.logoUrl].filter(Boolean) as string[];
|
||||
const currentImage = resolveMediaUrl(allImages[galleryIndex] ?? null);
|
||||
|
||||
const profile = cafe.discoverProfile;
|
||||
const allTags = [
|
||||
...profile.occasions,
|
||||
...profile.vibes,
|
||||
...profile.spaceFeatures,
|
||||
...profile.themes,
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-svh bg-[#f5f5f4]">
|
||||
<header className="border-b bg-white px-4 py-4">
|
||||
<Link href={`/${locale}/discover`} className="text-sm text-[#0F6E56] hover:underline">
|
||||
← {t("backToList")}
|
||||
</Link>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<h1 className="text-lg font-medium">{cafe.name}</h1>
|
||||
{cafe.isOpenNow && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
{t("openNowLabel")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{cafe.city && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{cafe.city}{cafe.address ? ` — ${cafe.address}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-3xl space-y-4 p-4">
|
||||
|
||||
{/* Gallery carousel */}
|
||||
{allImages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{currentImage && (
|
||||
<div
|
||||
className="h-52 w-full rounded-xl bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${currentImage})` }}
|
||||
/>
|
||||
)}
|
||||
{allImages.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{allImages.map((img, i) => {
|
||||
const url = resolveMediaUrl(img);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setGalleryIndex(i)}
|
||||
className={cn(
|
||||
"h-14 w-20 shrink-0 rounded-lg bg-cover bg-center transition-all cursor-pointer",
|
||||
i === galleryIndex
|
||||
? "ring-2 ring-[#0F6E56]"
|
||||
: "opacity-70 hover:opacity-100"
|
||||
)}
|
||||
style={{ backgroundImage: url ? `url(${url})` : undefined }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info card */}
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{cafe.averageRating > 0 && (
|
||||
<p className="text-sm font-medium text-[#0F6E56]">
|
||||
★ {formatNumber(cafe.averageRating, locale)} —{" "}
|
||||
{t("reviewCount", { count: cafe.reviewCount })}
|
||||
</p>
|
||||
)}
|
||||
{cafe.description && (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{cafe.description}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{allTags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px]">
|
||||
{label(tag)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Working hours */}
|
||||
{cafe.workingHours && <WorkingHoursCard hours={cafe.workingHours} t={t} locale={locale} />}
|
||||
|
||||
{/* Social links */}
|
||||
{(cafe.instagramHandle || cafe.websiteUrl) && (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardContent className="flex flex-wrap gap-3 p-4">
|
||||
{cafe.instagramHandle && (
|
||||
<a
|
||||
href={`https://instagram.com/${cafe.instagramHandle}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border/80 bg-white px-3 py-2 text-sm transition hover:border-pink-400 cursor-pointer"
|
||||
>
|
||||
<svg className="h-4 w-4 text-pink-500" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 1.366.062 2.633.336 3.608 1.31.975.975 1.249 2.242 1.311 3.608.058 1.265.069 1.645.069 4.849 0 3.205-.011 3.584-.069 4.849-.062 1.366-.336 2.633-1.311 3.608-.975.975-2.242 1.249-3.608 1.311-1.266.058-1.644.069-4.85.069-3.204 0-3.584-.011-4.849-.069-1.366-.062-2.633-.336-3.608-1.311-.975-.975-1.249-2.242-1.311-3.608C2.175 15.584 2.163 15.205 2.163 12c0-3.204.012-3.584.07-4.849.062-1.366.336-2.633 1.311-3.608.975-.974 2.242-1.248 3.608-1.31C8.416 2.175 8.796 2.163 12 2.163zm0-2.163C8.741 0 8.333.014 7.053.072 5.197.157 3.355.673 1.965 2.063.573 3.453.157 5.197.072 7.053.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.085 1.856.5 3.598 1.893 4.99C3.355 23.327 5.197 23.843 7.053 23.928 8.333 23.986 8.741 24 12 24s3.667-.014 4.947-.072c1.856-.085 3.598-.501 4.99-1.893 1.393-1.392 1.808-3.134 1.893-4.99.058-1.28.072-1.689.072-4.948 0-3.259-.014-3.667-.072-4.947-.085-1.856-.5-3.598-1.893-4.99C20.645.673 18.803.157 16.947.072 15.667.014 15.259 0 12 0z"/>
|
||||
<path d="M12 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/>
|
||||
</svg>
|
||||
<span>@{cafe.instagramHandle}</span>
|
||||
</a>
|
||||
)}
|
||||
{cafe.websiteUrl && (
|
||||
<a
|
||||
href={cafe.websiteUrl.startsWith("http") ? cafe.websiteUrl : `https://${cafe.websiteUrl}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border/80 bg-white px-3 py-2 text-sm transition hover:border-blue-400 cursor-pointer"
|
||||
>
|
||||
<svg className="h-4 w-4 text-blue-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>
|
||||
</svg>
|
||||
<span>{t("websiteLabel")}</span>
|
||||
</a>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<CoffeeAdvisorPanel cafeSlug={slug} />
|
||||
|
||||
{/* Map */}
|
||||
{mapSrc && (
|
||||
<Card className="overflow-hidden rounded-xl border border-border/80">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("mapTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<iframe
|
||||
title={t("mapTitle")}
|
||||
src={mapSrc}
|
||||
className="h-64 w-full border-0"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
<div className="border-t p-3">
|
||||
<a
|
||||
href={mapSrc}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-[#0C447C] hover:underline"
|
||||
>
|
||||
{t("openInNeshan")}
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Reviews */}
|
||||
{reviews.length > 0 && (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("reviewsTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-4 pt-0">
|
||||
{reviews.map((r) => (
|
||||
<div key={r.id} className="space-y-2 border-b border-border/60 pb-3 last:border-0">
|
||||
<p className="text-sm font-medium">{r.authorName}</p>
|
||||
<p className="text-xs text-amber-600">{"★".repeat(r.rating)}</p>
|
||||
{r.comment && <p className="text-sm text-muted-foreground">{r.comment}</p>}
|
||||
{r.ownerReply && (
|
||||
<p className="rounded-lg bg-[#E1F5EE] px-3 py-2 text-sm text-[#0F6E56]">
|
||||
{t("ownerReply")}: {r.ownerReply}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Button asChild className="w-full bg-[#0F6E56]">
|
||||
<Link href={`/${locale}/discover`}>{t("exploreMore")}</Link>
|
||||
</Button>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Working hours sub-component ───────────────────────────────────────────────
|
||||
|
||||
function WorkingHoursCard({
|
||||
hours,
|
||||
t,
|
||||
locale,
|
||||
}: {
|
||||
hours: WorkingHours;
|
||||
t: ReturnType<typeof useTranslations<"discoverPublic">>;
|
||||
locale: string;
|
||||
}) {
|
||||
// Detect today's day key in Iran time (UTC+3:30)
|
||||
const iranOffset = 210; // minutes
|
||||
const iranNow = new Date(Date.now() + iranOffset * 60_000);
|
||||
const dayIndex = iranNow.getUTCDay(); // 0=Sun ... 6=Sat
|
||||
const dayKeyMap: Record<number, keyof WorkingHours> = {
|
||||
6: "sat", 0: "sun", 1: "mon", 2: "tue", 3: "wed", 4: "thu", 5: "fri",
|
||||
};
|
||||
const todayKey = dayKeyMap[dayIndex];
|
||||
|
||||
const DAY_KEYS: (keyof WorkingHours)[] = ["sat", "sun", "mon", "tue", "wed", "thu", "fri"];
|
||||
|
||||
return (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("workingHoursTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 pb-2">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{DAY_KEYS.map((day) => {
|
||||
const schedule = hours[day];
|
||||
const isToday = day === todayKey;
|
||||
return (
|
||||
<tr
|
||||
key={day}
|
||||
className={cn(
|
||||
"border-b border-border/40 last:border-0",
|
||||
isToday && "bg-[#E1F5EE]/60"
|
||||
)}
|
||||
>
|
||||
<td className={cn(
|
||||
"px-4 py-2 font-medium",
|
||||
isToday ? "text-[#0F6E56]" : "text-foreground"
|
||||
)}>
|
||||
{t(`days.${day}`)}
|
||||
{isToday && (
|
||||
<span className="ms-1.5 text-[10px] font-normal text-[#0F6E56]">
|
||||
(امروز)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-end text-muted-foreground">
|
||||
{!schedule || !schedule.isOpen ? (
|
||||
<span className="text-red-500">{t("closedLabel")}</span>
|
||||
) : schedule.open && schedule.close ? (
|
||||
<span dir="ltr">{schedule.open} – {schedule.close}</span>
|
||||
) : (
|
||||
<span className="text-emerald-600">{t("openNowLabel")}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchDiscoverTaxonomy,
|
||||
fetchNlpHints,
|
||||
fetchPublicDiscover,
|
||||
type DiscoverSearchParams,
|
||||
type NlpHints,
|
||||
type PublicCafeDiscover,
|
||||
} from "@/lib/api/public-discover";
|
||||
import { resolveMediaUrl } from "@/lib/api/client";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
const CITIES = [
|
||||
{ id: "tehran", query: "تهران" },
|
||||
{ id: "karaj", query: "کرج" },
|
||||
] as const;
|
||||
|
||||
type FilterKey = "themes" | "vibes" | "occasions" | "spaceFeatures";
|
||||
|
||||
function toggle(list: string[], value: string): string[] {
|
||||
return list.includes(value) ? list.filter((x) => x !== value) : [...list, value];
|
||||
}
|
||||
|
||||
// Count non-empty detected filter fields
|
||||
function nlpHintCount(h: NlpHints | null): number {
|
||||
if (!h) return 0;
|
||||
return (
|
||||
h.themes.length +
|
||||
h.vibes.length +
|
||||
h.occasions.length +
|
||||
h.spaceFeatures.length +
|
||||
(h.noiseLevel ? 1 : 0) +
|
||||
(h.priceTier ? 1 : 0) +
|
||||
(h.size ? 1 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function PublicDiscoverScreen() {
|
||||
const t = useTranslations("discoverPublic");
|
||||
const tProfile = useTranslations("discoverProfile");
|
||||
const locale = useLocale();
|
||||
|
||||
const [city, setCity] = useState<string>("tehran");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [sort, setSort] = useState("rating");
|
||||
const [themes, setThemes] = useState<string[]>([]);
|
||||
const [vibes, setVibes] = useState<string[]>([]);
|
||||
const [occasions, setOccasions] = useState<string[]>([]);
|
||||
const [spaceFeatures, setSpaceFeatures] = useState<string[]>([]);
|
||||
const [noise, setNoise] = useState<string | null>(null);
|
||||
const [priceTier, setPriceTier] = useState<string | null>(null);
|
||||
const [size, setSize] = useState<string | null>(null);
|
||||
const [openNow, setOpenNow] = useState(false);
|
||||
|
||||
// Debounce the search input for NLP hints
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => setDebouncedSearch(search), 600);
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
}, [search]);
|
||||
|
||||
// Fetch NLP hints whenever the debounced search changes
|
||||
const { data: nlpHints } = useQuery({
|
||||
queryKey: ["nlp-hints", debouncedSearch],
|
||||
queryFn: () => fetchNlpHints(debouncedSearch),
|
||||
enabled: debouncedSearch.trim().length > 2,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const cityQuery = CITIES.find((c) => c.id === city)?.query ?? "تهران";
|
||||
|
||||
const params: DiscoverSearchParams = useMemo(
|
||||
() => ({
|
||||
city: cityQuery,
|
||||
q: search.trim() || undefined,
|
||||
sort,
|
||||
themes: themes.length ? themes : undefined,
|
||||
vibes: vibes.length ? vibes : undefined,
|
||||
occasions: occasions.length ? occasions : undefined,
|
||||
spaceFeatures: spaceFeatures.length ? spaceFeatures : undefined,
|
||||
noise: noise ?? undefined,
|
||||
priceTier: priceTier ?? undefined,
|
||||
size: size ?? undefined,
|
||||
openNow,
|
||||
requireProfile: true,
|
||||
}),
|
||||
[cityQuery, search, sort, themes, vibes, occasions, spaceFeatures, noise, priceTier, size, openNow]
|
||||
);
|
||||
|
||||
const { data: taxonomy } = useQuery({
|
||||
queryKey: ["discover-taxonomy"],
|
||||
queryFn: fetchDiscoverTaxonomy,
|
||||
});
|
||||
|
||||
const { data: cafes = [], isLoading, isFetching } = useQuery({
|
||||
queryKey: ["public-discover", params],
|
||||
queryFn: () => fetchPublicDiscover(params),
|
||||
});
|
||||
|
||||
const label = useCallback(
|
||||
(key: string) => {
|
||||
const groups = ["themes", "vibes", "occasions", "spaceFeatures", "noiseLevels", "priceTiers", "sizes"] as const;
|
||||
for (const g of groups) {
|
||||
try { return tProfile(`${g}.${key}` as "themes.modern"); } catch { /* next */ }
|
||||
}
|
||||
return key;
|
||||
},
|
||||
[tProfile]
|
||||
);
|
||||
|
||||
const clearAll = () => {
|
||||
setThemes([]); setVibes([]); setOccasions([]); setSpaceFeatures([]);
|
||||
setNoise(null); setPriceTier(null); setSize(null); setSearch("");
|
||||
setOpenNow(false);
|
||||
};
|
||||
|
||||
const filterSection = (
|
||||
key: FilterKey,
|
||||
options: string[] | undefined,
|
||||
active: string[],
|
||||
setActive: (v: string[]) => void
|
||||
) => {
|
||||
if (!options?.length) return null;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t(`filters.${key}`)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.slice(0, 14).map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => setActive(toggle(active, opt))}
|
||||
className={cn(
|
||||
"rounded-lg border px-2.5 py-1 text-xs transition-colors active:scale-[0.98] cursor-pointer",
|
||||
active.includes(opt)
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
||||
: "border-border/80 hover:border-[#0F6E56]/40"
|
||||
)}
|
||||
>
|
||||
{label(opt)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const detectedCount = nlpHintCount(nlpHints ?? null);
|
||||
|
||||
return (
|
||||
<div className="min-h-svh bg-[#f5f5f4]">
|
||||
<header className="border-b bg-white px-4 py-5">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("brand")}
|
||||
</p>
|
||||
<h1 className="text-lg font-medium text-foreground">{t("title")}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-3xl space-y-4 p-4">
|
||||
{/* City selector */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CITIES.map((c) => (
|
||||
<Button
|
||||
key={c.id}
|
||||
size="sm"
|
||||
variant={city === c.id ? "default" : "outline"}
|
||||
className={city === c.id ? "bg-[#0F6E56]" : ""}
|
||||
onClick={() => setCity(c.id)}
|
||||
>
|
||||
{t(`cities.${c.id}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* AI smart search */}
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="text-end pe-10"
|
||||
/>
|
||||
{/* AI spark indicator */}
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-sm transition-opacity",
|
||||
debouncedSearch.trim().length > 2 ? "opacity-100" : "opacity-30"
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
✦
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t("searchHint")}</p>
|
||||
|
||||
{/* Detected filters banner */}
|
||||
{detectedCount > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-[#0F6E56]/30 bg-[#E1F5EE]/60 px-3 py-2">
|
||||
<span className="text-[11px] font-medium text-[#0F6E56]">
|
||||
{t("aiDetectedLabel")}
|
||||
</span>
|
||||
{nlpHints?.themes.map((k) => (
|
||||
<span key={k} className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
|
||||
{label(k)}
|
||||
</span>
|
||||
))}
|
||||
{nlpHints?.vibes.map((k) => (
|
||||
<span key={k} className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
|
||||
{label(k)}
|
||||
</span>
|
||||
))}
|
||||
{nlpHints?.occasions.map((k) => (
|
||||
<span key={k} className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
|
||||
{label(k)}
|
||||
</span>
|
||||
))}
|
||||
{nlpHints?.spaceFeatures.map((k) => (
|
||||
<span key={k} className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
|
||||
{label(k)}
|
||||
</span>
|
||||
))}
|
||||
{nlpHints?.noiseLevel && (
|
||||
<span className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
|
||||
{label(nlpHints.noiseLevel)}
|
||||
</span>
|
||||
)}
|
||||
{nlpHints?.priceTier && (
|
||||
<span className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
|
||||
{label(nlpHints.priceTier)}
|
||||
</span>
|
||||
)}
|
||||
{nlpHints?.size && (
|
||||
<span className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
|
||||
{label(nlpHints.size)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
className="ms-auto text-[11px] text-[#0F6E56] underline cursor-pointer"
|
||||
>
|
||||
{t("aiDetectedClear")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter panel */}
|
||||
<Card className="rounded-xl border border-border/80 shadow-sm">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
{filterSection("occasions", taxonomy?.occasions, occasions, setOccasions)}
|
||||
{filterSection("vibes", taxonomy?.vibes, vibes, setVibes)}
|
||||
{filterSection("spaceFeatures", taxonomy?.spaceFeatures, spaceFeatures, setSpaceFeatures)}
|
||||
{filterSection("themes", taxonomy?.themes, themes, setThemes)}
|
||||
|
||||
{/* Size filter — was missing before */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("filters.size")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{taxonomy?.sizes?.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSize(size === s ? null : s)}
|
||||
className={cn(
|
||||
"rounded-lg border px-2.5 py-1 text-xs transition-colors cursor-pointer",
|
||||
size === s
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
||||
: "border-border/80 hover:border-[#0F6E56]/40"
|
||||
)}
|
||||
>
|
||||
{label(s)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Noise level */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("filters.noise")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{taxonomy?.noiseLevels?.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setNoise(noise === n ? null : n)}
|
||||
className={cn(
|
||||
"rounded-lg border px-2.5 py-1 text-xs transition-colors cursor-pointer",
|
||||
noise === n
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
||||
: "border-border/80 hover:border-[#0F6E56]/40"
|
||||
)}
|
||||
>
|
||||
{label(n)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price tier */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("filters.priceTier")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{taxonomy?.priceTiers?.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setPriceTier(priceTier === p ? null : p)}
|
||||
className={cn(
|
||||
"rounded-lg border px-2.5 py-1 text-xs transition-colors cursor-pointer",
|
||||
priceTier === p
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
||||
: "border-border/80 hover:border-[#0F6E56]/40"
|
||||
)}
|
||||
>
|
||||
{label(p)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Open now toggle + actions */}
|
||||
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenNow((v) => !v)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer",
|
||||
openNow
|
||||
? "border-emerald-500 bg-emerald-50 text-emerald-700"
|
||||
: "border-border/80 hover:border-emerald-400/60"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 rounded-full",
|
||||
openNow ? "bg-emerald-500" : "bg-muted-foreground/40"
|
||||
)}
|
||||
/>
|
||||
{t("openNow")}
|
||||
</button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={clearAll}
|
||||
className="ms-auto"
|
||||
>
|
||||
{t("clearFilters")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Results header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isLoading || isFetching
|
||||
? t("loading")
|
||||
: t("resultCount", { count: cafes.length })}
|
||||
</p>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value)}
|
||||
className="rounded-lg border border-border/80 bg-white px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="rating">{t("sort.rating")}</option>
|
||||
<option value="reviews">{t("sort.reviews")}</option>
|
||||
<option value="name">{t("sort.name")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{cafes.length === 0 && !isLoading ? (
|
||||
<Card className="rounded-xl border border-dashed p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{cafes.map((cafe) => (
|
||||
<CafeDiscoverCard key={cafe.id} cafe={cafe} locale={locale} label={label} t={t} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Card component ────────────────────────────────────────────────────────────
|
||||
|
||||
function CafeDiscoverCard({
|
||||
cafe,
|
||||
locale,
|
||||
label,
|
||||
t,
|
||||
}: {
|
||||
cafe: PublicCafeDiscover;
|
||||
locale: string;
|
||||
label: (key: string) => string;
|
||||
t: ReturnType<typeof useTranslations<"discoverPublic">>;
|
||||
}) {
|
||||
// Pick the best cover: gallery first, then coverImage, then logo
|
||||
const firstGallery = cafe.galleryUrls?.[0];
|
||||
const cover = resolveMediaUrl(firstGallery ?? cafe.coverImageUrl ?? cafe.logoUrl);
|
||||
|
||||
const tags = [
|
||||
...cafe.discoverProfile.occasions.slice(0, 2),
|
||||
...cafe.discoverProfile.vibes.slice(0, 1),
|
||||
];
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={`/${locale}/discover/${cafe.slug}`}
|
||||
className="block rounded-xl border border-border/80 bg-white transition-all hover:border-[#0F6E56] hover:shadow-sm active:scale-[0.99] cursor-pointer"
|
||||
>
|
||||
{/* Cover image */}
|
||||
{cover ? (
|
||||
<div
|
||||
className="h-32 rounded-t-xl bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${cover})` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center rounded-t-xl bg-muted">
|
||||
<svg className="h-10 w-10 text-muted-foreground/30" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2 19V7a2 2 0 012-2h1V4a1 1 0 012 0v1h10V4a1 1 0 112 0v1h1a2 2 0 012 2v12a2 2 0 01-2 2H4a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-medium text-foreground">{cafe.name}</h2>
|
||||
{/* Gallery count badge */}
|
||||
{cafe.galleryUrls?.length > 1 && (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
+{cafe.galleryUrls.length - 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* Open/closed badge */}
|
||||
{cafe.isOpenNow && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
{t("openNowLabel")}
|
||||
</span>
|
||||
)}
|
||||
{cafe.averageRating > 0 && (
|
||||
<span className="text-sm font-medium text-[#0F6E56]">
|
||||
★ {formatNumber(cafe.averageRating, locale)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cafe.city && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{cafe.city}
|
||||
{cafe.address ? ` — ${cafe.address}` : ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px]">
|
||||
{label(tag)}
|
||||
</Badge>
|
||||
))}
|
||||
{cafe.discoverProfile.priceTier && (
|
||||
<Badge className="bg-amber-100 text-[10px] text-amber-900">
|
||||
{label(cafe.discoverProfile.priceTier)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gallery strip */}
|
||||
{cafe.galleryUrls?.length > 1 && (
|
||||
<div className="flex gap-1 overflow-x-auto pb-0.5">
|
||||
{cafe.galleryUrls.slice(1, 4).map((url, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-10 w-16 shrink-0 rounded bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${resolveMediaUrl(url)})` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[#0F6E56]">{t("viewCafe")} ←</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { apiDelete, apiGet, apiGetPaged, apiPost } from "@/lib/api/client";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||
import { isoTodayTehran } from "@/lib/reports/analytics";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
type Branch = { id: string; name: string };
|
||||
|
||||
type ShiftDto = {
|
||||
id: string;
|
||||
branchId: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ExpenseCategory =
|
||||
| "Supplies"
|
||||
| "Utilities"
|
||||
| "Salary"
|
||||
| "Rent"
|
||||
| "Maintenance"
|
||||
| "Other";
|
||||
|
||||
export type ExpenseRow = {
|
||||
id: string;
|
||||
cafeId: string;
|
||||
branchId: string;
|
||||
shiftId?: string | null;
|
||||
category: ExpenseCategory;
|
||||
amount: number;
|
||||
note?: string | null;
|
||||
receiptImageUrl?: string | null;
|
||||
createdByUserId: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const CATEGORIES: ExpenseCategory[] = [
|
||||
"Supplies",
|
||||
"Utilities",
|
||||
"Salary",
|
||||
"Rent",
|
||||
"Maintenance",
|
||||
"Other",
|
||||
];
|
||||
|
||||
const MANAGER_ROLES = new Set(["Owner", "Manager"]);
|
||||
|
||||
export function ExpensesScreen() {
|
||||
const t = useTranslations("expenses");
|
||||
const tCommon = useTranslations("common");
|
||||
const locale = useLocale();
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const role = useAuthStore((s) => s.user?.role ?? "");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const today = isoTodayTehran();
|
||||
const [branchId, setBranchId] = useState<string>("");
|
||||
const [from, setFrom] = useState(today);
|
||||
const [to, setTo] = useState(today);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [category, setCategory] = useState<ExpenseCategory>("Supplies");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [linkShift, setLinkShift] = useState(true);
|
||||
|
||||
const { data: branches = [] } = useQuery({
|
||||
queryKey: ["branches", cafeId],
|
||||
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!branchId && branches.length > 0) setBranchId(branches[0]!.id);
|
||||
}, [branchId, branches]);
|
||||
|
||||
const { data: currentShift } = useQuery({
|
||||
queryKey: ["shift-current", cafeId, branchId],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<ShiftDto>(
|
||||
`/api/cafes/${cafeId}/branches/${branchId}/shifts/current`
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: !!cafeId && !!branchId,
|
||||
});
|
||||
|
||||
const listKey = ["expenses", cafeId, branchId, from, to] as const;
|
||||
|
||||
const { data: listResponse, isLoading } = useQuery({
|
||||
queryKey: listKey,
|
||||
queryFn: () =>
|
||||
apiGetPaged<ExpenseRow>(
|
||||
`/api/cafes/${cafeId}/expenses?branchId=${encodeURIComponent(branchId)}&from=${from}&to=${to}&page=1&pageSize=50`
|
||||
),
|
||||
enabled: !!cafeId && !!branchId && !!from && !!to,
|
||||
});
|
||||
|
||||
const rows = useMemo(() => listResponse?.items ?? [], [listResponse?.items]);
|
||||
const totalAmount = useMemo(() => rows.reduce((s, r) => s + r.amount, 0), [rows]);
|
||||
|
||||
const createExpense = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<ExpenseRow>(`/api/cafes/${cafeId}/expenses`, {
|
||||
branchId,
|
||||
shiftId: linkShift && currentShift ? currentShift.id : null,
|
||||
category,
|
||||
amount: Number(amount),
|
||||
note: note.trim() || null,
|
||||
receiptImageUrl: null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: listKey });
|
||||
setShowModal(false);
|
||||
setAmount("");
|
||||
setNote("");
|
||||
setCategory("Supplies");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteExpense = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/expenses/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: listKey }),
|
||||
});
|
||||
|
||||
const canDelete = MANAGER_ROLES.has(role);
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 bg-[#f5f5f4] min-h-full -m-4 p-4 md:-m-6 md:p-6">
|
||||
<PageHeader
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
action={
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0d5e49]"
|
||||
onClick={() => setShowModal(true)}
|
||||
disabled={!branchId}
|
||||
>
|
||||
<Plus className="ms-2 h-4 w-4" />
|
||||
{t("addExpense")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="rounded-xl border border-border/80 bg-card">
|
||||
<CardContent className="flex flex-wrap items-end gap-4 pt-6">
|
||||
<LabeledField label={t("branch")} htmlFor="exp-branch">
|
||||
<select
|
||||
id="exp-branch"
|
||||
className="h-9 min-w-[10rem] rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={branchId}
|
||||
onChange={(e) => setBranchId(e.target.value)}
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("fromDate")} htmlFor="exp-from">
|
||||
<Input
|
||||
id="exp-from"
|
||||
type="date"
|
||||
dir="ltr"
|
||||
className="w-40 text-end"
|
||||
value={from}
|
||||
max={to}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("toDate")} htmlFor="exp-to">
|
||||
<Input
|
||||
id="exp-to"
|
||||
type="date"
|
||||
dir="ltr"
|
||||
className="w-40 text-end"
|
||||
value={to}
|
||||
min={from}
|
||||
max={today}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
/>
|
||||
</LabeledField>
|
||||
<div className="ms-auto text-end">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("periodTotal")}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-[#BA7517]">
|
||||
{formatCurrency(totalAmount, numberLocale)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-xl border border-border/80 bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("listTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="w-full min-w-[28rem] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
<th className="py-2 text-start">{t("colDate")}</th>
|
||||
<th className="py-2 text-start">{t("colCategory")}</th>
|
||||
<th className="py-2 text-start">{t("colNote")}</th>
|
||||
<th className="py-2 text-end">{t("colAmount")}</th>
|
||||
{canDelete ? <th className="py-2 w-10" /> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={canDelete ? 5 : 4} className="py-4 text-muted-foreground">
|
||||
{t("loading")}
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={canDelete ? 5 : 4} className="py-4 text-muted-foreground">
|
||||
{t("empty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-border/50">
|
||||
<td className="py-2.5 tabular-nums text-muted-foreground" dir="ltr">
|
||||
{new Date(row.createdAt).toLocaleString(
|
||||
locale === "en" ? "en-GB" : "fa-IR",
|
||||
{ dateStyle: "short", timeStyle: "short" }
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5">{t(`categories.${row.category}`)}</td>
|
||||
<td className="py-2.5 max-w-[12rem] truncate text-muted-foreground">
|
||||
{row.note ?? "—"}
|
||||
</td>
|
||||
<td className="py-2.5 text-end font-medium text-[#BA7517] tabular-nums">
|
||||
{formatCurrency(row.amount, numberLocale)}
|
||||
</td>
|
||||
{canDelete ? (
|
||||
<td className="py-2.5 text-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-[#A32D2D]"
|
||||
onClick={() => deleteExpense.mutate(row.id)}
|
||||
disabled={deleteExpense.isPending}
|
||||
aria-label={tCommon("delete")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{listResponse?.meta ? (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{t("rowCount", {
|
||||
count: formatNumber(listResponse.meta.total, numberLocale),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{showModal ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="expense-modal-title"
|
||||
>
|
||||
<Card className="w-full max-w-md rounded-xl border border-border/80 bg-card shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle id="expense-modal-title" className="text-base">
|
||||
{t("addExpense")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<LabeledField label={t("category")} htmlFor="exp-cat">
|
||||
<select
|
||||
id="exp-cat"
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as ExpenseCategory)}
|
||||
>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`categories.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("amount")} htmlFor="exp-amount">
|
||||
<Input
|
||||
id="exp-amount"
|
||||
type="number"
|
||||
min={1}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("note")} htmlFor="exp-note">
|
||||
<Input
|
||||
id="exp-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("notePlaceholder")}
|
||||
/>
|
||||
</LabeledField>
|
||||
{currentShift ? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={linkShift}
|
||||
onChange={(e) => setLinkShift(e.target.checked)}
|
||||
/>
|
||||
{t("linkOpenShift")}
|
||||
</label>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{t("noOpenShift")}</p>
|
||||
)}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0d5e49]"
|
||||
disabled={!amount || createExpense.isPending}
|
||||
onClick={() => createExpense.mutate()}
|
||||
>
|
||||
{tCommon("confirm")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowModal(false)}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface Employee {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
baseSalary: number;
|
||||
}
|
||||
|
||||
interface Attendance {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
date: string;
|
||||
clockIn?: string;
|
||||
clockOut?: string;
|
||||
}
|
||||
|
||||
interface LeaveRequest {
|
||||
id: string;
|
||||
employeeName: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
reason?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface Salary {
|
||||
id: string;
|
||||
employeeName: string;
|
||||
monthYear: string;
|
||||
netSalary: number;
|
||||
isPaid: boolean;
|
||||
}
|
||||
|
||||
type Tab = "attendance" | "leave" | "payroll";
|
||||
|
||||
export function HrScreen() {
|
||||
const t = useTranslations("hr");
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const userId = useAuthStore((s) => s.user?.userId);
|
||||
const queryClient = useQueryClient();
|
||||
const [tab, setTab] = useState<Tab>("attendance");
|
||||
const [monthYear, setMonthYear] = useState(
|
||||
new Date().toISOString().slice(0, 7)
|
||||
);
|
||||
|
||||
const { data: employees = [] } = useQuery({
|
||||
queryKey: ["employees", cafeId],
|
||||
queryFn: () => apiGet<Employee[]>(`/api/cafes/${cafeId}/employees`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const { data: attendance = [] } = useQuery({
|
||||
queryKey: ["attendance", cafeId],
|
||||
queryFn: () => apiGet<Attendance[]>(`/api/cafes/${cafeId}/attendance`),
|
||||
enabled: !!cafeId && tab === "attendance",
|
||||
});
|
||||
|
||||
const { data: leaves = [] } = useQuery({
|
||||
queryKey: ["leave-requests", cafeId],
|
||||
queryFn: () =>
|
||||
apiGet<LeaveRequest[]>(`/api/cafes/${cafeId}/leave-requests?status=Pending`),
|
||||
enabled: !!cafeId && tab === "leave",
|
||||
});
|
||||
|
||||
const { data: salaries = [] } = useQuery({
|
||||
queryKey: ["salaries", cafeId, monthYear],
|
||||
queryFn: () =>
|
||||
apiGet<Salary[]>(`/api/cafes/${cafeId}/salaries?monthYear=${monthYear}`),
|
||||
enabled: !!cafeId && tab === "payroll",
|
||||
});
|
||||
|
||||
const clockIn = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<Attendance>(`/api/cafes/${cafeId}/employees/${userId}/attendance/clock-in`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["attendance", cafeId] }),
|
||||
});
|
||||
|
||||
const clockOut = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<Attendance>(`/api/cafes/${cafeId}/employees/${userId}/attendance/clock-out`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["attendance", cafeId] }),
|
||||
});
|
||||
|
||||
const approveLeave = useMutation({
|
||||
mutationFn: (leaveId: string) =>
|
||||
apiPatch(`/api/cafes/${cafeId}/leave-requests/${leaveId}/status`, {
|
||||
status: "Approved",
|
||||
}),
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["leave-requests", cafeId] }),
|
||||
});
|
||||
|
||||
const markPaid = useMutation({
|
||||
mutationFn: (salaryId: string) =>
|
||||
apiPatch(`/api/cafes/${cafeId}/salaries/${salaryId}/paid`),
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["salaries", cafeId, monthYear] }),
|
||||
});
|
||||
|
||||
if (!cafeId || !userId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">{t("title")}</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["attendance", "leave", "payroll"] as Tab[]).map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
size="sm"
|
||||
variant={tab === key ? "default" : "outline"}
|
||||
onClick={() => setTab(key)}
|
||||
>
|
||||
{t(`tabs.${key}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "attendance" && (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("myAttendance")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => clockIn.mutate()} disabled={clockIn.isPending}>
|
||||
{t("clockIn")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => clockOut.mutate()} disabled={clockOut.isPending}>
|
||||
{t("clockOut")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{attendance.map((a) => (
|
||||
<Card key={a.id}>
|
||||
<CardContent className="space-y-1 pt-4 text-sm">
|
||||
<p className="font-medium">{a.employeeName}</p>
|
||||
<p className="text-muted-foreground">{a.date}</p>
|
||||
<p dir="ltr" className="text-end font-mono text-xs">
|
||||
{a.clockIn ? new Date(a.clockIn).toLocaleTimeString("fa-IR") : "—"}
|
||||
{" → "}
|
||||
{a.clockOut ? new Date(a.clockOut).toLocaleTimeString("fa-IR") : "—"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "leave" && (
|
||||
<div className="space-y-3">
|
||||
{leaves.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t("noLeave")}</p>
|
||||
) : (
|
||||
leaves.map((l) => (
|
||||
<Card key={l.id}>
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-3 pt-4">
|
||||
<div>
|
||||
<p className="font-medium">{l.employeeName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{l.startDate} — {l.endDate}
|
||||
</p>
|
||||
{l.reason && <p className="text-sm">{l.reason}</p>}
|
||||
</div>
|
||||
<Button size="sm" onClick={() => approveLeave.mutate(l.id)}>
|
||||
{t("approve")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "payroll" && (
|
||||
<div className="space-y-4">
|
||||
<LabeledField label={t("monthYear")} htmlFor="hr-month" hint="YYYY-MM" className="max-w-xs">
|
||||
<Input
|
||||
id="hr-month"
|
||||
value={monthYear}
|
||||
onChange={(e) => setMonthYear(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("employeeCount")}: {formatNumber(employees.length)}
|
||||
</p>
|
||||
{salaries.map((s) => (
|
||||
<Card key={s.id}>
|
||||
<CardContent className="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<p className="font-medium">{s.employeeName}</p>
|
||||
<p>{formatCurrency(s.netSalary)}</p>
|
||||
</div>
|
||||
{s.isPaid ? (
|
||||
<Badge>{t("paid")}</Badge>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => markPaid.mutate(s.id)}>
|
||||
{t("markPaid")}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { apiGet, apiPatch, apiPost, apiPut } from "@/lib/api/client";
|
||||
import { InventoryUnitField } from "@/components/inventory/inventory-unit-field";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useBranchStore } from "@/lib/stores/branch.store";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { notify } from "@/lib/notify";
|
||||
|
||||
type Ingredient = {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
quantityOnHand: number;
|
||||
reorderLevel: number;
|
||||
unitCost: number;
|
||||
parLevel: number;
|
||||
lowStockWarningPercent: number;
|
||||
warningThreshold: number;
|
||||
stockValueToman: number;
|
||||
isLowStock: boolean;
|
||||
};
|
||||
|
||||
type MenuItem = { id: string; name: string };
|
||||
|
||||
type RecipeLine = {
|
||||
id: string;
|
||||
ingredientId: string;
|
||||
ingredientName: string;
|
||||
unit: string;
|
||||
quantityPerUnit: number;
|
||||
};
|
||||
|
||||
type MenuItemRecipe = {
|
||||
menuItemId: string;
|
||||
menuItemName: string;
|
||||
lines: RecipeLine[];
|
||||
materialCostPerUnitToman: number;
|
||||
};
|
||||
|
||||
type PurchasesSummary = {
|
||||
totalPaidToman: number;
|
||||
purchaseCount: number;
|
||||
recent: {
|
||||
id: string;
|
||||
ingredientName: string;
|
||||
delta: number;
|
||||
unit: string;
|
||||
totalPaidToman: number;
|
||||
createdAt: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function InventoryScreen() {
|
||||
const t = useTranslations("inventory");
|
||||
const tCommon = useTranslations("common");
|
||||
const locale = useLocale();
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const branchId = useBranchStore((s) => s.branchId);
|
||||
const setBranchId = useBranchStore((s) => s.setBranchId);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: branches = [] } = useQuery({
|
||||
queryKey: ["branches", cafeId],
|
||||
queryFn: () => apiGet<{ id: string }[]>(`/api/cafes/${cafeId}/branches`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!branchId && branches.length > 0) setBranchId(branches[0]!.id);
|
||||
}, [branchId, branches, setBranchId]);
|
||||
const [tab, setTab] = useState<"materials" | "recipes">("materials");
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [unit, setUnit] = useState("گرم");
|
||||
const [qty, setQty] = useState("500");
|
||||
const [reorder, setReorder] = useState("50");
|
||||
const [totalPaid, setTotalPaid] = useState("");
|
||||
const [parLevel, setParLevel] = useState("500");
|
||||
const [warningPct, setWarningPct] = useState("20");
|
||||
const [adjustQty, setAdjustQty] = useState<Record<string, string>>({});
|
||||
const [adjustPaid, setAdjustPaid] = useState<Record<string, string>>({});
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editUnit, setEditUnit] = useState("گرم");
|
||||
const [editReorder, setEditReorder] = useState("0");
|
||||
const [editUnitCost, setEditUnitCost] = useState("0");
|
||||
const [editParLevel, setEditParLevel] = useState("0");
|
||||
const [editWarningPct, setEditWarningPct] = useState("20");
|
||||
|
||||
const [selectedMenuItemId, setSelectedMenuItemId] = useState("");
|
||||
const [recipeDraft, setRecipeDraft] = useState<RecipeLine[]>([]);
|
||||
const [newRecipeIngredientId, setNewRecipeIngredientId] = useState("");
|
||||
const [newRecipeQty, setNewRecipeQty] = useState("10");
|
||||
|
||||
const { data: ingredients = [], isLoading } = useQuery({
|
||||
queryKey: ["inventory", cafeId],
|
||||
queryFn: () => apiGet<Ingredient[]>(`/api/cafes/${cafeId}/inventory/ingredients`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const monthRange = useMemo(() => {
|
||||
const now = new Date();
|
||||
const from = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const fmt = (d: Date) =>
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
return { from: fmt(from), to: fmt(now) };
|
||||
}, []);
|
||||
|
||||
const { data: purchasesSummary } = useQuery({
|
||||
queryKey: ["inventory", cafeId, "purchases", branchId, monthRange.from, monthRange.to],
|
||||
queryFn: () =>
|
||||
apiGet<PurchasesSummary>(
|
||||
`/api/cafes/${cafeId}/inventory/purchases?branchId=${encodeURIComponent(branchId!)}&from=${monthRange.from}&to=${monthRange.to}`
|
||||
),
|
||||
enabled: !!cafeId && !!branchId && tab === "materials",
|
||||
});
|
||||
|
||||
const { data: lowStock = [] } = useQuery({
|
||||
queryKey: ["inventory", cafeId, "low"],
|
||||
queryFn: () => apiGet<Ingredient[]>(`/api/cafes/${cafeId}/inventory/low-stock`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const { data: menuItems = [] } = useQuery({
|
||||
queryKey: ["menu", "items", cafeId],
|
||||
queryFn: () => apiGet<MenuItem[]>(`/api/cafes/${cafeId}/menu/items`),
|
||||
enabled: !!cafeId && tab === "recipes",
|
||||
});
|
||||
|
||||
const { data: recipe, isLoading: recipeLoading } = useQuery({
|
||||
queryKey: ["inventory", cafeId, "recipe", selectedMenuItemId],
|
||||
queryFn: () =>
|
||||
apiGet<MenuItemRecipe>(
|
||||
`/api/cafes/${cafeId}/inventory/menu-items/${selectedMenuItemId}/recipe`
|
||||
),
|
||||
enabled: !!cafeId && !!selectedMenuItemId,
|
||||
});
|
||||
|
||||
const impliedUnitCost = useMemo(() => {
|
||||
const q = parseFloat(qty) || 0;
|
||||
const paid = parseFloat(totalPaid) || 0;
|
||||
if (q > 0 && paid > 0) return paid / q;
|
||||
return 0;
|
||||
}, [qty, totalPaid]);
|
||||
|
||||
const createIngredient = useMutation({
|
||||
mutationFn: () => {
|
||||
const quantity = parseFloat(qty) || 0;
|
||||
const paid = parseFloat(totalPaid) || 0;
|
||||
return apiPost(`/api/cafes/${cafeId}/inventory/ingredients`, {
|
||||
name,
|
||||
unit: unit.trim() || t("defaultUnit"),
|
||||
quantityOnHand: quantity,
|
||||
reorderLevel: parseFloat(reorder) || 0,
|
||||
unitCost: impliedUnitCost,
|
||||
parLevel: parseFloat(parLevel) || quantity || 0,
|
||||
lowStockWarningPercent: parseFloat(warningPct) || 20,
|
||||
totalPaidToman: paid > 0 ? paid : null,
|
||||
branchId: paid > 0 ? branchId : null,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["inventory", cafeId] });
|
||||
notify.success(t("created"));
|
||||
},
|
||||
});
|
||||
|
||||
const updateIngredient = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiPatch(`/api/cafes/${cafeId}/inventory/ingredients/${id}`, {
|
||||
name: editName.trim(),
|
||||
unit: editUnit.trim() || t("defaultUnit"),
|
||||
reorderLevel: parseFloat(editReorder) || 0,
|
||||
unitCost: parseFloat(editUnitCost) || 0,
|
||||
parLevel: parseFloat(editParLevel) || 0,
|
||||
lowStockWarningPercent: parseFloat(editWarningPct) || 20,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["inventory", cafeId] });
|
||||
setEditingId(null);
|
||||
notify.success(t("updated"));
|
||||
},
|
||||
});
|
||||
|
||||
const adjustStock = useMutation({
|
||||
mutationFn: ({ id, delta, paid }: { id: string; delta: number; paid?: number }) =>
|
||||
apiPost(`/api/cafes/${cafeId}/inventory/ingredients/${id}/adjust`, {
|
||||
delta,
|
||||
note: delta > 0 ? t("purchaseNote") : t("adjustNote"),
|
||||
totalPaidToman: delta > 0 ? paid : null,
|
||||
branchId: delta > 0 ? branchId : null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["inventory", cafeId] });
|
||||
void qc.invalidateQueries({ queryKey: ["inventory", cafeId, "purchases"] });
|
||||
void qc.invalidateQueries({ queryKey: ["expenses"] });
|
||||
notify.success(t("adjusted"));
|
||||
},
|
||||
onError: () => notify.error(t("purchaseRequired")),
|
||||
});
|
||||
|
||||
const saveRecipe = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPut(`/api/cafes/${cafeId}/inventory/menu-items/${selectedMenuItemId}/recipe`, {
|
||||
lines: recipeDraft.map((l) => ({
|
||||
ingredientId: l.ingredientId,
|
||||
quantityPerUnit: l.quantityPerUnit,
|
||||
})),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["inventory", cafeId, "recipe", selectedMenuItemId] });
|
||||
notify.success(t("recipeSaved"));
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (recipe?.lines) setRecipeDraft(recipe.lines.map((l) => ({ ...l })));
|
||||
}, [recipe]);
|
||||
|
||||
const startEdit = (ing: Ingredient) => {
|
||||
setEditingId(ing.id);
|
||||
setEditName(ing.name);
|
||||
setEditUnit(ing.unit);
|
||||
setEditReorder(String(ing.reorderLevel));
|
||||
setEditUnitCost(String(ing.unitCost));
|
||||
setEditParLevel(String(ing.parLevel));
|
||||
setEditWarningPct(String(ing.lowStockWarningPercent));
|
||||
};
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
{lowStock.length > 0 ? (
|
||||
<Card className="rounded-xl border border-amber-300 bg-amber-50/80 p-4">
|
||||
<p className="text-sm font-medium text-amber-900">{t("lowStockAlert")}</p>
|
||||
<p className="mt-1 text-sm text-amber-800">
|
||||
{lowStock.map((i) => i.name).join("، ")}
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{tab === "materials" && branchId && purchasesSummary ? (
|
||||
<Card className="rounded-xl border border-[#0F6E56]/25 bg-[#E1F5EE]/40 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("purchasesThisMonth")}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-medium text-[#0F6E56]">
|
||||
{formatNumber(purchasesSummary.totalPaidToman, numberLocale)} ت
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("purchaseCount", { count: purchasesSummary.purchaseCount })}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/expenses"
|
||||
className="text-sm font-medium text-[#0C447C] hover:underline"
|
||||
>
|
||||
{t("viewInExpenses")}
|
||||
</Link>
|
||||
</div>
|
||||
{purchasesSummary.recent.length > 0 ? (
|
||||
<ul className="mt-3 space-y-1 border-t border-[#0F6E56]/15 pt-3 text-sm">
|
||||
{purchasesSummary.recent.slice(0, 5).map((p) => (
|
||||
<li key={p.id} className="flex justify-between gap-2">
|
||||
<span>
|
||||
{p.ingredientName} (+{formatNumber(p.delta, numberLocale)} {p.unit})
|
||||
</span>
|
||||
<span className="shrink-0 font-medium">
|
||||
{formatNumber(p.totalPaidToman, numberLocale)} ت
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{tab === "materials" && !branchId ? (
|
||||
<p className="text-sm text-amber-800">{t("selectBranchForPurchases")}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={tab === "materials" ? "default" : "outline"}
|
||||
className={tab === "materials" ? "bg-[#0F6E56]" : ""}
|
||||
onClick={() => setTab("materials")}
|
||||
>
|
||||
{t("tabMaterials")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === "recipes" ? "default" : "outline"}
|
||||
className={tab === "recipes" ? "bg-[#0F6E56]" : ""}
|
||||
onClick={() => setTab("recipes")}
|
||||
>
|
||||
{t("tabRecipes")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tab === "materials" ? (
|
||||
<>
|
||||
<Card className="rounded-xl border border-border/80 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("addIngredient")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<LabeledField label={t("name")}>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</LabeledField>
|
||||
<InventoryUnitField value={unit} onChange={setUnit} id="ingredient-unit-new" />
|
||||
<LabeledField label={t("quantity")}>
|
||||
<Input value={qty} onChange={(e) => setQty(e.target.value)} dir="ltr" className="text-end" />
|
||||
</LabeledField>
|
||||
<LabeledField label={t("parLevel")}>
|
||||
<Input value={parLevel} onChange={(e) => setParLevel(e.target.value)} dir="ltr" className="text-end" />
|
||||
</LabeledField>
|
||||
<LabeledField label={t("totalPaid")}>
|
||||
<Input
|
||||
value={totalPaid}
|
||||
onChange={(e) => setTotalPaid(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
placeholder="0"
|
||||
/>
|
||||
</LabeledField>
|
||||
{impliedUnitCost > 0 ? (
|
||||
<p className="text-xs text-muted-foreground sm:col-span-2">
|
||||
{t("impliedUnitCost")}: {formatNumber(impliedUnitCost, numberLocale)} ت / {unit}
|
||||
</p>
|
||||
) : null}
|
||||
<LabeledField label={t("warningPercent")}>
|
||||
<Input value={warningPct} onChange={(e) => setWarningPct(e.target.value)} dir="ltr" className="text-end" />
|
||||
</LabeledField>
|
||||
<LabeledField label={t("reorderLevel")}>
|
||||
<Input value={reorder} onChange={(e) => setReorder(e.target.value)} dir="ltr" className="text-end" />
|
||||
</LabeledField>
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46] self-end"
|
||||
disabled={!name.trim()}
|
||||
onClick={() => createIngredient.mutate()}
|
||||
>
|
||||
{tCommon("save")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
||||
) : ingredients.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{ingredients.map((ing) => {
|
||||
const isEditing = editingId === ing.id;
|
||||
return (
|
||||
<Card
|
||||
key={ing.id}
|
||||
className={cn(
|
||||
"rounded-xl border shadow-sm",
|
||||
ing.isLowStock ? "border-amber-300 bg-amber-50/50" : "border-border/80"
|
||||
)}
|
||||
>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("editIngredient")}
|
||||
</p>
|
||||
<LabeledField label={t("name")}>
|
||||
<Input value={editName} onChange={(e) => setEditName(e.target.value)} />
|
||||
</LabeledField>
|
||||
<InventoryUnitField
|
||||
value={editUnit}
|
||||
onChange={setEditUnit}
|
||||
id={`ingredient-unit-${ing.id}`}
|
||||
/>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<LabeledField label={t("parLevel")}>
|
||||
<Input
|
||||
value={editParLevel}
|
||||
onChange={(e) => setEditParLevel(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("unitCost")}>
|
||||
<Input
|
||||
value={editUnitCost}
|
||||
onChange={(e) => setEditUnitCost(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("warningPercent")}>
|
||||
<Input
|
||||
value={editWarningPct}
|
||||
onChange={(e) => setEditWarningPct(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("reorderLevel")}>
|
||||
<Input
|
||||
value={editReorder}
|
||||
onChange={(e) => setEditReorder(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("quantity")}: {formatNumber(ing.quantityOnHand)} {ing.unit} —{" "}
|
||||
{t("quantityEditHint")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={!editName.trim() || updateIngredient.isPending}
|
||||
onClick={() => updateIngredient.mutate(ing.id)}
|
||||
>
|
||||
{tCommon("save")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditingId(null)}
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">{ing.name}</h3>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{ing.isLowStock ? (
|
||||
<Badge variant="outline">{t("lowStock")}</Badge>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8"
|
||||
aria-label={t("editIngredient")}
|
||||
onClick={() => startEdit(ing)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[#0F6E56]">
|
||||
{formatNumber(ing.quantityOnHand)} {ing.unit}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("warningAt")}: {formatNumber(ing.warningThreshold)} {ing.unit}
|
||||
<span className="mx-1">·</span>
|
||||
{t("stockValue")}: {formatNumber(ing.stockValueToman)} ت
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<LabeledField label={t("adjustDelta")} className="min-w-0 flex-1">
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={adjustQty[ing.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setAdjustQty((s) => ({ ...s, [ing.id]: e.target.value }))
|
||||
}
|
||||
dir="ltr"
|
||||
className="h-9 text-end"
|
||||
placeholder="+100"
|
||||
/>
|
||||
</LabeledField>
|
||||
{parseFloat(adjustQty[ing.id] ?? "0") > 0 ? (
|
||||
<LabeledField label={t("totalPaid")} className="min-w-0 flex-1">
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={adjustPaid[ing.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setAdjustPaid((s) => ({ ...s, [ing.id]: e.target.value }))
|
||||
}
|
||||
dir="ltr"
|
||||
className="h-9 text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!branchId && parseFloat(adjustQty[ing.id] ?? "0") > 0}
|
||||
onClick={() => {
|
||||
const delta = parseFloat(adjustQty[ing.id] ?? "0");
|
||||
if (!delta) return;
|
||||
const paid = parseFloat(adjustPaid[ing.id] ?? "0");
|
||||
if (delta > 0 && paid <= 0) {
|
||||
notify.error(t("purchaseRequired"));
|
||||
return;
|
||||
}
|
||||
adjustStock.mutate({
|
||||
id: ing.id,
|
||||
delta,
|
||||
paid: delta > 0 ? paid : undefined,
|
||||
});
|
||||
setAdjustQty((s) => ({ ...s, [ing.id]: "" }));
|
||||
setAdjustPaid((s) => ({ ...s, [ing.id]: "" }));
|
||||
}}
|
||||
>
|
||||
{t("adjust")}
|
||||
</Button>
|
||||
</div>
|
||||
{parseFloat(adjustQty[ing.id] ?? "0") > 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">{t("purchaseHint")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card className="rounded-xl border border-border/80 p-4 space-y-4">
|
||||
<LabeledField label={t("selectMenuItem")}>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={selectedMenuItemId}
|
||||
onChange={(e) => {
|
||||
setSelectedMenuItemId(e.target.value);
|
||||
setRecipeDraft([]);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("selectMenuItemPlaceholder")}</option>
|
||||
{menuItems.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
|
||||
{selectedMenuItemId && recipe ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("materialCostPerUnit")}: {formatNumber(recipe.materialCostPerUnitToman)} ت
|
||||
</p>
|
||||
<p className="text-[11px] uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("recipeLines")}
|
||||
</p>
|
||||
{recipeDraft.map((line, idx) => (
|
||||
<div key={line.ingredientId} className="flex flex-wrap items-end gap-2">
|
||||
<span className="text-sm flex-1 min-w-[120px]">
|
||||
{line.ingredientName} ({line.unit})
|
||||
</span>
|
||||
<Input
|
||||
className="w-28 text-end"
|
||||
dir="ltr"
|
||||
value={String(line.quantityPerUnit)}
|
||||
onChange={(e) => {
|
||||
const next = [...recipeDraft];
|
||||
next[idx] = { ...line, quantityPerUnit: parseFloat(e.target.value) || 0 };
|
||||
setRecipeDraft(next);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => setRecipeDraft(recipeDraft.filter((_, i) => i !== idx))}
|
||||
>
|
||||
{tCommon("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap items-end gap-2 border-t pt-3">
|
||||
<select
|
||||
className="h-10 flex-1 min-w-[140px] rounded-md border border-input px-2 text-sm"
|
||||
value={newRecipeIngredientId}
|
||||
onChange={(e) => setNewRecipeIngredientId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("pickIngredient")}</option>
|
||||
{ingredients.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.name} ({i.unit})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
className="w-24 text-end"
|
||||
dir="ltr"
|
||||
value={newRecipeQty}
|
||||
onChange={(e) => setNewRecipeQty(e.target.value)}
|
||||
placeholder={t("perUnit")}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const ing = ingredients.find((i) => i.id === newRecipeIngredientId);
|
||||
if (!ing) return;
|
||||
if (recipeDraft.some((l) => l.ingredientId === ing.id)) return;
|
||||
setRecipeDraft([
|
||||
...recipeDraft,
|
||||
{
|
||||
id: `draft_${ing.id}`,
|
||||
ingredientId: ing.id,
|
||||
ingredientName: ing.name,
|
||||
unit: ing.unit,
|
||||
quantityPerUnit: parseFloat(newRecipeQty) || 0,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
{t("addLine")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-[#0F6E56]"
|
||||
disabled={saveRecipe.isPending}
|
||||
onClick={() => saveRecipe.mutate()}
|
||||
>
|
||||
{t("saveRecipe")}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">{t("recipeHint")}</p>
|
||||
</>
|
||||
) : selectedMenuItemId ? (
|
||||
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
||||
) : null}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { INVENTORY_UNITS, isKnownInventoryUnit } from "@/lib/inventory-units";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
|
||||
const CUSTOM_VALUE = "__custom__";
|
||||
|
||||
type InventoryUnitFieldProps = {
|
||||
value: string;
|
||||
onChange: (unit: string) => void;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export function InventoryUnitField({ value, onChange, id }: InventoryUnitFieldProps) {
|
||||
const t = useTranslations("inventory");
|
||||
const selectValue = isKnownInventoryUnit(value) ? value : CUSTOM_VALUE;
|
||||
const showCustom = selectValue === CUSTOM_VALUE;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<LabeledField label={t("unit")} htmlFor={id}>
|
||||
<select
|
||||
id={id}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={selectValue}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
if (next === CUSTOM_VALUE) {
|
||||
onChange(value && !isKnownInventoryUnit(value) ? value : "");
|
||||
} else {
|
||||
onChange(next);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{INVENTORY_UNITS.map((u) => (
|
||||
<option key={u.value} value={u.value}>
|
||||
{t(`units.${u.key}`)}
|
||||
</option>
|
||||
))}
|
||||
<option value={CUSTOM_VALUE}>{t("unitCustom")}</option>
|
||||
</select>
|
||||
</LabeledField>
|
||||
{showCustom ? (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={t("unitCustomPlaceholder")}
|
||||
/>
|
||||
) : null}
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">{t("unitsHelp")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import * as signalR from "@microsoft/signalr";
|
||||
import { apiGet, apiPatch } from "@/lib/api/client";
|
||||
import type { LiveOrder } from "@/lib/api/types";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||
import { formatOrderNumber } from "@/lib/order-number";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_FLOW: Record<string, string> = {
|
||||
Pending: "Confirmed",
|
||||
Confirmed: "Preparing",
|
||||
Preparing: "Ready",
|
||||
Ready: "Delivered",
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
Pending: "border-yellow-400 bg-yellow-50",
|
||||
Confirmed: "border-yellow-400 bg-yellow-50",
|
||||
Preparing: "border-blue-400 bg-blue-50",
|
||||
Ready: "border-green-400 bg-green-50",
|
||||
};
|
||||
|
||||
type KdsT = ReturnType<typeof useTranslations<"kds">>;
|
||||
|
||||
function statusLabel(t: KdsT, status: string): string {
|
||||
switch (status) {
|
||||
case "Pending":
|
||||
return t("status.Pending");
|
||||
case "Confirmed":
|
||||
return t("status.Confirmed");
|
||||
case "Preparing":
|
||||
return t("status.Preparing");
|
||||
case "Ready":
|
||||
return t("status.Ready");
|
||||
case "Delivered":
|
||||
return t("status.Delivered");
|
||||
case "Cancelled":
|
||||
return t("status.Cancelled");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function advanceLabel(t: KdsT, nextStatus: string): string {
|
||||
switch (nextStatus) {
|
||||
case "Confirmed":
|
||||
return t("advanceTo.Confirmed");
|
||||
case "Preparing":
|
||||
return t("advanceTo.Preparing");
|
||||
case "Ready":
|
||||
return t("advanceTo.Ready");
|
||||
case "Delivered":
|
||||
return t("advanceTo.Delivered");
|
||||
default:
|
||||
return t("advance");
|
||||
}
|
||||
}
|
||||
|
||||
export function KdsScreen() {
|
||||
const t = useTranslations("kds");
|
||||
const locale = useLocale();
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const queryClient = useQueryClient();
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
const { data: orders = [], isLoading } = useQuery({
|
||||
queryKey: ["orders-live", cafeId],
|
||||
queryFn: () => apiGet<LiveOrder[]>(`/api/cafes/${cafeId}/orders/live`),
|
||||
enabled: !!cafeId,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] });
|
||||
}, [queryClient, cafeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cafeId) return;
|
||||
|
||||
const token =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("meezi_access_token")
|
||||
: null;
|
||||
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
|
||||
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(`${baseUrl}/hubs/kds`, {
|
||||
accessTokenFactory: () => token ?? "",
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
connection
|
||||
.start()
|
||||
.then(() => connection.invoke("JoinCafe", cafeId))
|
||||
.then(() => setConnected(true))
|
||||
.catch(() => setConnected(false));
|
||||
|
||||
connection.on("OrderCreated", () => refresh());
|
||||
connection.on("OrderStatusChanged", () => refresh());
|
||||
|
||||
return () => {
|
||||
void connection.stop();
|
||||
};
|
||||
}, [cafeId, refresh]);
|
||||
|
||||
const advanceStatus = useMutation({
|
||||
mutationFn: async ({ orderId, status }: { orderId: string; status: string }) =>
|
||||
apiPatch(`/api/cafes/${cafeId}/orders/${orderId}/status`, { status }),
|
||||
onSuccess: () => refresh(),
|
||||
});
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
const columns = [
|
||||
{ key: "Pending", label: t("pending"), statuses: ["Pending", "Confirmed"] },
|
||||
{ key: "Preparing", label: t("preparing"), statuses: ["Preparing"] },
|
||||
{ key: "Ready", label: t("ready"), statuses: ["Ready"] },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">{t("title")}</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{connected ? `● ${t("live")}` : `○ ${t("polling")}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">{t("loading")}</p>
|
||||
) : orders.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t("noOrders")}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{columns.map((col) => (
|
||||
<div key={col.key} className="space-y-3">
|
||||
<h3 className="font-semibold">{col.label}</h3>
|
||||
{orders
|
||||
.filter((o) => col.statuses.includes(o.status))
|
||||
.map((order) => {
|
||||
const nextStatus = STATUS_FLOW[order.status];
|
||||
return (
|
||||
<Card
|
||||
key={order.id}
|
||||
className={cn("border-2", statusColors[order.status] ?? "")}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex justify-between gap-2 text-base">
|
||||
<span className="min-w-0">
|
||||
#{formatOrderNumber(order)}
|
||||
{" · "}
|
||||
{order.tableNumber
|
||||
? `${t("table")} ${formatNumber(order.tableNumber, numberLocale)}`
|
||||
: "—"}
|
||||
</span>
|
||||
<span className="shrink-0 text-sm font-normal">
|
||||
{formatCurrency(order.total, numberLocale)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<span className="mt-1 inline-flex w-fit rounded-md border border-border/80 bg-card/80 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
{statusLabel(t, order.status)}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<ul className="text-sm">
|
||||
{order.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
{formatNumber(item.quantity, numberLocale)}×{" "}
|
||||
{item.menuItemName}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{nextStatus ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={advanceStatus.isPending}
|
||||
onClick={() =>
|
||||
advanceStatus.mutate({
|
||||
orderId: order.id,
|
||||
status: nextStatus,
|
||||
})
|
||||
}
|
||||
>
|
||||
{advanceLabel(t, nextStatus)}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { apiGet } from "@/lib/api/client";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
|
||||
type Branch = { id: string; name: string };
|
||||
|
||||
type BranchFilterSelectProps = {
|
||||
value: string | null;
|
||||
onChange: (branchId: string | null) => void;
|
||||
includeAll?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function BranchFilterSelect({
|
||||
value,
|
||||
onChange,
|
||||
includeAll = true,
|
||||
className,
|
||||
}: BranchFilterSelectProps) {
|
||||
const t = useTranslations("tables");
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
|
||||
const { data: branches = [] } = useQuery({
|
||||
queryKey: ["branches", cafeId],
|
||||
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
if (!cafeId || branches.length === 0) return null;
|
||||
if (!includeAll && branches.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<select
|
||||
className={className ?? "rounded-md border border-input bg-background px-3 py-2 text-sm"}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
aria-label={t("branchFilter")}
|
||||
>
|
||||
{includeAll ? (
|
||||
<option value="">{t("allBranches")}</option>
|
||||
) : null}
|
||||
{branches.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { apiGet } from "@/lib/api/client";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useBranchStore } from "@/lib/stores/branch.store";
|
||||
|
||||
type Branch = { id: string; name: string };
|
||||
|
||||
export function BranchSelect({ className }: { className?: string }) {
|
||||
const t = useTranslations("branches");
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const branchId = useBranchStore((s) => s.branchId);
|
||||
const setBranchId = useBranchStore((s) => s.setBranchId);
|
||||
|
||||
const { data: branches = [] } = useQuery({
|
||||
queryKey: ["branches", cafeId],
|
||||
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (branches.length === 0) return;
|
||||
const valid = branchId && branches.some((b) => b.id === branchId);
|
||||
if (!valid) setBranchId(branches[0]!.id);
|
||||
}, [branches, branchId, setBranchId]);
|
||||
|
||||
if (!cafeId || branches.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<select
|
||||
className={className ?? "rounded-md border border-input bg-background px-3 py-2 text-sm"}
|
||||
value={branchId ?? ""}
|
||||
onChange={(e) => setBranchId(e.target.value || null)}
|
||||
aria-label={t("label")}
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { format } from "date-fns-jalali";
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useLiveClock } from "@/lib/hooks/use-live-clock";
|
||||
import { useOnlineStatus } from "@/lib/hooks/use-online-status";
|
||||
import {
|
||||
formatHeaderJalaliDate,
|
||||
formatHeaderTime,
|
||||
isPlanTierKey,
|
||||
} from "@/lib/format-datetime";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function HeaderDivider() {
|
||||
return <div className="mx-3 h-9 w-px shrink-0 bg-border/80" aria-hidden />;
|
||||
}
|
||||
|
||||
/** WiFi + Jalali date/time + plan — grouped at header center; clock is the middle focus. */
|
||||
export function HeaderCenterCluster() {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("dashboard");
|
||||
const tPlanNames = useTranslations("settings.plans.names");
|
||||
const online = useOnlineStatus();
|
||||
const now = useLiveClock();
|
||||
const planTier = useAuthStore((s) => s.user?.planTier);
|
||||
|
||||
const time = useMemo(() => formatHeaderTime(now, locale), [now, locale]);
|
||||
const jalaliDate = useMemo(
|
||||
() => formatHeaderJalaliDate(now, locale),
|
||||
[now, locale]
|
||||
);
|
||||
|
||||
const planLabel = planTier
|
||||
? isPlanTierKey(planTier)
|
||||
? tPlanNames(planTier)
|
||||
: planTier
|
||||
: "—";
|
||||
|
||||
const planBadgeVariant =
|
||||
planTier === "Business" || planTier === "Enterprise"
|
||||
? "default"
|
||||
: planTier === "Pro"
|
||||
? "secondary"
|
||||
: "outline";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 px-1"
|
||||
title={online ? t("online") : t("offline")}
|
||||
aria-label={online ? t("online") : t("offline")}
|
||||
>
|
||||
{online ? (
|
||||
<Wifi className="h-4 w-4 text-emerald-600" aria-hidden />
|
||||
) : (
|
||||
<WifiOff className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"hidden text-xs font-medium whitespace-nowrap lg:inline",
|
||||
online ? "text-emerald-700 dark:text-emerald-500" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{online ? t("online") : t("offline")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<HeaderDivider />
|
||||
|
||||
<div className="flex min-w-[5.5rem] flex-col items-center gap-0.5 px-1 text-center tabular-nums">
|
||||
<time
|
||||
className="max-w-[12rem] truncate text-[11px] leading-none text-muted-foreground"
|
||||
dateTime={format(now, "yyyy-MM-dd")}
|
||||
dir={locale === "en" ? "ltr" : "rtl"}
|
||||
>
|
||||
{jalaliDate}
|
||||
</time>
|
||||
<time
|
||||
className="text-base font-semibold leading-none tracking-tight sm:text-lg"
|
||||
dateTime={format(now, "HH:mm:ss")}
|
||||
dir="ltr"
|
||||
>
|
||||
{time}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<HeaderDivider />
|
||||
|
||||
<Link
|
||||
href="/subscription"
|
||||
className="pointer-events-auto flex flex-col items-center gap-0.5 rounded-md px-1 py-0.5 transition-colors hover:bg-accent/60"
|
||||
title={t("viewSubscription")}
|
||||
>
|
||||
<span className="text-[10px] font-medium uppercase leading-none tracking-wide text-muted-foreground">
|
||||
{t("activePlan")}
|
||||
</span>
|
||||
<Badge variant={planBadgeVariant} className="text-xs font-semibold">
|
||||
{planLabel}
|
||||
</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PageHeaderProps = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PageHeader({ title, subtitle, action, className }: PageHeaderProps) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="text-start">
|
||||
<h1 className="text-lg font-medium tracking-tight text-foreground">{title}</h1>
|
||||
{subtitle ? (
|
||||
<p className="mt-1 text-start text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link, usePathname } from "@/i18n/routing";
|
||||
import { canSeeNavGroup, canSeeNavItem } from "@/lib/auth-permissions";
|
||||
import {
|
||||
NAV_GROUPS,
|
||||
NAV_GROUPS_STORAGE_KEY,
|
||||
findNavGroupForPath,
|
||||
type NavGroupDef,
|
||||
type NavGroupId,
|
||||
type NavItemDef,
|
||||
} from "@/lib/sidebar-nav";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type OpenGroupsState = Partial<Record<NavGroupId, boolean>>;
|
||||
|
||||
function readStoredOpenGroups(): OpenGroupsState {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(NAV_GROUPS_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as OpenGroupsState;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function buildDefaultOpenGroups(): OpenGroupsState {
|
||||
const stored = readStoredOpenGroups();
|
||||
const defaults: OpenGroupsState = {};
|
||||
for (const g of NAV_GROUPS) {
|
||||
defaults[g.id] = stored[g.id] ?? g.defaultOpen;
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
function persistOpenGroups(next: OpenGroupsState): void {
|
||||
try {
|
||||
localStorage.setItem(NAV_GROUPS_STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignore quota */
|
||||
}
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
item,
|
||||
label,
|
||||
active,
|
||||
}: {
|
||||
item: NavItemDef;
|
||||
label: string;
|
||||
active: boolean;
|
||||
}) {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"group flex items-center rounded-lg px-3 py-2 text-sm transition-colors cursor-pointer",
|
||||
active
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 me-2.5",
|
||||
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function NavGroupSection({
|
||||
group,
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
pathname,
|
||||
role,
|
||||
branchId,
|
||||
tItem,
|
||||
}: {
|
||||
group: NavGroupDef;
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
pathname: string;
|
||||
role: string | undefined;
|
||||
branchId: string | null | undefined;
|
||||
tItem: (key: string) => string;
|
||||
}) {
|
||||
const visibleItems = group.items.filter((item) =>
|
||||
canSeeNavItem(item.key, role, branchId)
|
||||
);
|
||||
if (visibleItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-start transition-colors hover:bg-accent/50 cursor-pointer"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3 w-3 shrink-0 text-muted-foreground/60 transition-transform duration-200",
|
||||
open && "rotate-180"
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/70">
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-200",
|
||||
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="space-y-0.5 pb-1 pt-0.5">
|
||||
{visibleItems.map((item) => {
|
||||
const active =
|
||||
pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.key}
|
||||
item={item}
|
||||
label={tItem(item.key)}
|
||||
active={active}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ side }: { side: "left" | "right" }) {
|
||||
const t = useTranslations("nav");
|
||||
const tGroups = useTranslations("nav.groups");
|
||||
const tBrand = useTranslations("brand");
|
||||
const pathname = usePathname();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const hasHydrated = useAuthStore((s) => s._hasHydrated);
|
||||
const role = user?.role;
|
||||
const branchId = user?.branchId ?? null;
|
||||
|
||||
const [openGroups, setOpenGroups] = useState<OpenGroupsState>(buildDefaultOpenGroups);
|
||||
|
||||
const visibleGroups = useMemo(
|
||||
() =>
|
||||
NAV_GROUPS.filter((g) => {
|
||||
if (!canSeeNavGroup(g.id, role, branchId)) return false;
|
||||
return g.items.some((item) => canSeeNavItem(item.key, role, branchId));
|
||||
}),
|
||||
[role, branchId]
|
||||
);
|
||||
|
||||
const setGroupOpen = useCallback((groupId: NavGroupId, open: boolean) => {
|
||||
setOpenGroups((prev) => {
|
||||
const next = { ...prev, [groupId]: open };
|
||||
persistOpenGroups(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const activeGroup = findNavGroupForPath(pathname);
|
||||
if (!activeGroup) return;
|
||||
setOpenGroups((prev) => {
|
||||
if (prev[activeGroup]) return prev;
|
||||
const next = { ...prev, [activeGroup]: true };
|
||||
persistOpenGroups(next);
|
||||
return next;
|
||||
});
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex w-56 shrink-0 flex-col bg-background",
|
||||
"border-border",
|
||||
side === "right" ? "border-s" : "border-e"
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex h-14 items-center gap-2.5 px-4 border-b border-border">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-primary" aria-hidden>
|
||||
<path d="M3 6h18v2H3V6zm2 4h14v2H5v-2zm-2 4h18v2H3v-2zm4 4h10v2H7v-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-bold tracking-tight text-foreground">
|
||||
{tBrand("name")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav
|
||||
className="flex-1 overflow-y-auto p-3 space-y-1
|
||||
[&::-webkit-scrollbar]:w-1
|
||||
[&::-webkit-scrollbar-track]:bg-transparent
|
||||
[&::-webkit-scrollbar-thumb]:rounded-full
|
||||
[&::-webkit-scrollbar-thumb]:bg-border"
|
||||
aria-label={t("aria")}
|
||||
>
|
||||
{!hasHydrated ? (
|
||||
/* Skeleton — shown for ~50ms until Zustand rehydrates from localStorage.
|
||||
Prevents the flash where all groups are briefly visible before
|
||||
permission-based filtering kicks in for branch-scoped accounts. */
|
||||
<div className="space-y-3 px-1 pt-1">
|
||||
{[40, 32, 40, 32, 40].map((w, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="h-2 w-20 animate-pulse rounded bg-muted" />
|
||||
{Array.from({ length: i % 2 === 0 ? 3 : 2 }).map((_, j) => (
|
||||
<div
|
||||
key={j}
|
||||
className={`h-8 animate-pulse rounded-lg bg-muted`}
|
||||
style={{ width: `${w + j * 4}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
visibleGroups.map((group) => {
|
||||
const isOpen = openGroups[group.id] ?? group.defaultOpen;
|
||||
return (
|
||||
<NavGroupSection
|
||||
key={group.id}
|
||||
group={group}
|
||||
title={tGroups(group.id)}
|
||||
open={isOpen}
|
||||
onToggle={() => setGroupOpen(group.id, !isOpen)}
|
||||
pathname={pathname}
|
||||
role={role}
|
||||
branchId={branchId}
|
||||
tItem={(key) => t(key)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Footer — user role badge */}
|
||||
{user && (
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<span className="text-[11px] font-semibold text-primary">
|
||||
{(user.actor ?? user.role).charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{user.actor ?? user.userId}
|
||||
</p>
|
||||
<p className="truncate text-[10px] text-muted-foreground">{user.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { WifiOff, CloudUpload, RefreshCw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
||||
import { useLocale } from "next-intl";
|
||||
import {
|
||||
getAllQueueItems,
|
||||
getQueueCount,
|
||||
removeQueueItem,
|
||||
markQueueItemFailed,
|
||||
} from "@/lib/offline/offline-db";
|
||||
import { apiPost } from "@/lib/api/client";
|
||||
|
||||
/** Manual retry — fires one sync pass immediately (used as onClick). */
|
||||
async function runManualSync(
|
||||
setSyncing: (v: boolean) => void,
|
||||
setQueueCount: (n: number) => void
|
||||
) {
|
||||
if (!navigator.onLine) return;
|
||||
setSyncing(true);
|
||||
try {
|
||||
const items = await getAllQueueItems();
|
||||
for (const item of items) {
|
||||
try {
|
||||
if (item.type === "create_order") {
|
||||
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
|
||||
await apiPost(`/api/cafes/${cafeId}/orders`, body as Record<string, unknown>);
|
||||
} else if (item.type === "add_items") {
|
||||
const { cafeId, orderId, body } = item.payload as {
|
||||
cafeId: string;
|
||||
orderId: string;
|
||||
body: unknown;
|
||||
};
|
||||
await apiPost(
|
||||
`/api/cafes/${cafeId}/orders/${orderId}/items`,
|
||||
body as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
await removeQueueItem(item.id);
|
||||
} catch {
|
||||
await markQueueItemFailed(item.id);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
setQueueCount(await getQueueCount());
|
||||
}
|
||||
}
|
||||
|
||||
export function SyncStatusIndicator() {
|
||||
const { queueCount, isSyncing, isOnline, setSyncing, setQueueCount } =
|
||||
useSyncQueueStore();
|
||||
const locale = useLocale();
|
||||
const isFa = locale !== "en";
|
||||
|
||||
const show = !isOnline || queueCount > 0 || isSyncing;
|
||||
if (!show) return null;
|
||||
|
||||
const label = isFa
|
||||
? !isOnline
|
||||
? "آفلاین"
|
||||
: isSyncing
|
||||
? "همگامسازی..."
|
||||
: `${queueCount} مورد در صف`
|
||||
: !isOnline
|
||||
? "Offline"
|
||||
: isSyncing
|
||||
? "Syncing..."
|
||||
: `${queueCount} pending`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runManualSync(setSyncing, setQueueCount)}
|
||||
disabled={isSyncing || !isOnline}
|
||||
title={
|
||||
isFa
|
||||
? "برای همگامسازی دستی کلیک کنید"
|
||||
: "Click to retry sync"
|
||||
}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors",
|
||||
"disabled:cursor-not-allowed",
|
||||
!isOnline
|
||||
? "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"
|
||||
: isSyncing
|
||||
? "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
: "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300"
|
||||
)}
|
||||
>
|
||||
{!isOnline ? (
|
||||
<WifiOff className="h-3 w-3 shrink-0" aria-hidden />
|
||||
) : isSyncing ? (
|
||||
<RefreshCw className="h-3 w-3 shrink-0 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<CloudUpload className="h-3 w-3 shrink-0" aria-hidden />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Link, useRouter, usePathname } from "@/i18n/routing";
|
||||
import { Pencil, LogOut } from "lucide-react";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useCafeSettings } from "@/lib/hooks/use-cafe-settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { HeaderCenterCluster } from "@/components/layout/header-center-cluster";
|
||||
import { NotificationCenter } from "@/components/notifications/notification-center";
|
||||
import { SyncStatusIndicator } from "@/components/layout/sync-status-indicator";
|
||||
|
||||
const locales = ["fa", "ar", "en"] as const;
|
||||
|
||||
export function Topbar() {
|
||||
const t = useTranslations();
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const clearAuth = useAuthStore((s) => s.clearAuth);
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const { data: cafeSettings, isLoading, isPending } = useCafeSettings(cafeId);
|
||||
const cafeDisplayName = cafeSettings?.name ?? t("dashboard.cafeName");
|
||||
const showNameSkeleton = (isLoading || isPending) && !cafeSettings;
|
||||
|
||||
const switchLocale = (next: string) => {
|
||||
router.replace(pathname, { locale: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="relative flex h-14 items-center gap-3 border-b border-border bg-background px-4 sm:px-6">
|
||||
{/* Cafe name */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{showNameSkeleton ? (
|
||||
<Skeleton className="h-5 w-32 max-w-full" />
|
||||
) : (
|
||||
<Link
|
||||
href="/settings"
|
||||
className="group inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-lg px-2 py-1 transition-colors hover:bg-accent cursor-pointer"
|
||||
title={t("dashboard.editCafeSettings")}
|
||||
>
|
||||
<h1 className="truncate text-sm font-semibold text-foreground sm:text-base">
|
||||
{cafeDisplayName}
|
||||
</h1>
|
||||
<Pencil
|
||||
className="h-3 w-3 shrink-0 text-muted-foreground/50 transition-colors group-hover:text-primary"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="sr-only">{t("dashboard.editCafeSettings")}</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<HeaderCenterCluster />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-1 items-center justify-end gap-1.5">
|
||||
<SyncStatusIndicator />
|
||||
<NotificationCenter />
|
||||
|
||||
{/* Language switcher */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2.5 text-xs cursor-pointer">
|
||||
{t(`languages.${locale}`)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[120px]">
|
||||
{locales.map((code) => (
|
||||
<DropdownMenuItem
|
||||
key={code}
|
||||
onClick={() => switchLocale(code)}
|
||||
className={locale === code ? "font-semibold text-primary cursor-pointer" : "cursor-pointer"}
|
||||
>
|
||||
{t(`languages.${code}`)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Logout */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
clearAuth();
|
||||
router.push("/login");
|
||||
}}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
title={t("common.logout")}
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" aria-hidden />
|
||||
<span className="sr-only">{t("common.logout")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { Clock, X, Zap } from "lucide-react";
|
||||
|
||||
// 14 Khordad 1405 = June 4, 2026 (Tehran UTC+3:30)
|
||||
const DEADLINE = new Date("2026-06-04T00:00:00+03:30");
|
||||
const STORAGE_KEY = "meezi_trial_banner_v1";
|
||||
|
||||
interface TimeLeft {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
function calcTimeLeft(): TimeLeft {
|
||||
const diff = Math.max(0, DEADLINE.getTime() - Date.now());
|
||||
return {
|
||||
days: Math.floor(diff / 86_400_000),
|
||||
hours: Math.floor((diff % 86_400_000) / 3_600_000),
|
||||
minutes: Math.floor((diff % 3_600_000) / 60_000),
|
||||
seconds: Math.floor((diff % 60_000) / 1_000),
|
||||
};
|
||||
}
|
||||
|
||||
function pad(n: number) {
|
||||
return n.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
export function TrialCountdownBanner() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const isRtl = locale !== "en";
|
||||
|
||||
// Start hidden — reveal after mount so we can read localStorage without SSR mismatch
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState<TimeLeft>(calcTimeLeft);
|
||||
const [expired, setExpired] = useState(false);
|
||||
|
||||
// Hydrate visibility from localStorage
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(STORAGE_KEY) !== "1") {
|
||||
setVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Tick every second
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const id = setInterval(() => {
|
||||
const tl = calcTimeLeft();
|
||||
setTimeLeft(tl);
|
||||
if (tl.days === 0 && tl.hours === 0 && tl.minutes === 0 && tl.seconds === 0) {
|
||||
setExpired(true);
|
||||
}
|
||||
}, 1_000);
|
||||
return () => clearInterval(id);
|
||||
}, [visible]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
setVisible(false);
|
||||
localStorage.setItem(STORAGE_KEY, "1");
|
||||
};
|
||||
|
||||
const urgency = timeLeft.days <= 3; // red when ≤ 3 days left
|
||||
const soon = timeLeft.days <= 7; // amber when ≤ 7 days left
|
||||
|
||||
const bgClass = urgency
|
||||
? "bg-red-600"
|
||||
: soon
|
||||
? "bg-amber-500"
|
||||
: "bg-[#0F6E56]";
|
||||
|
||||
const textFa = expired
|
||||
? "دوره آزمایشی میزی به پایان رسید. برای ادامه پلن انتخاب کنید."
|
||||
: "دوره آزمایشی رایگان تا ۱۴ خرداد ۱۴۰۵";
|
||||
|
||||
const textEn = expired
|
||||
? "Your Meezi trial has ended. Choose a plan to continue."
|
||||
: "Free trial ends 14 Khordad 1405 (Jun 4)";
|
||||
|
||||
const Digit = ({ value, label }: { value: number; label: string }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="min-w-[2.25rem] rounded-md bg-white/20 px-2 py-0.5 text-center text-base font-extrabold tabular-nums leading-tight text-white sm:text-lg">
|
||||
{pad(value)}
|
||||
</span>
|
||||
<span className="mt-0.5 text-[10px] font-medium text-white/70">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const labelsFa = ["روز", "ساعت", "دقیقه", "ثانیه"];
|
||||
const labelsEn = ["d", "h", "m", "s"];
|
||||
const labels = isRtl ? labelsFa : labelsEn;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-2 sm:px-6 ${bgClass} transition-colors duration-700`}
|
||||
role="banner"
|
||||
aria-live="polite"
|
||||
>
|
||||
{/* Icon + message */}
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<Clock className="h-4 w-4 shrink-0 opacity-80" />
|
||||
<span className="text-xs font-semibold sm:text-sm">
|
||||
{isRtl ? textFa : textEn}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Countdown digits */}
|
||||
{!expired && (
|
||||
<div className="flex items-end gap-2">
|
||||
<Digit value={timeLeft.days} label={labels[0]} />
|
||||
<span className="mb-3 text-white/60 font-bold">:</span>
|
||||
<Digit value={timeLeft.hours} label={labels[1]} />
|
||||
<span className="mb-3 text-white/60 font-bold">:</span>
|
||||
<Digit value={timeLeft.minutes} label={labels[2]} />
|
||||
<span className="mb-3 text-white/60 font-bold">:</span>
|
||||
<Digit value={timeLeft.seconds} label={labels[3]} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<button
|
||||
onClick={() => router.push("/subscription")}
|
||||
className="ms-auto flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-bold text-gray-900 shadow-sm transition hover:bg-gray-100 active:scale-95"
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5 text-amber-500" />
|
||||
{isRtl ? "ارتقا به پرو" : "Upgrade to Pro"}
|
||||
</button>
|
||||
|
||||
{/* Dismiss */}
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="shrink-0 rounded p-0.5 text-white/70 transition hover:text-white"
|
||||
aria-label={isRtl ? "بستن" : "Dismiss"}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { ImagePlus, Video } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { apiUpload, resolveMediaUrl } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MediaKind = "menu" | "table";
|
||||
|
||||
type MediaPairUploadProps = {
|
||||
cafeId: string;
|
||||
kind: MediaKind;
|
||||
imageUrl?: string | null;
|
||||
videoUrl?: string | null;
|
||||
onImageChange: (url: string | null) => void;
|
||||
onVideoChange: (url: string | null) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function MediaPairUpload({
|
||||
cafeId,
|
||||
kind,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
onImageChange,
|
||||
onVideoChange,
|
||||
className,
|
||||
}: MediaPairUploadProps) {
|
||||
const t = useTranslations("media");
|
||||
const imageRef = useRef<HTMLInputElement>(null);
|
||||
const videoRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const imageEndpoint =
|
||||
kind === "menu"
|
||||
? `/api/cafes/${cafeId}/media/menu-image`
|
||||
: `/api/cafes/${cafeId}/media/table-image`;
|
||||
const videoEndpoint =
|
||||
kind === "menu"
|
||||
? `/api/cafes/${cafeId}/media/menu-video`
|
||||
: `/api/cafes/${cafeId}/media/table-video`;
|
||||
|
||||
const imgSrc = resolveMediaUrl(imageUrl);
|
||||
const vidSrc = resolveMediaUrl(videoUrl);
|
||||
|
||||
const upload = async (file: File, endpoint: string, onDone: (url: string) => void) => {
|
||||
const data = await apiUpload<{ url: string }>(endpoint, file);
|
||||
onDone(data.url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
ref={imageRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void upload(f, imageEndpoint, onImageChange);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={videoRef}
|
||||
type="file"
|
||||
accept="video/mp4,video/webm,video/quicktime"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void upload(f, videoEndpoint, onVideoChange);
|
||||
}}
|
||||
/>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => imageRef.current?.click()}>
|
||||
<ImagePlus className="me-1 h-3.5 w-3.5" />
|
||||
{t("uploadImage")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => videoRef.current?.click()}>
|
||||
<Video className="me-1 h-3.5 w-3.5" />
|
||||
{t("uploadVideo")}
|
||||
</Button>
|
||||
{imageUrl ? (
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => onImageChange(null)}>
|
||||
{t("removeImage")}
|
||||
</Button>
|
||||
) : null}
|
||||
{videoUrl ? (
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => onVideoChange(null)}>
|
||||
{t("removeVideo")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{imgSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt=""
|
||||
className="h-20 w-20 rounded-lg border object-cover"
|
||||
/>
|
||||
) : null}
|
||||
{vidSrc ? (
|
||||
<video
|
||||
src={vidSrc}
|
||||
className="h-20 max-w-[140px] rounded-lg border object-cover"
|
||||
muted
|
||||
playsInline
|
||||
controls
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiGet, apiUpload, ApiClientError } from "@/lib/api/client";
|
||||
import { MENU_3D_GLB_MAX_MB, MENU_360_PHOTO_COUNT } from "@/lib/menu-3d";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Menu3dUploadProps = {
|
||||
cafeId: string;
|
||||
model3dUrl?: string | null;
|
||||
onChange: (url: string | null) => void;
|
||||
};
|
||||
|
||||
export function Menu3dUpload({ cafeId, model3dUrl, onChange }: Menu3dUploadProps) {
|
||||
const t = useTranslations("media");
|
||||
const tSub = useTranslations("subscription");
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const { data: billing } = useQuery({
|
||||
queryKey: ["billing-status", cafeId],
|
||||
queryFn: () => apiGet<{ menu3dEnabled: boolean }>("/api/billing/status"),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
const enabled = billing?.menu3dEnabled ?? false;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border border-dashed border-border/80 bg-muted/20 p-3">
|
||||
<p className="text-xs font-medium text-foreground">{t("upload3dTitle")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("upload3dHint", { maxMb: MENU_3D_GLB_MAX_MB })}</p>
|
||||
{!enabled ? (
|
||||
<p className="text-xs text-amber-700">{tSub("featureMenu3dUpgrade")}</p>
|
||||
) : null}
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("upload3dPhotoCount", {
|
||||
min: MENU_360_PHOTO_COUNT.min,
|
||||
ideal: MENU_360_PHOTO_COUNT.ideal,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
ref={ref}
|
||||
type="file"
|
||||
accept=".glb,model/gltf-binary"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
void apiUpload<{ url: string }>(`/api/cafes/${cafeId}/media/menu-model3d`, f)
|
||||
.then((d) => onChange(d.url))
|
||||
.catch((err) => {
|
||||
if (err instanceof ApiClientError && err.code === "PLAN_FEATURE_DISABLED") {
|
||||
alert(tSub("featureMenu3dUpgrade"));
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!enabled}
|
||||
onClick={() => ref.current?.click()}
|
||||
>
|
||||
<Box className="me-1 h-3.5 w-3.5" />
|
||||
{t("upload3d")}
|
||||
</Button>
|
||||
{model3dUrl ? (
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => onChange(null)}>
|
||||
{t("remove3d")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{model3dUrl ? (
|
||||
<p className="text-[11px] text-[#0F6E56]">{t("upload3dReady")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiGet, apiPost, ApiClientError } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { notify } from "@/lib/notify";
|
||||
|
||||
type MenuAi3dGenerateProps = {
|
||||
cafeId: string;
|
||||
itemId: string;
|
||||
imageUrl?: string | null;
|
||||
onGenerated: (model3dUrl: string) => void;
|
||||
};
|
||||
|
||||
type BillingStatus = {
|
||||
menu3dEnabled: boolean;
|
||||
menuAi3dEnabled: boolean;
|
||||
menuAi3dUsedThisMonth: number;
|
||||
menuAi3dMonthlyLimit: number;
|
||||
};
|
||||
|
||||
type Ai3dUsage = {
|
||||
used: number;
|
||||
limit: number;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export function MenuAi3dGenerate({
|
||||
cafeId,
|
||||
itemId,
|
||||
imageUrl,
|
||||
onGenerated,
|
||||
}: MenuAi3dGenerateProps) {
|
||||
const t = useTranslations("media");
|
||||
const tSub = useTranslations("subscription");
|
||||
const queryClient = useQueryClient();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const { data: billing } = useQuery({
|
||||
queryKey: ["billing-status", cafeId],
|
||||
queryFn: () => apiGet<BillingStatus>("/api/billing/status"),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const { data: usage } = useQuery({
|
||||
queryKey: ["menu-ai-3d-usage", cafeId],
|
||||
queryFn: () => apiGet<Ai3dUsage>(`/api/cafes/${cafeId}/menu/ai-3d/usage`),
|
||||
enabled: !!cafeId && (billing?.menuAi3dEnabled ?? false),
|
||||
});
|
||||
|
||||
const aiEnabled = billing?.menuAi3dEnabled ?? false;
|
||||
const used = usage?.used ?? billing?.menuAi3dUsedThisMonth ?? 0;
|
||||
const limit = usage?.limit ?? billing?.menuAi3dMonthlyLimit ?? 100;
|
||||
const atLimit = limit > 0 && used >= limit;
|
||||
|
||||
const generate = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<{ model3dUrl: string; used: number; limit: number }>(
|
||||
`/api/cafes/${cafeId}/menu/items/${itemId}/ai-3d`,
|
||||
{}
|
||||
),
|
||||
onSuccess: (data) => {
|
||||
onGenerated(data.model3dUrl);
|
||||
void queryClient.invalidateQueries({ queryKey: ["billing-status", cafeId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["menu-ai-3d-usage", cafeId] });
|
||||
notify.success(t("ai3dSuccess"));
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ApiClientError) {
|
||||
if (err.code === "PLAN_FEATURE_DISABLED") {
|
||||
notify.error(tSub("featureMenuAi3dUpgrade"));
|
||||
return;
|
||||
}
|
||||
if (err.code === "PLAN_LIMIT_REACHED") {
|
||||
notify.error(t("ai3dLimitReached"));
|
||||
return;
|
||||
}
|
||||
if (err.code === "NO_IMAGE") {
|
||||
notify.error(t("ai3dNoImage"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
notify.error(t("ai3dFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
const handleClick = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await generate.mutateAsync();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!billing?.menu3dEnabled) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border border-border/80 bg-card p-3">
|
||||
<p className="text-xs font-medium text-foreground">{t("ai3dTitle")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("ai3dHint")}</p>
|
||||
{!aiEnabled ? (
|
||||
<p className="text-xs text-amber-700">{tSub("featureMenuAi3dUpgrade")}</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("ai3dUsage", { used: used.toLocaleString("fa-IR"), limit: limit.toLocaleString("fa-IR") })}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-primary text-primary-foreground hover:opacity-90"
|
||||
disabled={!aiEnabled || !imageUrl || atLimit || busy || generate.isPending}
|
||||
onClick={() => void handleClick()}
|
||||
>
|
||||
<Sparkles className="me-1 h-3.5 w-3.5" />
|
||||
{busy || generate.isPending ? t("ai3dGenerating") : t("ai3dGenerate")}
|
||||
</Button>
|
||||
{!imageUrl ? (
|
||||
<p className="text-[11px] text-amber-700">{t("ai3dNoImage")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Lock } from "lucide-react";
|
||||
import { ApiClientError } from "@/lib/api/client";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import {
|
||||
deleteBranchMenuOverride,
|
||||
getBranchMenu,
|
||||
upsertBranchMenuOverride,
|
||||
type BranchMenuItem,
|
||||
} from "@/lib/api/branch-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { useConfirm } from "@/components/providers/confirm-provider";
|
||||
|
||||
type BranchMenuOverridesProps = {
|
||||
cafeId: string;
|
||||
branchId: string;
|
||||
numberLocale: string;
|
||||
};
|
||||
|
||||
export function BranchMenuOverrides({
|
||||
cafeId,
|
||||
branchId,
|
||||
numberLocale,
|
||||
}: BranchMenuOverridesProps) {
|
||||
const t = useTranslations("branchMenu");
|
||||
const tErrors = useTranslations("errors");
|
||||
const planTier = useAuthStore((s) => s.user?.planTier ?? "Free");
|
||||
const canOverridePrice = planTier !== "Free";
|
||||
const queryClient = useQueryClient();
|
||||
const confirmDialog = useConfirm();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [priceDraft, setPriceDraft] = useState<Record<string, string>>({});
|
||||
|
||||
const { data: rows = [], isLoading } = useQuery({
|
||||
queryKey: ["branch-menu", cafeId, branchId, "manage"],
|
||||
queryFn: () => getBranchMenu(cafeId, branchId, { includeUnavailable: true }),
|
||||
enabled: !!cafeId && !!branchId,
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["branch-menu", cafeId, branchId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["menu-items", cafeId] });
|
||||
};
|
||||
|
||||
const upsert = useMutation({
|
||||
mutationFn: ({
|
||||
menuItemId,
|
||||
isAvailable,
|
||||
priceOverride,
|
||||
}: {
|
||||
menuItemId: string;
|
||||
isAvailable: boolean;
|
||||
priceOverride: number | null;
|
||||
}) =>
|
||||
upsertBranchMenuOverride(cafeId, branchId, menuItemId, {
|
||||
isAvailable,
|
||||
priceOverride,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setMessage(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
if (err instanceof ApiClientError && err.code === "PLAN_LIMIT_REACHED") {
|
||||
setMessage(t("priceOverridePro"));
|
||||
return;
|
||||
}
|
||||
setMessage(tErrors("planLimit"));
|
||||
},
|
||||
});
|
||||
|
||||
const resetOverride = useMutation({
|
||||
mutationFn: (menuItemId: string) =>
|
||||
deleteBranchMenuOverride(cafeId, branchId, menuItemId),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...rows].sort((a, b) => a.name.localeCompare(b.name, "fa")),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const handleToggle = (row: BranchMenuItem) => {
|
||||
upsert.mutate({
|
||||
menuItemId: row.id,
|
||||
isAvailable: !row.isAvailable,
|
||||
priceOverride: row.hasPriceOverride ? row.effectivePrice : null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSavePrice = (row: BranchMenuItem) => {
|
||||
const raw = priceDraft[row.id] ?? String(row.effectivePrice);
|
||||
const parsed = Number(raw.replace(/,/g, ""));
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return;
|
||||
upsert.mutate({
|
||||
menuItemId: row.id,
|
||||
isAvailable: row.isAvailable,
|
||||
priceOverride: parsed,
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = async (row: BranchMenuItem) => {
|
||||
if (!row.isOverridden) return;
|
||||
const ok = await confirmDialog({ description: t("confirmReset") });
|
||||
if (!ok) return;
|
||||
resetOverride.mutate(row.id);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{message ? (
|
||||
<Alert variant="warning" onDismiss={() => setMessage(null)}>
|
||||
{message}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-border/80 bg-card">
|
||||
<table className="w-full min-w-[32rem] text-start text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/80 bg-muted/30 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-3 py-2 font-medium">{t("name")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("masterPrice")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("branchPrice")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("availability")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={cn(
|
||||
"border-b border-border/60 last:border-0",
|
||||
row.isOverridden && "bg-[#E1F5EE]/40"
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<MenuItemLabels item={row} lines={1} primaryClassName="text-sm" />
|
||||
{row.isOverridden ? (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t("overrideActive")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{formatCurrency(row.masterPrice, numberLocale)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{canOverridePrice ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
className="h-8 w-28 text-xs"
|
||||
value={priceDraft[row.id] ?? String(row.effectivePrice)}
|
||||
onChange={(e) =>
|
||||
setPriceDraft((d) => ({ ...d, [row.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs"
|
||||
disabled={upsert.isPending}
|
||||
onClick={() => handleSavePrice(row)}
|
||||
>
|
||||
{t("savePrice")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Lock className="h-3 w-3" aria-hidden />
|
||||
{t("priceOverridePro")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={row.isAvailable}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 shrink-0 rounded-full border transition-colors",
|
||||
row.isAvailable
|
||||
? "border-primary bg-primary"
|
||||
: "border-border bg-muted"
|
||||
)}
|
||||
onClick={() => handleToggle(row)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none inline-block h-5 w-5 translate-y-0.5 rounded-full bg-white shadow transition-transform",
|
||||
row.isAvailable ? "translate-x-5" : "translate-x-0.5"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<span className="ms-2 text-xs text-muted-foreground">
|
||||
{row.isAvailable ? t("available") : t("unavailable")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 text-xs"
|
||||
disabled={!row.isOverridden || resetOverride.isPending}
|
||||
onClick={() => handleReset(row)}
|
||||
>
|
||||
{t("resetOverride")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CATEGORY_EMOJI_GROUPS } from "@/lib/category-emoji-presets";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CategoryEmojiPickerProps = {
|
||||
value: string;
|
||||
onChange: (emoji: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CategoryEmojiPicker({ value, onChange, className }: CategoryEmojiPickerProps) {
|
||||
const t = useTranslations("menuAdmin");
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3 max-h-[min(420px,50vh)] overflow-y-auto pe-1", className)}>
|
||||
{CATEGORY_EMOJI_GROUPS.map((group) => (
|
||||
<div key={group.id} className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t(`iconEmojiGroups.${group.id}`)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{group.emojis.map((emoji, index) => {
|
||||
const selected = value.trim() === emoji;
|
||||
return (
|
||||
<button
|
||||
key={`${group.id}-${index}-${emoji}`}
|
||||
type="button"
|
||||
title={emoji}
|
||||
onClick={() => onChange(emoji)}
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-lg border text-lg transition-all active:scale-[0.96]",
|
||||
selected
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] ring-1 ring-[#0F6E56]/30"
|
||||
: "border-border/80 bg-card hover:border-[#0F6E56]/40 hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { ImagePlus } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
CategoryPresetPicker,
|
||||
type CategoryIconSelection,
|
||||
} from "@/components/menu/category-preset-picker";
|
||||
import { CategoryEmojiPicker } from "@/components/menu/category-emoji-picker";
|
||||
import { CategoryPresetIcon } from "@/components/menu/category-preset-icon";
|
||||
import { CategoryVisual } from "@/components/menu/category-visual";
|
||||
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
|
||||
import { apiUpload, resolveMediaUrl } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CategoryMediaFieldsProps = {
|
||||
cafeId: string;
|
||||
icon: string;
|
||||
iconPresetId: string | null;
|
||||
iconStyle: string | null;
|
||||
imageUrl: string;
|
||||
onIconChange: (value: string) => void;
|
||||
onPresetChange: (value: CategoryIconSelection) => void;
|
||||
onImageChange: (url: string | null) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type MediaTab = "preset" | "emoji" | "image";
|
||||
|
||||
export function CategoryMediaFields({
|
||||
cafeId,
|
||||
icon,
|
||||
iconPresetId,
|
||||
iconStyle,
|
||||
imageUrl,
|
||||
onIconChange,
|
||||
onPresetChange,
|
||||
onImageChange,
|
||||
className,
|
||||
}: CategoryMediaFieldsProps) {
|
||||
const t = useTranslations("menuAdmin");
|
||||
const tMedia = useTranslations("media");
|
||||
const imageRef = useRef<HTMLInputElement>(null);
|
||||
const imgSrc = resolveMediaUrl(imageUrl);
|
||||
const [tab, setTab] = useState<MediaTab>(
|
||||
imageUrl ? "image" : iconPresetId ? "preset" : "preset"
|
||||
);
|
||||
|
||||
const uploadImage = async (file: File) => {
|
||||
const data = await apiUpload<{ url: string }>(
|
||||
`/api/cafes/${cafeId}/media/menu-image`,
|
||||
file
|
||||
);
|
||||
onImageChange(data.url);
|
||||
onPresetChange({ iconPresetId: null, iconStyle: null });
|
||||
onIconChange("");
|
||||
setTab("image");
|
||||
};
|
||||
|
||||
const tabs: { id: MediaTab; label: string }[] = [
|
||||
{ id: "preset", label: t("iconTabPreset") },
|
||||
{ id: "emoji", label: t("iconTabEmoji") },
|
||||
{ id: "image", label: t("iconTabImage") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tabs.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTab(id)}
|
||||
className={cn(
|
||||
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
tab === id
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
||||
: "border-border/80 text-muted-foreground hover:border-[#0F6E56]/40"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "preset" ? (
|
||||
<CategoryPresetPicker
|
||||
value={{
|
||||
iconPresetId,
|
||||
iconStyle: (iconStyle as CategoryIconSelection["iconStyle"]) ?? DEFAULT_CATEGORY_ICON_STYLE,
|
||||
}}
|
||||
onChange={(next) => {
|
||||
onPresetChange(next);
|
||||
if (next.iconPresetId) onIconChange("");
|
||||
onImageChange(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{tab === "emoji" ? (
|
||||
<div className="space-y-3 rounded-lg border border-border/80 bg-muted/20 p-3">
|
||||
{icon.trim() ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{t("iconPreview")}</span>
|
||||
<CategoryVisual icon={icon} size="md" />
|
||||
<button
|
||||
type="button"
|
||||
className="text-[#0F6E56] underline-offset-2 hover:underline"
|
||||
onClick={() => onIconChange("")}
|
||||
>
|
||||
{t("clearIconEmoji")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<CategoryEmojiPicker
|
||||
value={icon}
|
||||
onChange={(emoji) => {
|
||||
onIconChange(emoji);
|
||||
onPresetChange({ iconPresetId: null, iconStyle: null });
|
||||
onImageChange(null);
|
||||
}}
|
||||
/>
|
||||
<LabeledField label={t("categoryIconCustom")} htmlFor="cat-icon" className="max-w-[10rem]">
|
||||
<Input
|
||||
id="cat-icon"
|
||||
value={icon}
|
||||
onChange={(e) => {
|
||||
onIconChange(e.target.value);
|
||||
if (e.target.value.trim()) {
|
||||
onPresetChange({ iconPresetId: null, iconStyle: null });
|
||||
onImageChange(null);
|
||||
}
|
||||
}}
|
||||
placeholder="☕"
|
||||
className="text-center text-lg"
|
||||
maxLength={16}
|
||||
/>
|
||||
</LabeledField>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "image" ? (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
ref={imageRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void uploadImage(f);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => imageRef.current?.click()}>
|
||||
<ImagePlus className="me-1 h-3.5 w-3.5" />
|
||||
{t("categoryImage")}
|
||||
</Button>
|
||||
{imageUrl ? (
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => onImageChange(null)}>
|
||||
{tMedia("removeImage")}
|
||||
</Button>
|
||||
) : null}
|
||||
{iconPresetId ? (
|
||||
<CategoryPresetIcon presetId={iconPresetId} style={iconStyle} size="sm" />
|
||||
) : null}
|
||||
</div>
|
||||
{imgSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={imgSrc} alt="" className="h-16 w-16 rounded-lg border object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import {
|
||||
DEFAULT_CATEGORY_ICON_STYLE,
|
||||
getCategoryIconPreset,
|
||||
getCategoryIconStroke,
|
||||
isCategoryIconStyle,
|
||||
type CategoryIconStyleId,
|
||||
} from "@/lib/category-icon-presets";
|
||||
import type { CafeThemePalette } from "@/lib/cafe-theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CategoryPresetIconProps = {
|
||||
presetId: string;
|
||||
style?: string | null;
|
||||
size?: "xs" | "sm" | "md";
|
||||
className?: string;
|
||||
/** When set (QR guest menu), icon shell uses café theme instead of default Meezi green. */
|
||||
brandColors?: Pick<
|
||||
CafeThemePalette,
|
||||
"primary" | "secondary" | "accent" | "surface" | "textMuted"
|
||||
>;
|
||||
};
|
||||
|
||||
function themedIconShellStyle(
|
||||
styleId: CategoryIconStyleId,
|
||||
c: NonNullable<CategoryPresetIconProps["brandColors"]>
|
||||
): CSSProperties {
|
||||
const primaryRing = `0 0 0 2px color-mix(in srgb, ${c.primary} 20%, transparent)`;
|
||||
switch (styleId) {
|
||||
case "flat":
|
||||
return {
|
||||
backgroundColor: c.secondary,
|
||||
color: c.primary,
|
||||
border: `1px solid color-mix(in srgb, ${c.primary} 15%, transparent)`,
|
||||
};
|
||||
case "modern":
|
||||
return {
|
||||
background: `linear-gradient(135deg, ${c.secondary}, ${c.surface}, color-mix(in srgb, ${c.secondary} 60%, transparent))`,
|
||||
color: c.primary,
|
||||
border: `1px solid color-mix(in srgb, ${c.primary} 20%, transparent)`,
|
||||
};
|
||||
case "minimal":
|
||||
return { backgroundColor: "transparent", color: c.textMuted, border: "1px solid transparent" };
|
||||
case "outline":
|
||||
return {
|
||||
backgroundColor: c.surface,
|
||||
color: c.primary,
|
||||
border: `2px solid color-mix(in srgb, ${c.primary} 35%, transparent)`,
|
||||
};
|
||||
case "soft":
|
||||
return {
|
||||
backgroundColor: `color-mix(in srgb, ${c.secondary} 70%, transparent)`,
|
||||
color: c.primary,
|
||||
border: "none",
|
||||
borderRadius: "0.75rem",
|
||||
};
|
||||
case "bold":
|
||||
return { backgroundColor: c.primary, color: "#fff", border: "none" };
|
||||
case "gradient":
|
||||
return {
|
||||
background: `linear-gradient(to top right, ${c.primary}, color-mix(in srgb, ${c.primary} 75%, ${c.accent}), color-mix(in srgb, ${c.primary} 40%, #fff))`,
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
};
|
||||
case "pastel":
|
||||
return {
|
||||
backgroundColor: `color-mix(in srgb, ${c.secondary} 40%, ${c.surface})`,
|
||||
color: c.accent,
|
||||
border: `1px solid color-mix(in srgb, ${c.accent} 20%, transparent)`,
|
||||
};
|
||||
case "duotone":
|
||||
return {
|
||||
backgroundColor: c.secondary,
|
||||
color: c.accent,
|
||||
border: `1px solid color-mix(in srgb, ${c.accent} 15%, transparent)`,
|
||||
boxShadow: primaryRing,
|
||||
};
|
||||
default:
|
||||
return { backgroundColor: c.secondary, color: c.primary };
|
||||
}
|
||||
}
|
||||
|
||||
const boxSize = {
|
||||
xs: "h-5 w-5",
|
||||
sm: "h-7 w-7",
|
||||
md: "h-10 w-10",
|
||||
} as const;
|
||||
|
||||
const iconSize = {
|
||||
xs: "h-3 w-3",
|
||||
sm: "h-4 w-4",
|
||||
md: "h-5 w-5",
|
||||
} as const;
|
||||
|
||||
function resolveStyle(style: string | null | undefined): CategoryIconStyleId {
|
||||
return isCategoryIconStyle(style) ? style : DEFAULT_CATEGORY_ICON_STYLE;
|
||||
}
|
||||
|
||||
const styleShell: Record<CategoryIconStyleId, string> = {
|
||||
flat: "bg-[#E1F5EE] text-[#0F6E56] border border-[#0F6E56]/15",
|
||||
modern:
|
||||
"bg-gradient-to-br from-[#E1F5EE] via-white to-[#E1F5EE]/60 text-[#0F6E56] border border-[#0F6E56]/20 shadow-sm",
|
||||
minimal: "bg-transparent text-muted-foreground border border-transparent",
|
||||
outline: "bg-white text-[#0F6E56] border-2 border-[#0F6E56]/35",
|
||||
real: "bg-muted/30 border border-border/80 overflow-hidden p-0",
|
||||
soft: "bg-[#E1F5EE]/70 text-[#0F6E56] border-0 shadow-md rounded-xl",
|
||||
bold: "bg-[#0F6E56] text-white border-0 shadow-sm",
|
||||
gradient:
|
||||
"bg-gradient-to-tr from-[#0F6E56] via-[#1a8f6e] to-[#5ec4a8] text-white border-0 shadow-md",
|
||||
pastel: "bg-[#FDF8F3] text-[#BA7517] border border-[#BA7517]/20",
|
||||
duotone: "bg-[#E1F5EE] text-[#0C447C] border border-[#0C447C]/15 ring-2 ring-[#0F6E56]/20",
|
||||
};
|
||||
|
||||
export function CategoryPresetIcon({
|
||||
presetId,
|
||||
style,
|
||||
size = "sm",
|
||||
className,
|
||||
brandColors,
|
||||
}: CategoryPresetIconProps) {
|
||||
const preset = getCategoryIconPreset(presetId);
|
||||
if (!preset) return null;
|
||||
|
||||
const styleId = resolveStyle(style);
|
||||
const Icon = preset.icon;
|
||||
const stroke = getCategoryIconStroke(styleId);
|
||||
|
||||
if (styleId === "real") {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={preset.realImageUrl}
|
||||
alt=""
|
||||
className={cn(
|
||||
"shrink-0 rounded-md object-cover",
|
||||
boxSize[size],
|
||||
styleShell.real,
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const shellStyle = brandColors ? themedIconShellStyle(styleId, brandColors) : undefined;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-md",
|
||||
boxSize[size],
|
||||
!brandColors && styleShell[styleId],
|
||||
className
|
||||
)}
|
||||
style={shellStyle}
|
||||
aria-hidden
|
||||
>
|
||||
<Icon
|
||||
className={cn(iconSize[size], stroke.className)}
|
||||
strokeWidth={stroke.strokeWidth}
|
||||
fill={styleId === "bold" || styleId === "gradient" ? "currentColor" : "none"}
|
||||
fillOpacity={styleId === "bold" || styleId === "gradient" ? 0.15 : 0}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
CATEGORY_ICON_PRESETS,
|
||||
CATEGORY_ICON_STYLES,
|
||||
DEFAULT_CATEGORY_ICON_STYLE,
|
||||
type CategoryIconPresetKind,
|
||||
type CategoryIconStyleId,
|
||||
} from "@/lib/category-icon-presets";
|
||||
import { CategoryPresetIcon } from "@/components/menu/category-preset-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type CategoryIconSelection = {
|
||||
iconPresetId: string | null;
|
||||
iconStyle: CategoryIconStyleId | null;
|
||||
};
|
||||
|
||||
type CategoryPresetPickerProps = {
|
||||
value: CategoryIconSelection;
|
||||
onChange: (value: CategoryIconSelection) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CategoryPresetPicker({ value, onChange, className }: CategoryPresetPickerProps) {
|
||||
const t = useTranslations("menuAdmin");
|
||||
const activeStyle = value.iconStyle ?? DEFAULT_CATEGORY_ICON_STYLE;
|
||||
|
||||
const setStyle = (style: CategoryIconStyleId) => {
|
||||
onChange({
|
||||
iconPresetId: value.iconPresetId,
|
||||
iconStyle: style,
|
||||
});
|
||||
};
|
||||
|
||||
const setPreset = (presetId: string) => {
|
||||
onChange({
|
||||
iconPresetId: presetId,
|
||||
iconStyle: activeStyle,
|
||||
});
|
||||
};
|
||||
|
||||
const clearPreset = () => {
|
||||
onChange({ iconPresetId: null, iconStyle: null });
|
||||
};
|
||||
|
||||
const renderGroup = (kind: CategoryIconPresetKind, label: string) => {
|
||||
const presets = CATEGORY_ICON_PRESETS.filter((p) => p.kind === kind);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{presets.map((preset) => {
|
||||
const selected = value.iconPresetId === preset.id;
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
title={t(`iconPresets.${preset.id}`)}
|
||||
onClick={() => setPreset(preset.id)}
|
||||
className={cn(
|
||||
"rounded-lg border p-1.5 transition-all active:scale-[0.98]",
|
||||
selected
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] ring-1 ring-[#0F6E56]/30"
|
||||
: "border-border/80 bg-card hover:border-[#0F6E56]/40"
|
||||
)}
|
||||
>
|
||||
<CategoryPresetIcon presetId={preset.id} style={activeStyle} size="md" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3 rounded-lg border border-border/80 bg-muted/20 p-3", className)}>
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("iconStyleLabel")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CATEGORY_ICON_STYLES.map((style) => (
|
||||
<button
|
||||
key={style}
|
||||
type="button"
|
||||
onClick={() => setStyle(style)}
|
||||
className={cn(
|
||||
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
activeStyle === style
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
||||
: "border-border/80 bg-card text-muted-foreground hover:border-[#0F6E56]/40"
|
||||
)}
|
||||
>
|
||||
{t(`iconStyles.${style}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{value.iconPresetId ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{t("iconPreview")}</span>
|
||||
<CategoryPresetIcon
|
||||
presetId={value.iconPresetId}
|
||||
style={activeStyle}
|
||||
size="md"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[#0F6E56] underline-offset-2 hover:underline"
|
||||
onClick={clearPreset}
|
||||
>
|
||||
{t("clearIconPreset")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{renderGroup("drink", t("iconPresetGroupDrinks"))}
|
||||
{renderGroup("food", t("iconPresetGroupFood"))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { CategoryPresetIcon } from "@/components/menu/category-preset-icon";
|
||||
import { resolveMediaUrl } from "@/lib/api/client";
|
||||
import type { CafeThemePalette } from "@/lib/cafe-theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CategoryVisualProps = {
|
||||
icon?: string | null;
|
||||
iconPresetId?: string | null;
|
||||
iconStyle?: string | null;
|
||||
imageUrl?: string | null;
|
||||
size?: "xs" | "sm" | "md";
|
||||
className?: string;
|
||||
brandColors?: Pick<
|
||||
CafeThemePalette,
|
||||
"primary" | "secondary" | "accent" | "surface" | "textMuted"
|
||||
>;
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
xs: "h-5 w-5 text-sm",
|
||||
sm: "h-7 w-7 text-base",
|
||||
md: "h-10 w-10 text-xl",
|
||||
} as const;
|
||||
|
||||
export function CategoryVisual({
|
||||
icon,
|
||||
iconPresetId,
|
||||
iconStyle,
|
||||
imageUrl,
|
||||
size = "sm",
|
||||
className,
|
||||
brandColors,
|
||||
}: CategoryVisualProps) {
|
||||
const imgSrc = resolveMediaUrl(imageUrl);
|
||||
const emoji = icon?.trim();
|
||||
|
||||
if (imgSrc) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt=""
|
||||
className={cn(
|
||||
"shrink-0 rounded-md border border-border/80 object-cover",
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (iconPresetId) {
|
||||
return (
|
||||
<CategoryPresetIcon
|
||||
presetId={iconPresetId}
|
||||
style={iconStyle}
|
||||
size={size}
|
||||
className={className}
|
||||
brandColors={brandColors}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (emoji) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-md border",
|
||||
brandColors ? "qr-border" : "border-border/60 bg-muted/40",
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
style={
|
||||
brandColors
|
||||
? {
|
||||
backgroundColor: `color-mix(in srgb, ${brandColors.secondary} 55%, ${brandColors.surface})`,
|
||||
borderColor: `color-mix(in srgb, ${brandColors.primary} 18%, transparent)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useIsRtl } from "@/lib/use-is-rtl";
|
||||
import { Box, Pencil, Video } from "lucide-react";
|
||||
import { Menu3dUpload } from "@/components/media/menu-3d-upload";
|
||||
import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate";
|
||||
import { CategoryVisual } from "@/components/menu/category-visual";
|
||||
import { CategoryMediaFields } from "@/components/menu/category-media-fields";
|
||||
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
|
||||
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
|
||||
import { apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { MediaPairUpload } from "@/components/media/media-pair-upload";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
|
||||
import { MenuItemMedia } from "@/components/menu/menu-item-media";
|
||||
import { buildCategoryNameMap, inferMenuItemKind } from "@/lib/menu-item-image";
|
||||
|
||||
interface MenuCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
nameEn?: string;
|
||||
nameAr?: string;
|
||||
sortOrder: number;
|
||||
discountPercent: number;
|
||||
icon?: string;
|
||||
iconPresetId?: string;
|
||||
iconStyle?: string;
|
||||
imageUrl?: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
name: string;
|
||||
nameEn?: string;
|
||||
nameAr?: string;
|
||||
price: number;
|
||||
discountPercent: number;
|
||||
imageUrl?: string;
|
||||
videoUrl?: string;
|
||||
model3dUrl?: string;
|
||||
isAvailable: boolean;
|
||||
}
|
||||
|
||||
function discountedPrice(price: number, percent: number) {
|
||||
if (percent <= 0) return price;
|
||||
return Math.round(price * (1 - percent / 100));
|
||||
}
|
||||
|
||||
function mediaField(url: string) {
|
||||
return url.trim() === "" ? "" : url;
|
||||
}
|
||||
|
||||
export function MenuAdminScreen() {
|
||||
const t = useTranslations("menuAdmin");
|
||||
const tCommon = useTranslations("common");
|
||||
const isRtl = useIsRtl();
|
||||
const locale = useLocale();
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const queryClient = useQueryClient();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
|
||||
|
||||
const [catName, setCatName] = useState("");
|
||||
const [catIcon, setCatIcon] = useState("");
|
||||
const [catIconPreset, setCatIconPreset] = useState<CategoryIconSelection>({
|
||||
iconPresetId: null,
|
||||
iconStyle: DEFAULT_CATEGORY_ICON_STYLE,
|
||||
});
|
||||
const [catImageUrl, setCatImageUrl] = useState("");
|
||||
const [editCatName, setEditCatName] = useState("");
|
||||
const [editCatIcon, setEditCatIcon] = useState("");
|
||||
const [editCatIconPreset, setEditCatIconPreset] = useState<CategoryIconSelection>({
|
||||
iconPresetId: null,
|
||||
iconStyle: DEFAULT_CATEGORY_ICON_STYLE,
|
||||
});
|
||||
const [editCatImageUrl, setEditCatImageUrl] = useState("");
|
||||
const [itemName, setItemName] = useState("");
|
||||
const [itemNameEn, setItemNameEn] = useState("");
|
||||
const [itemPrice, setItemPrice] = useState("");
|
||||
const [itemDiscount, setItemDiscount] = useState("0");
|
||||
const [itemCategoryId, setItemCategoryId] = useState("");
|
||||
const [itemImageUrl, setItemImageUrl] = useState("");
|
||||
const [itemVideoUrl, setItemVideoUrl] = useState("");
|
||||
const [itemModel3dUrl, setItemModel3dUrl] = useState("");
|
||||
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editNameEn, setEditNameEn] = useState("");
|
||||
const [editPrice, setEditPrice] = useState("");
|
||||
const [editDiscount, setEditDiscount] = useState("0");
|
||||
const [editImageUrl, setEditImageUrl] = useState("");
|
||||
const [editVideoUrl, setEditVideoUrl] = useState("");
|
||||
const [editModel3dUrl, setEditModel3dUrl] = useState("");
|
||||
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ["menu-categories", cafeId],
|
||||
queryFn: () => apiGet<MenuCategory[]>(`/api/cafes/${cafeId}/menu/categories`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const { data: items = [], isLoading } = useQuery({
|
||||
queryKey: ["menu-items-all", cafeId],
|
||||
queryFn: () => apiGet<MenuItem[]>(`/api/cafes/${cafeId}/menu/items`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const categoryNameById = useMemo(
|
||||
() => buildCategoryNameMap(categories),
|
||||
[categories]
|
||||
);
|
||||
|
||||
const invalidateMenu = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["menu-items-all", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["menu-categories", cafeId] });
|
||||
};
|
||||
|
||||
const addCategory = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost(`/api/cafes/${cafeId}/menu/categories`, {
|
||||
name: catName,
|
||||
sortOrder: categories.length + 1,
|
||||
discountPercent: 0,
|
||||
icon: catIcon.trim() || null,
|
||||
iconPresetId: catIconPreset.iconPresetId,
|
||||
iconStyle: catIconPreset.iconPresetId ? catIconPreset.iconStyle : null,
|
||||
imageUrl: catImageUrl.trim() || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setCatName("");
|
||||
setCatIcon("");
|
||||
setCatIconPreset({ iconPresetId: null, iconStyle: DEFAULT_CATEGORY_ICON_STYLE });
|
||||
setCatImageUrl("");
|
||||
invalidateMenu();
|
||||
},
|
||||
});
|
||||
|
||||
const updateCategory = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiPatch(`/api/cafes/${cafeId}/menu/categories/${id}`, {
|
||||
name: editCatName,
|
||||
icon: mediaField(editCatIcon),
|
||||
iconPresetId: editCatIconPreset.iconPresetId ?? "",
|
||||
iconStyle: editCatIconPreset.iconPresetId ? editCatIconPreset.iconStyle : "",
|
||||
imageUrl: mediaField(editCatImageUrl),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setEditingCategoryId(null);
|
||||
invalidateMenu();
|
||||
},
|
||||
});
|
||||
|
||||
const addItem = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost(`/api/cafes/${cafeId}/menu/items`, {
|
||||
categoryId: itemCategoryId,
|
||||
name: itemName,
|
||||
nameEn: itemNameEn.trim(),
|
||||
price: parseFloat(itemPrice),
|
||||
discountPercent: parseFloat(itemDiscount) || 0,
|
||||
imageUrl: itemImageUrl || null,
|
||||
videoUrl: itemVideoUrl || null,
|
||||
model3dUrl: itemModel3dUrl || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setItemName("");
|
||||
setItemNameEn("");
|
||||
setItemPrice("");
|
||||
setItemDiscount("0");
|
||||
setItemImageUrl("");
|
||||
setItemVideoUrl("");
|
||||
setItemModel3dUrl("");
|
||||
invalidateMenu();
|
||||
},
|
||||
});
|
||||
|
||||
const updateItem = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiPatch(`/api/cafes/${cafeId}/menu/items/${id}`, {
|
||||
name: editName,
|
||||
nameEn: editNameEn.trim(),
|
||||
price: parseFloat(editPrice),
|
||||
discountPercent: parseFloat(editDiscount) || 0,
|
||||
imageUrl: mediaField(editImageUrl),
|
||||
videoUrl: mediaField(editVideoUrl),
|
||||
model3dUrl: mediaField(editModel3dUrl),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setEditingId(null);
|
||||
invalidateMenu();
|
||||
},
|
||||
});
|
||||
|
||||
const toggleItem = useMutation({
|
||||
mutationFn: ({ id, isAvailable }: { id: string; isAvailable: boolean }) =>
|
||||
apiPatch(`/api/cafes/${cafeId}/menu/items/${id}/availability`, { isAvailable }),
|
||||
onSuccess: invalidateMenu,
|
||||
});
|
||||
|
||||
const startCategoryEdit = (cat: MenuCategory) => {
|
||||
setEditingCategoryId(cat.id);
|
||||
setEditCatName(cat.name);
|
||||
setEditCatIcon(cat.icon ?? "");
|
||||
setEditCatIconPreset({
|
||||
iconPresetId: cat.iconPresetId ?? null,
|
||||
iconStyle: (cat.iconStyle as CategoryIconSelection["iconStyle"]) ?? DEFAULT_CATEGORY_ICON_STYLE,
|
||||
});
|
||||
setEditCatImageUrl(cat.imageUrl ?? "");
|
||||
};
|
||||
|
||||
const startEdit = (item: MenuItem) => {
|
||||
setEditingId(item.id);
|
||||
setEditName(item.name);
|
||||
setEditNameEn(item.nameEn ?? "");
|
||||
setEditPrice(String(item.price));
|
||||
setEditDiscount(String(item.discountPercent));
|
||||
setEditImageUrl(item.imageUrl ?? "");
|
||||
setEditVideoUrl(item.videoUrl ?? "");
|
||||
setEditModel3dUrl(item.model3dUrl ?? "");
|
||||
};
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6" dir={isRtl ? "rtl" : "ltr"}>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
<Card className="rounded-xl border border-border/80 bg-card shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("categories")}
|
||||
</p>
|
||||
<CardTitle className="text-base">{t("addCategory")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<LabeledField label={t("name")} htmlFor="cat-name" className="min-w-[12rem] flex-1">
|
||||
<Input id="cat-name" value={catName} onChange={(e) => setCatName(e.target.value)} />
|
||||
</LabeledField>
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={!catName.trim()}
|
||||
onClick={() => addCategory.mutate()}
|
||||
>
|
||||
{t("addCategory")}
|
||||
</Button>
|
||||
</div>
|
||||
<CategoryMediaFields
|
||||
cafeId={cafeId}
|
||||
icon={catIcon}
|
||||
iconPresetId={catIconPreset.iconPresetId}
|
||||
iconStyle={catIconPreset.iconStyle}
|
||||
imageUrl={catImageUrl}
|
||||
onIconChange={setCatIcon}
|
||||
onPresetChange={setCatIconPreset}
|
||||
onImageChange={(url) => setCatImageUrl(url ?? "")}
|
||||
/>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{categories.map((c) => {
|
||||
const isEditingCat = editingCategoryId === c.id;
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg border border-border/80 bg-card px-3 py-2 transition-colors hover:border-[#0F6E56]/40",
|
||||
isEditingCat && "ring-1 ring-[#0F6E56]/30"
|
||||
)}
|
||||
>
|
||||
<CategoryVisual
|
||||
icon={c.icon}
|
||||
iconPresetId={c.iconPresetId}
|
||||
iconStyle={c.iconStyle}
|
||||
imageUrl={c.imageUrl}
|
||||
size="sm"
|
||||
/>
|
||||
{isEditingCat ? (
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Input value={editCatName} onChange={(e) => setEditCatName(e.target.value)} />
|
||||
<CategoryMediaFields
|
||||
cafeId={cafeId}
|
||||
icon={editCatIcon}
|
||||
iconPresetId={editCatIconPreset.iconPresetId}
|
||||
iconStyle={editCatIconPreset.iconStyle}
|
||||
imageUrl={editCatImageUrl}
|
||||
onIconChange={setEditCatIcon}
|
||||
onPresetChange={setEditCatIconPreset}
|
||||
onImageChange={(url) => setEditCatImageUrl(url ?? "")}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={!editCatName.trim()}
|
||||
onClick={() => updateCategory.mutate(c.id)}
|
||||
>
|
||||
{tCommon("save")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditingCategoryId(null)}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{c.name}</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => startCategoryEdit(c)}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section>
|
||||
<p className="mb-3 text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("items")}
|
||||
</p>
|
||||
|
||||
<Card className="mb-4 rounded-xl border border-border/80 bg-card shadow-sm">
|
||||
<CardContent className="space-y-3 pt-6">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<LabeledField label={t("category")} htmlFor="item-category">
|
||||
<select
|
||||
id="item-category"
|
||||
className="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||
value={itemCategoryId}
|
||||
onChange={(e) => setItemCategoryId(e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("name")} htmlFor="item-name">
|
||||
<Input id="item-name" value={itemName} onChange={(e) => setItemName(e.target.value)} />
|
||||
</LabeledField>
|
||||
<LabeledField label={t("nameEn")} htmlFor="item-name-en">
|
||||
<Input
|
||||
id="item-name-en"
|
||||
value={itemNameEn}
|
||||
onChange={(e) => setItemNameEn(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-start"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("price")} htmlFor="item-price">
|
||||
<Input
|
||||
id="item-price"
|
||||
value={itemPrice}
|
||||
onChange={(e) => setItemPrice(e.target.value)}
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("discountPercent")} htmlFor="item-discount">
|
||||
<Input
|
||||
id="item-discount"
|
||||
value={itemDiscount}
|
||||
onChange={(e) => setItemDiscount(e.target.value)}
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46] self-end"
|
||||
disabled={!itemName.trim() || !itemNameEn.trim() || !itemCategoryId || !itemPrice}
|
||||
onClick={() => addItem.mutate()}
|
||||
>
|
||||
{t("addItem")}
|
||||
</Button>
|
||||
</div>
|
||||
<LabeledField label={t("media")}>
|
||||
<MediaPairUpload
|
||||
cafeId={cafeId}
|
||||
kind="menu"
|
||||
imageUrl={itemImageUrl}
|
||||
videoUrl={itemVideoUrl}
|
||||
onImageChange={(url) => setItemImageUrl(url ?? "")}
|
||||
onVideoChange={(url) => setItemVideoUrl(url ?? "")}
|
||||
/>
|
||||
<Menu3dUpload
|
||||
cafeId={cafeId}
|
||||
model3dUrl={itemModel3dUrl || null}
|
||||
onChange={(url) => setItemModel3dUrl(url ?? "")}
|
||||
/>
|
||||
</LabeledField>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((item) => {
|
||||
const kind = inferMenuItemKind(
|
||||
item.categoryId,
|
||||
categoryNameById.get(item.categoryId)
|
||||
);
|
||||
const hasDiscount = item.discountPercent > 0;
|
||||
const salePrice = discountedPrice(item.price, item.discountPercent);
|
||||
const isEditing = editingId === item.id;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border border-border/80 bg-card shadow-sm transition-colors hover:border-[#0F6E56]/40",
|
||||
!item.isAvailable && "opacity-60"
|
||||
)}
|
||||
>
|
||||
{hasDiscount ? (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-2 z-10 rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-[#BA7517]",
|
||||
isRtl ? "start-2" : "end-2"
|
||||
)}
|
||||
>
|
||||
{formatNumber(item.discountPercent)}٪ {t("discountBadge")}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-muted/50">
|
||||
<MenuItemMedia
|
||||
imageUrl={item.imageUrl}
|
||||
kind={kind}
|
||||
size="md"
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
{item.videoUrl ? (
|
||||
<span className="absolute bottom-2 start-2 flex items-center gap-1 rounded-md bg-black/60 px-2 py-0.5 text-[10px] text-white">
|
||||
<Video className="h-3 w-3" />
|
||||
Video
|
||||
</span>
|
||||
) : null}
|
||||
{item.model3dUrl ? (
|
||||
<span className="absolute bottom-2 end-2 flex items-center gap-1 rounded-md bg-[#0F6E56]/90 px-2 py-0.5 text-[10px] text-white">
|
||||
<Box className="h-3 w-3" />
|
||||
3D
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<CardContent className="space-y-2 p-4">
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<LabeledField label={t("name")} htmlFor={`edit-name-${item.id}`}>
|
||||
<Input
|
||||
id={`edit-name-${item.id}`}
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("nameEn")} htmlFor={`edit-name-en-${item.id}`}>
|
||||
<Input
|
||||
id={`edit-name-en-${item.id}`}
|
||||
value={editNameEn}
|
||||
onChange={(e) => setEditNameEn(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-start"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("price")} htmlFor={`edit-price-${item.id}`}>
|
||||
<Input
|
||||
id={`edit-price-${item.id}`}
|
||||
value={editPrice}
|
||||
onChange={(e) => setEditPrice(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("discountPercent")} htmlFor={`edit-discount-${item.id}`}>
|
||||
<Input
|
||||
id={`edit-discount-${item.id}`}
|
||||
value={editDiscount}
|
||||
onChange={(e) => setEditDiscount(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<MediaPairUpload
|
||||
cafeId={cafeId}
|
||||
kind="menu"
|
||||
imageUrl={editImageUrl}
|
||||
videoUrl={editVideoUrl}
|
||||
onImageChange={(url) => setEditImageUrl(url ?? "")}
|
||||
onVideoChange={(url) => setEditVideoUrl(url ?? "")}
|
||||
/>
|
||||
<Menu3dUpload
|
||||
cafeId={cafeId}
|
||||
model3dUrl={editModel3dUrl || null}
|
||||
onChange={(url) => setEditModel3dUrl(url ?? "")}
|
||||
/>
|
||||
<MenuAi3dGenerate
|
||||
cafeId={cafeId}
|
||||
itemId={item.id}
|
||||
imageUrl={editImageUrl || item.imageUrl}
|
||||
onGenerated={(url) => setEditModel3dUrl(url)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => updateItem.mutate(item.id)}>
|
||||
{tCommon("save")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditingId(null)}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<MenuItemLabels item={item} primaryClassName="text-sm" />
|
||||
<div className="flex items-baseline gap-2">
|
||||
{hasDiscount ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground line-through">
|
||||
{formatCurrency(item.price)}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-[#0F6E56]">
|
||||
{formatCurrency(salePrice)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-[#0F6E56]">
|
||||
{formatCurrency(item.price)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button size="sm" variant="outline" onClick={() => startEdit(item)}>
|
||||
<Pencil className="me-1 h-3 w-3" />
|
||||
{t("editItem")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => toggleItem.mutate({ id: item.id, isAvailable: !item.isAvailable })}
|
||||
>
|
||||
{item.isAvailable ? t("available") : t("unavailable")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getMenuEnglishSubtitle,
|
||||
getMenuPrimaryName,
|
||||
type MenuNameFields,
|
||||
} from "@/lib/menu-display";
|
||||
|
||||
type MenuItemLabelsProps = {
|
||||
item: MenuNameFields;
|
||||
className?: string;
|
||||
primaryClassName?: string;
|
||||
secondaryClassName?: string;
|
||||
lines?: 1 | 2;
|
||||
};
|
||||
|
||||
export function MenuItemLabels({
|
||||
item,
|
||||
className,
|
||||
primaryClassName,
|
||||
secondaryClassName,
|
||||
lines = 2,
|
||||
}: MenuItemLabelsProps) {
|
||||
const locale = useLocale();
|
||||
const primary = getMenuPrimaryName(item, locale);
|
||||
const english = getMenuEnglishSubtitle(item, locale);
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0", className)}>
|
||||
<p
|
||||
className={cn(
|
||||
"font-medium leading-snug",
|
||||
lines === 2 ? "line-clamp-2" : "truncate",
|
||||
primaryClassName
|
||||
)}
|
||||
>
|
||||
{primary}
|
||||
</p>
|
||||
{english ? (
|
||||
<p
|
||||
className={cn(
|
||||
"mt-0.5 truncate text-[11px] text-muted-foreground",
|
||||
secondaryClassName
|
||||
)}
|
||||
dir="ltr"
|
||||
>
|
||||
{english}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { MenuItemVisualKind } from "@/lib/menu-item-image";
|
||||
import {
|
||||
getMenuItemImageSrc,
|
||||
menuItemPlaceholderHeroIcon,
|
||||
menuItemPlaceholderIcon,
|
||||
} from "@/lib/menu-item-image";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MenuItemMediaProps = {
|
||||
imageUrl?: string | null;
|
||||
kind: MenuItemVisualKind;
|
||||
size?: "xs" | "sm" | "md" | "hero";
|
||||
className?: string;
|
||||
imgClassName?: string;
|
||||
};
|
||||
|
||||
const iconSize: Record<NonNullable<MenuItemMediaProps["size"]>, string> = {
|
||||
xs: "h-3 w-3",
|
||||
sm: "h-4 w-4",
|
||||
md: "h-10 w-10",
|
||||
hero: "h-8 w-8",
|
||||
};
|
||||
|
||||
const placeholderBg: Record<MenuItemVisualKind, string> = {
|
||||
drink: "bg-[#E8F4F8]",
|
||||
food: "bg-[#F5F0EB]",
|
||||
};
|
||||
|
||||
const placeholderIconColor: Record<MenuItemVisualKind, string> = {
|
||||
drink: "text-[#0F6E56]/45",
|
||||
food: "text-[#8B6914]/40",
|
||||
};
|
||||
|
||||
export function MenuItemMedia({
|
||||
imageUrl,
|
||||
kind,
|
||||
size = "sm",
|
||||
className,
|
||||
imgClassName,
|
||||
}: MenuItemMediaProps) {
|
||||
const src = getMenuItemImageSrc(imageUrl);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const PlaceholderIcon =
|
||||
size === "hero" ? menuItemPlaceholderHeroIcon(kind) : menuItemPlaceholderIcon(kind);
|
||||
|
||||
const showPlaceholder = !src || loadFailed;
|
||||
|
||||
if (showPlaceholder) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center",
|
||||
placeholderBg[kind],
|
||||
className
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<PlaceholderIcon className={cn(iconSize[size], placeholderIconColor[kind])} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className={cn("h-full w-full object-cover", imgClassName, className)}
|
||||
loading="lazy"
|
||||
onError={() => setLoadFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import "@google/model-viewer";
|
||||
import { resolveMediaUrl } from "@/lib/api/client";
|
||||
|
||||
type MenuItemModelViewerProps = {
|
||||
modelUrl: string;
|
||||
posterUrl?: string | null;
|
||||
alt: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function MenuItemModelViewer({
|
||||
modelUrl,
|
||||
posterUrl,
|
||||
alt,
|
||||
className,
|
||||
}: MenuItemModelViewerProps) {
|
||||
const src = resolveMediaUrl(modelUrl);
|
||||
const poster = posterUrl ? resolveMediaUrl(posterUrl) : undefined;
|
||||
if (!src) return null;
|
||||
|
||||
return (
|
||||
// @ts-expect-error model-viewer is a custom element from @google/model-viewer
|
||||
<model-viewer
|
||||
src={src}
|
||||
poster={poster}
|
||||
alt={alt}
|
||||
camera-controls
|
||||
touch-action="pan-y"
|
||||
auto-rotate
|
||||
rotation-per-second="28deg"
|
||||
interaction-prompt="none"
|
||||
shadow-intensity="1"
|
||||
exposure="1"
|
||||
environment-image="neutral"
|
||||
className={className}
|
||||
style={{ width: "100%", height: "100%", minHeight: "min(72vh, 420px)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bell } from "lucide-react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useNotificationsFeed } from "@/lib/hooks/use-notifications-feed";
|
||||
import type { CafeNotification } from "@/lib/api/notifications";
|
||||
import { numberLocaleForUi } from "@/lib/format-datetime";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { NotificationDetailPanel, NotificationRow } from "@/components/notifications/notification-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function NotificationCenter() {
|
||||
const t = useTranslations("notifications");
|
||||
const locale = useLocale();
|
||||
const numberLocale = numberLocaleForUi(locale);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<CafeNotification | null>(null);
|
||||
|
||||
const { items, unreadCount, openNotification, markAllRead } = useNotificationsFeed({
|
||||
enableToasts: true,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const handleSelect = async (n: CafeNotification) => {
|
||||
await openNotification(n);
|
||||
setSelected({ ...n, isRead: true });
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) setSelected(null);
|
||||
};
|
||||
|
||||
const isRtl = locale !== "en";
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="relative shrink-0"
|
||||
aria-label={t("title")}
|
||||
>
|
||||
<Bell className="size-4" />
|
||||
{unreadCount > 0 ? (
|
||||
<span className="absolute -top-1 -end-1 flex size-5 items-center justify-center rounded-full bg-[#0F6E56] text-[10px] font-bold text-white">
|
||||
{unreadCount > 9
|
||||
? locale === "fa"
|
||||
? "۹+"
|
||||
: "9+"
|
||||
: unreadCount.toLocaleString(numberLocale)}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={isRtl ? "start" : "end"}
|
||||
sideOffset={4}
|
||||
collisionPadding={12}
|
||||
className={cn(
|
||||
"w-80 max-h-[min(24rem,70vh)] overflow-y-auto overflow-x-hidden p-0",
|
||||
"rounded-xl border border-border/80 shadow-lg"
|
||||
)}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-border/80 bg-card px-3 py-2">
|
||||
<span className="text-sm font-semibold">{t("title")}</span>
|
||||
{unreadCount > 0 && !selected ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-medium text-[#0F6E56] hover:underline"
|
||||
onClick={() => void markAllRead()}
|
||||
>
|
||||
{t("markAllRead")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<NotificationDetailPanel
|
||||
item={selected}
|
||||
locale={locale}
|
||||
onBack={() => setSelected(null)}
|
||||
/>
|
||||
) : items.length === 0 ? (
|
||||
<p className="px-3 py-8 text-center text-sm text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2 p-2">
|
||||
{items.map((n) => (
|
||||
<li key={n.id}>
|
||||
<NotificationRow
|
||||
item={n}
|
||||
locale={locale}
|
||||
compact
|
||||
onSelect={(item) => void handleSelect(item)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { format } from "date-fns-jalali";
|
||||
import { enUS } from "date-fns-jalali/locale/en-US";
|
||||
import { faIR } from "date-fns-jalali/locale/fa-IR";
|
||||
import { Bell, ChefHat, UtensilsCrossed, type LucideIcon } from "lucide-react";
|
||||
import type { CafeNotification } from "@/lib/api/notifications";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function notificationIcon(type: string): LucideIcon {
|
||||
if (type === "table_call_waiter") return Bell;
|
||||
if (type.startsWith("guest_order")) return ChefHat;
|
||||
if (type.includes("table")) return UtensilsCrossed;
|
||||
return Bell;
|
||||
}
|
||||
|
||||
export function formatNotificationTime(iso: string, locale: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const jalaliLocale = locale === "en" ? enUS : faIR;
|
||||
return format(date, "d MMM yyyy — HH:mm", { locale: jalaliLocale });
|
||||
}
|
||||
|
||||
type NotificationRowProps = {
|
||||
item: CafeNotification;
|
||||
locale: string;
|
||||
onSelect: (n: CafeNotification) => void;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function NotificationRow({ item, locale, onSelect, compact }: NotificationRowProps) {
|
||||
const Icon = notificationIcon(item.type);
|
||||
const timeLabel = formatNotificationTime(item.createdAt, locale);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(item)}
|
||||
className={cn(
|
||||
"flex w-full gap-3 rounded-xl border border-border bg-card text-start shadow-sm transition active:scale-[0.98]",
|
||||
"hover:border-primary/40",
|
||||
!item.isRead && "border-primary/25 bg-[#E1F5EE]/40",
|
||||
compact ? "p-3" : "p-4"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-lg",
|
||||
compact ? "size-9" : "size-10",
|
||||
item.isRead ? "bg-muted text-muted-foreground" : "bg-[#E1F5EE] text-[#0F6E56]"
|
||||
)}
|
||||
>
|
||||
<Icon className={compact ? "size-4" : "size-5"} aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-start justify-between gap-2">
|
||||
<span className={cn("font-medium text-foreground", compact ? "text-xs" : "text-sm")}>
|
||||
{item.title}
|
||||
</span>
|
||||
{!item.isRead ? (
|
||||
<span className="mt-1 size-2 shrink-0 rounded-full bg-[#0F6E56]" aria-hidden />
|
||||
) : null}
|
||||
</span>
|
||||
{item.body && !compact ? (
|
||||
<span className="mt-1 line-clamp-2 block text-sm leading-relaxed text-muted-foreground">
|
||||
{item.body}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="mt-1.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||
{timeLabel ? <span>{timeLabel}</span> : null}
|
||||
{item.tableNumber ? (
|
||||
<span className="rounded-full border border-border/80 px-2 py-0.5">
|
||||
{item.tableNumber}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type NotificationDetailPanelProps = {
|
||||
item: CafeNotification;
|
||||
locale: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export function NotificationDetailPanel({ item, locale, onBack }: NotificationDetailPanelProps) {
|
||||
const t = useTranslations("notifications");
|
||||
const Icon = notificationIcon(item.type);
|
||||
const timeLabel = formatNotificationTime(item.createdAt, locale);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="self-start text-xs font-medium text-[#0F6E56] hover:underline"
|
||||
>
|
||||
{t("backToList")}
|
||||
</button>
|
||||
<div className="flex gap-3 rounded-xl border border-primary/20 bg-[#E1F5EE]/50 p-4">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-[#E1F5EE] text-[#0F6E56]">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
||||
{item.body ? (
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{item.body}</p>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-[11px] text-muted-foreground">
|
||||
{timeLabel ? <span>{timeLabel}</span> : null}
|
||||
{item.tableNumber ? (
|
||||
<span className="rounded-full border border-border/80 bg-card px-2 py-0.5">
|
||||
{item.tableNumber}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Bell } from "lucide-react";
|
||||
import { useNotificationsFeed } from "@/lib/hooks/use-notifications-feed";
|
||||
import { NotificationRow } from "@/components/notifications/notification-ui";
|
||||
import { numberLocaleForUi } from "@/lib/format-datetime";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FilterMode = "all" | "unread";
|
||||
|
||||
export function NotificationsScreen() {
|
||||
const t = useTranslations("notifications");
|
||||
const locale = useLocale();
|
||||
const [filter, setFilter] = useState<FilterMode>("all");
|
||||
const unreadOnly = filter === "unread";
|
||||
|
||||
const { cafeId, items, unreadCount, isLoading, isFetching, openNotification, markAllRead } =
|
||||
useNotificationsFeed({ unreadOnly });
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
const numberLocale = numberLocaleForUi(locale);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-medium text-foreground">{t("pageTitle")}</h1>
|
||||
{unreadCount > 0 ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("unreadCount", { count: unreadCount.toLocaleString(numberLocale) })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{unreadCount > 0 ? (
|
||||
<Button variant="outline" size="sm" onClick={() => void markAllRead()}>
|
||||
{t("markAllRead")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="inline-flex rounded-lg border border-border bg-card p-1"
|
||||
role="tablist"
|
||||
aria-label={t("filterLabel")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={filter === "all"}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-sm transition",
|
||||
filter === "all"
|
||||
? "bg-[#E1F5EE] font-medium text-[#0F6E56]"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setFilter("all")}
|
||||
>
|
||||
{t("filterAll")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={filter === "unread"}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-sm transition",
|
||||
filter === "unread"
|
||||
? "bg-[#E1F5EE] font-medium text-[#0F6E56]"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setFilter("unread")}
|
||||
>
|
||||
{t("filterUnread")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">{t("loading")}</p>
|
||||
) : items.length === 0 ? (
|
||||
<div className="rounded-xl border border-border bg-card px-6 py-16 text-center">
|
||||
<Bell className="mx-auto mb-3 size-10 text-muted-foreground/50" aria-hidden />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filter === "unread" ? t("emptyUnread") : t("empty")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((n) => (
|
||||
<li key={n.id}>
|
||||
<NotificationRow
|
||||
item={n}
|
||||
locale={locale}
|
||||
onSelect={(item) => void openNotification(item, { navigate: true })}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{isFetching && !isLoading ? (
|
||||
<p className="text-center text-[11px] text-muted-foreground">{t("refreshing")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
|
||||
interface ChartPoint {
|
||||
label: string;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
export function OverviewMiniChart({
|
||||
data,
|
||||
numberLocale,
|
||||
}: {
|
||||
data: ChartPoint[];
|
||||
numberLocale: string;
|
||||
}) {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 8, right: 4, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="ovRevFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.28} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: "#475569", fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: "#475569", fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={52}
|
||||
tickFormatter={(v: number) => formatNumber(v, numberLocale)}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "rgba(2,6,23,0.96)",
|
||||
border: "1px solid rgba(51,65,85,0.7)",
|
||||
borderRadius: "10px",
|
||||
color: "#e2e8f0",
|
||||
fontSize: 12,
|
||||
}}
|
||||
itemStyle={{ color: "#10b981" }}
|
||||
formatter={(v: number) => [formatNumber(v, numberLocale), ""]}
|
||||
labelStyle={{ color: "#94a3b8", marginBottom: 2 }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
fill="url(#ovRevFill)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: "#10b981", stroke: "#020617", strokeWidth: 2 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
"use client";
|
||||
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
ShoppingBag,
|
||||
CreditCard,
|
||||
Utensils,
|
||||
BarChart3,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
ChefHat,
|
||||
TableProperties,
|
||||
} from "lucide-react";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { apiGet } from "@/lib/api/client";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useLiveClock } from "@/lib/hooks/use-live-clock";
|
||||
import {
|
||||
addDaysIso,
|
||||
isoTodayTehran,
|
||||
percentChange,
|
||||
revenueChartPoints,
|
||||
sumSnapshots,
|
||||
topProductsFromRange,
|
||||
type DailyReportSnapshot,
|
||||
} from "@/lib/reports/analytics";
|
||||
import { fetchCafeTableBoard } from "@/lib/api/branch-tables";
|
||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
const LazyMiniChart = lazy(() =>
|
||||
import("@/components/overview/overview-mini-chart").then((m) => ({
|
||||
default: m.OverviewMiniChart,
|
||||
}))
|
||||
);
|
||||
|
||||
function KpiValue({
|
||||
value,
|
||||
loading,
|
||||
currency = false,
|
||||
locale = "fa-IR",
|
||||
}: {
|
||||
value: number | null;
|
||||
loading: boolean;
|
||||
currency?: boolean;
|
||||
locale?: string;
|
||||
}) {
|
||||
if (loading) return <Skeleton className="h-7 w-28 mt-1" />;
|
||||
if (value === null) return <span className="text-2xl font-bold text-muted-foreground">—</span>;
|
||||
return (
|
||||
<span className="text-2xl font-bold text-foreground tabular-nums">
|
||||
{currency ? formatCurrency(value, locale) : formatNumber(value, locale)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendBadge({ delta, label }: { delta: number | null; label: string }) {
|
||||
if (delta === null) return null;
|
||||
const up = delta > 0;
|
||||
const flat = delta === 0;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||
up
|
||||
? "bg-green-100 text-green-700"
|
||||
: flat
|
||||
? "bg-muted text-muted-foreground"
|
||||
: "bg-red-100 text-red-600"
|
||||
)}
|
||||
>
|
||||
{up ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : flat ? (
|
||||
<Minus className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
{Math.abs(delta).toFixed(1)}%
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewScreen() {
|
||||
const t = useTranslations("overview");
|
||||
const tNav = useTranslations("nav");
|
||||
const locale = useLocale();
|
||||
const rtl = locale !== "en";
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
const ArrowIcon = rtl ? ArrowLeft : ArrowRight;
|
||||
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const branchId = useAuthStore((s) => s.user?.branchId ?? null);
|
||||
const role = useAuthStore((s) => s.user?.role);
|
||||
|
||||
const clock = useLiveClock(10_000);
|
||||
|
||||
const today = useMemo(() => isoTodayTehran(), []);
|
||||
const sevenDaysAgo = useMemo(() => addDaysIso(today, -6), [today]);
|
||||
const yesterday = useMemo(() => addDaysIso(today, -1), [today]);
|
||||
|
||||
const timeStr = clock.toLocaleTimeString(
|
||||
locale === "fa" ? "fa-IR" : locale === "ar" ? "ar-SA" : "en-US",
|
||||
{ hour: "2-digit", minute: "2-digit", timeZone: "Asia/Tehran" }
|
||||
);
|
||||
const dateStr = clock.toLocaleDateString(
|
||||
locale === "fa" ? "fa-IR-u-ca-persian" : locale === "ar" ? "ar-SA" : "en-GB",
|
||||
{ weekday: "long", year: "numeric", month: "long", day: "numeric", timeZone: "Asia/Tehran" }
|
||||
);
|
||||
|
||||
const { data: weekSnapshots = [], isLoading } = useQuery({
|
||||
queryKey: ["overview-week", cafeId, today],
|
||||
queryFn: () =>
|
||||
apiGet<DailyReportSnapshot[]>(
|
||||
`/api/cafes/${cafeId}/reports/daily/range?from=${sevenDaysAgo}&to=${today}`
|
||||
),
|
||||
enabled: !!cafeId,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: prevSnapshots = [] } = useQuery({
|
||||
queryKey: ["overview-yesterday", cafeId, yesterday],
|
||||
queryFn: () =>
|
||||
apiGet<DailyReportSnapshot[]>(
|
||||
`/api/cafes/${cafeId}/reports/daily/range?from=${yesterday}&to=${yesterday}`
|
||||
),
|
||||
enabled: !!cafeId,
|
||||
staleTime: 300_000,
|
||||
});
|
||||
|
||||
const { data: tables = [] } = useQuery({
|
||||
queryKey: ["overview-tables", cafeId, branchId],
|
||||
queryFn: () => fetchCafeTableBoard(cafeId!, branchId),
|
||||
enabled: !!cafeId,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const todaySnapshot = useMemo(
|
||||
() => weekSnapshots.find((s) => s.date === today) ?? null,
|
||||
[weekSnapshots, today]
|
||||
);
|
||||
const todayTotals = useMemo(
|
||||
() => (todaySnapshot ? sumSnapshots([todaySnapshot]) : null),
|
||||
[todaySnapshot]
|
||||
);
|
||||
const yesterdayTotals = useMemo(
|
||||
() => (prevSnapshots.length > 0 ? sumSnapshots(prevSnapshots) : null),
|
||||
[prevSnapshots]
|
||||
);
|
||||
|
||||
const revenueDelta = useMemo(
|
||||
() => todayTotals && yesterdayTotals
|
||||
? percentChange(todayTotals.totalRevenue, yesterdayTotals.totalRevenue)
|
||||
: null,
|
||||
[todayTotals, yesterdayTotals]
|
||||
);
|
||||
const ordersDelta = useMemo(
|
||||
() => todayTotals && yesterdayTotals
|
||||
? percentChange(todayTotals.totalOrders, yesterdayTotals.totalOrders)
|
||||
: null,
|
||||
[todayTotals, yesterdayTotals]
|
||||
);
|
||||
const netIncomeDelta = useMemo(
|
||||
() => todayTotals && yesterdayTotals
|
||||
? percentChange(todayTotals.netIncome, yesterdayTotals.netIncome)
|
||||
: null,
|
||||
[todayTotals, yesterdayTotals]
|
||||
);
|
||||
|
||||
const chartPoints = useMemo(
|
||||
() => revenueChartPoints(weekSnapshots, locale, rtl),
|
||||
[weekSnapshots, locale, rtl]
|
||||
);
|
||||
|
||||
const topProducts = useMemo(() => topProductsFromRange(weekSnapshots, 5), [weekSnapshots]);
|
||||
|
||||
const tableStats = useMemo(() => {
|
||||
const active = tables.filter((tb) => tb.isActive);
|
||||
return {
|
||||
free: active.filter((tb) => tb.status === "Free").length,
|
||||
busy: active.filter((tb) => tb.status === "Busy").length,
|
||||
cleaning: active.filter((tb) => tb.status === "Cleaning").length,
|
||||
total: active.length,
|
||||
};
|
||||
}, [tables]);
|
||||
|
||||
const quickLinks = [
|
||||
{ key: "pos", href: "/pos", icon: CreditCard, labelKey: "pos" },
|
||||
{ key: "tables", href: "/tables", icon: TableProperties, labelKey: "tables" },
|
||||
{ key: "reports", href: "/reports", icon: BarChart3, labelKey: "reports" },
|
||||
{ key: "kds", href: "/kds", icon: ChefHat, labelKey: "kds" },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 auto-rows-auto">
|
||||
|
||||
{/* Welcome + Clock */}
|
||||
<Card className="col-span-2 p-5 flex flex-col bg-primary/5 border-primary/20">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-primary/70 mb-1">
|
||||
{t("greeting")}
|
||||
{role && <span className="ms-1.5 text-muted-foreground normal-case tracking-normal font-normal">· {role}</span>}
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-foreground tabular-nums leading-none">{timeStr}</p>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground truncate">{dateStr}</p>
|
||||
<div className="mt-auto pt-4">
|
||||
<span className="text-[11px] text-muted-foreground/50">Meezi</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Revenue KPI */}
|
||||
<Card className="col-span-1 p-5 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("todayRevenue")}
|
||||
</span>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10">
|
||||
<CreditCard className="h-3.5 w-3.5 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<KpiValue value={todayTotals?.totalRevenue ?? null} loading={isLoading} currency locale={numberLocale} />
|
||||
<div className="mt-2">
|
||||
<TrendBadge delta={revenueDelta} label={t("vsYesterday")} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Orders KPI */}
|
||||
<Card className="col-span-1 p-5 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("todayOrders")}
|
||||
</span>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-100">
|
||||
<ShoppingBag className="h-3.5 w-3.5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<KpiValue value={todayTotals?.totalOrders ?? null} loading={isLoading} locale={numberLocale} />
|
||||
<div className="mt-2">
|
||||
<TrendBadge delta={ordersDelta} label={t("vsYesterday")} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 7-day Chart (3×2) */}
|
||||
<Card className="col-span-2 lg:col-span-3 lg:row-span-2 p-5 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">{t("revenueChart")}</h3>
|
||||
<Link
|
||||
href="/reports"
|
||||
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{tNav("reports")}
|
||||
<ArrowIcon className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1 min-h-[180px] lg:min-h-[220px]">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-full w-full" />
|
||||
) : chartPoints.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">{t("noData")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Suspense fallback={<Skeleton className="h-full w-full" />}>
|
||||
<LazyMiniChart data={chartPoints} numberLocale={numberLocale} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Table Status (1×2) */}
|
||||
<Card className="col-span-2 lg:col-span-1 lg:row-span-2 p-5 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">{t("tableStatus")}</h3>
|
||||
<Link href="/tables" className="text-muted-foreground hover:text-primary transition-colors">
|
||||
<TableProperties className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-4">
|
||||
{/* Occupancy ring */}
|
||||
<div className="relative flex h-24 w-24 items-center justify-center">
|
||||
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 36 36">
|
||||
<circle cx="18" cy="18" r="15.5" fill="none" stroke="hsl(var(--muted))" strokeWidth="3" />
|
||||
{tableStats.total > 0 && (
|
||||
<circle
|
||||
cx="18" cy="18" r="15.5" fill="none"
|
||||
stroke="hsl(var(--primary))" strokeWidth="3" strokeLinecap="round"
|
||||
strokeDasharray={`${(tableStats.busy / tableStats.total) * 97.4} 97.4`}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-foreground tabular-nums leading-none">{tableStats.busy}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">/ {tableStats.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2.5">
|
||||
{[
|
||||
{ label: t("tableFree"), count: tableStats.free, cls: "bg-green-500", txt: "text-green-600" },
|
||||
{ label: t("tableBusy"), count: tableStats.busy, cls: "bg-amber-500", txt: "text-amber-600" },
|
||||
{ label: t("tableCleaning"), count: tableStats.cleaning, cls: "bg-blue-500", txt: "text-blue-600" },
|
||||
].map(({ label, count, cls, txt }) => (
|
||||
<div key={label} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("h-2 w-2 rounded-full", cls)} />
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<span className={cn("text-sm font-semibold tabular-nums", txt)}>
|
||||
{formatNumber(count, numberLocale)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Top Products (2 cols) */}
|
||||
<Card className="col-span-2 p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">{t("topProducts")}</h3>
|
||||
<Utensils className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2.5">{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-5 w-full" />)}</div>
|
||||
) : topProducts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("noData")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topProducts.map((p, idx) => {
|
||||
const maxRevenue = topProducts[0]?.revenue ?? 1;
|
||||
const pct = Math.round((p.revenue / maxRevenue) * 100);
|
||||
return (
|
||||
<div key={p.productId} className="flex items-center gap-3">
|
||||
<span className="w-4 shrink-0 text-[11px] font-medium text-muted-foreground tabular-nums text-end">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-foreground truncate">{p.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums shrink-0 ms-2">
|
||||
{formatNumber(p.quantity, numberLocale)} {t("unit")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary/60 transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
||||
{formatCurrency(p.revenue, numberLocale)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Net Income KPI */}
|
||||
<Card className="col-span-1 p-5 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("netIncome")}
|
||||
</span>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-purple-100">
|
||||
<BarChart3 className="h-3.5 w-3.5 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
<KpiValue value={todayTotals?.netIncome ?? null} loading={isLoading} currency locale={numberLocale} />
|
||||
<div className="mt-2">
|
||||
<TrendBadge delta={netIncomeDelta} label={t("vsYesterday")} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick Links */}
|
||||
<Card className="col-span-1 p-5 flex flex-col">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
{t("quickLinks")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 flex-1">
|
||||
{quickLinks.map(({ key, href, icon: Icon, labelKey }) => (
|
||||
<Link
|
||||
key={key}
|
||||
href={href}
|
||||
className="flex flex-col items-center justify-center gap-1.5 rounded-xl bg-muted/50 border border-border p-2.5 text-center transition-colors hover:bg-accent hover:text-accent-foreground group"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
<span className="text-[10px] text-muted-foreground group-hover:text-primary transition-colors leading-tight">
|
||||
{tNav(labelKey)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function PlaceholderPage({ titleKey }: { titleKey: string }) {
|
||||
const tNav = useTranslations("nav");
|
||||
const tCommon = useTranslations("common");
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
|
||||
<p className="text-muted-foreground">
|
||||
{tNav(titleKey as "crm")} — {tCommon("comingSoon")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronDown, Search, UserPlus, X } from "lucide-react";
|
||||
import { apiGet, apiPost, ApiClientError } from "@/lib/api/client";
|
||||
import type { Customer } from "@/lib/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CustomerMode = "existing" | "new";
|
||||
|
||||
type PosCustomerPickerProps = {
|
||||
cafeId: string;
|
||||
guestName: string;
|
||||
guestPhone: string;
|
||||
customerId: string | null;
|
||||
onGuestNameChange: (value: string) => void;
|
||||
onGuestPhoneChange: (value: string) => void;
|
||||
onCustomerChange: (customer: Customer | null) => void;
|
||||
onClearCustomer: () => void;
|
||||
/** Collapsed header in POS sidebar to leave room for cart lines */
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function PosCustomerPicker({
|
||||
cafeId,
|
||||
guestName,
|
||||
guestPhone,
|
||||
customerId,
|
||||
onGuestNameChange,
|
||||
onGuestPhoneChange,
|
||||
onCustomerChange,
|
||||
onClearCustomer,
|
||||
compact = false,
|
||||
}: PosCustomerPickerProps) {
|
||||
const t = useTranslations("pos");
|
||||
const tCrm = useTranslations("crm");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
const [expanded, setExpanded] = useState(!compact);
|
||||
const [mode, setMode] = useState<CustomerMode>(customerId ? "existing" : "new");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [search]);
|
||||
|
||||
const pickCustomer = (c: Customer) => {
|
||||
onCustomerChange(c);
|
||||
setSearch("");
|
||||
setMessage(null);
|
||||
setHighlightIndex(-1);
|
||||
};
|
||||
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (results.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((i) => (i < results.length - 1 ? i + 1 : 0));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((i) => (i > 0 ? i - 1 : results.length - 1));
|
||||
} else if (e.key === "Enter" && highlightIndex >= 0) {
|
||||
e.preventDefault();
|
||||
pickCustomer(results[highlightIndex]!);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (customerId) setMode("existing");
|
||||
}, [customerId]);
|
||||
|
||||
const { data: results = [], isFetching } = useQuery({
|
||||
queryKey: ["customers", cafeId, debouncedSearch],
|
||||
queryFn: () =>
|
||||
apiGet<Customer[]>(
|
||||
`/api/cafes/${cafeId}/customers?q=${encodeURIComponent(debouncedSearch)}`
|
||||
),
|
||||
enabled: !!cafeId && mode === "existing" && debouncedSearch.length >= 2,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightIndex(-1);
|
||||
}, [debouncedSearch, results.length]);
|
||||
|
||||
const createCustomer = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<Customer>(`/api/cafes/${cafeId}/customers`, {
|
||||
name: guestName.trim(),
|
||||
phone: guestPhone.trim(),
|
||||
group: "New",
|
||||
}),
|
||||
onSuccess: (customer) => {
|
||||
onCustomerChange(customer);
|
||||
setMode("existing");
|
||||
setMessage(t("customerSaved"));
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
if (err instanceof ApiClientError && err.code === "DUPLICATE_PHONE") {
|
||||
setMessage(t("customerPhoneExists"));
|
||||
return;
|
||||
}
|
||||
setMessage(t("customerSaveError"));
|
||||
},
|
||||
});
|
||||
|
||||
const switchMode = (next: CustomerMode) => {
|
||||
setMode(next);
|
||||
setMessage(null);
|
||||
if (next === "new") {
|
||||
onClearCustomer();
|
||||
}
|
||||
};
|
||||
|
||||
const canSaveNew =
|
||||
guestName.trim().length > 0 &&
|
||||
/^09\d{9}$/.test(guestPhone.trim()) &&
|
||||
!customerId;
|
||||
|
||||
const summaryLabel =
|
||||
guestName.trim() || guestPhone.trim()
|
||||
? `${guestName.trim() || "—"}${guestPhone.trim() ? ` · ${guestPhone.trim()}` : ""}`
|
||||
: t("customerSection");
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", compact && !expanded && "space-y-1")}>
|
||||
{compact ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-2 rounded-md border border-border/80 bg-muted/30 px-2 py-1.5 text-start"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<span className="min-w-0 truncate text-xs font-medium">{summaryLabel}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
expanded && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("customerSection")}</p>
|
||||
)}
|
||||
|
||||
{compact && !expanded ? null : (
|
||||
<>
|
||||
<div className="flex gap-1 rounded-lg border border-border p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition",
|
||||
mode === "existing"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted"
|
||||
)}
|
||||
onClick={() => switchMode("existing")}
|
||||
>
|
||||
{t("existingCustomer")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition",
|
||||
mode === "new"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted"
|
||||
)}
|
||||
onClick={() => switchMode("new")}
|
||||
>
|
||||
{t("newCustomer")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{customerId ? (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-[#0F6E56]/30 bg-[#E1F5EE] px-2 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-[#0F6E56]">{guestName}</p>
|
||||
<p className="truncate text-xs text-muted-foreground" dir="ltr">
|
||||
{guestPhone}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => {
|
||||
onClearCustomer();
|
||||
onGuestNameChange("");
|
||||
onGuestPhoneChange("");
|
||||
setMode("new");
|
||||
}}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mode === "existing" && !customerId ? (
|
||||
<div className="space-y-2">
|
||||
<LabeledField label={tCommon("search")} htmlFor="pos-customer-search">
|
||||
<div className="relative">
|
||||
<Search className="absolute start-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="pos-customer-search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder={t("customerSearchPlaceholder")}
|
||||
className={cn("ps-8", compact && "h-8 text-sm")}
|
||||
role="combobox"
|
||||
aria-expanded={results.length > 0 && debouncedSearch.length >= 2}
|
||||
aria-activedescendant={
|
||||
highlightIndex >= 0 ? `pos-customer-opt-${highlightIndex}` : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</LabeledField>
|
||||
{debouncedSearch.length < 2 ? (
|
||||
<p className="text-[11px] text-muted-foreground">{t("customerSearchHint")}</p>
|
||||
) : isFetching ? (
|
||||
<p className="text-[11px] text-muted-foreground">{tCommon("loading")}</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">{t("customerNotFound")}</p>
|
||||
) : (
|
||||
<ul
|
||||
className={cn(
|
||||
"space-y-1 overflow-y-auto overscroll-contain",
|
||||
compact ? "max-h-24" : "max-h-32"
|
||||
)}
|
||||
>
|
||||
{results.map((c, idx) => (
|
||||
<li key={c.id} id={`pos-customer-opt-${idx}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-md border px-2 py-1.5 text-start text-sm transition",
|
||||
idx === highlightIndex
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border/80 hover:border-primary hover:bg-muted/40"
|
||||
)}
|
||||
onClick={() => pickCustomer(c)}
|
||||
>
|
||||
<span className="min-w-0 truncate font-medium">{c.name}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground" dir="ltr">
|
||||
{c.phone}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mode === "new" && !customerId ? (
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (canSaveNew && !createCustomer.isPending) createCustomer.mutate();
|
||||
}}
|
||||
>
|
||||
<LabeledField label={t("guestName")} htmlFor="pos-guest">
|
||||
<Input
|
||||
id="pos-guest"
|
||||
value={guestName}
|
||||
onChange={(e) => onGuestNameChange(e.target.value)}
|
||||
placeholder={t("guestNamePlaceholder")}
|
||||
className={compact ? "h-8 text-sm" : undefined}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("guestPhone")} htmlFor="pos-phone">
|
||||
<Input
|
||||
id="pos-phone"
|
||||
value={guestPhone}
|
||||
onChange={(e) => onGuestPhoneChange(e.target.value)}
|
||||
placeholder={t("guestPhonePlaceholder")}
|
||||
dir="ltr"
|
||||
className={cn("text-end", compact && "h-8 text-sm")}
|
||||
/>
|
||||
</LabeledField>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={!canSaveNew || createCustomer.isPending}
|
||||
>
|
||||
<UserPlus className="me-1.5 h-3.5 w-3.5" />
|
||||
{createCustomer.isPending ? "..." : tCrm("addCustomer")}
|
||||
</Button>
|
||||
{!compact ? (
|
||||
<p className="text-[10px] text-muted-foreground">{t("newCustomerHint")}</p>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<p className="text-center text-xs text-primary">{message}</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import * as signalR from "@microsoft/signalr";
|
||||
import { apiGet, apiPatch, apiPost, ApiClientError } from "@/lib/api/client";
|
||||
import { printErrorMessage, printReceipt } from "@/lib/api/print";
|
||||
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
|
||||
import { PosSlipModal } from "@/components/pos/pos-slip-modal";
|
||||
import type { Customer, Order, Table, TableBoardItem } from "@/lib/api/types";
|
||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||
import { formatPosOrderLabel } from "@/lib/pos-order-label";
|
||||
import { formatOrderNumber } from "@/lib/order-number";
|
||||
import { PosTableBoard } from "@/components/pos/pos-table-board";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCafeSettings } from "@/lib/hooks/use-cafe-settings";
|
||||
import { confirmPayLabel } from "@/lib/pos-confirm-pay-label";
|
||||
import { useConfirm } from "@/components/providers/confirm-provider";
|
||||
|
||||
type PaymentRow = {
|
||||
method: "Cash" | "Card" | "Credit";
|
||||
amount: string;
|
||||
};
|
||||
|
||||
type PosPayPanelProps = {
|
||||
cafeId: string;
|
||||
numberLocale: string;
|
||||
branchId?: string | null;
|
||||
};
|
||||
|
||||
export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPanelProps) {
|
||||
const t = useTranslations("pos");
|
||||
const tPrint = useTranslations("print");
|
||||
const tDashboard = useTranslations("dashboard");
|
||||
const queryClient = useQueryClient();
|
||||
const confirmDialog = useConfirm();
|
||||
const { data: cafeSettings } = useCafeSettings(cafeId);
|
||||
const cafeName = cafeSettings?.name ?? tDashboard("cafeName");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectedTableId, setSelectedTableId] = useState<string | null>(null);
|
||||
const [filterTableId, setFilterTableId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [payMessage, setPayMessage] = useState<string | null>(null);
|
||||
const [receiptOrder, setReceiptOrder] = useState<Order | null>(null);
|
||||
const [lastPaidOrderId, setLastPaidOrderId] = useState<string | null>(null);
|
||||
const [paymentRows, setPaymentRows] = useState<PaymentRow[]>([
|
||||
{ method: "Cash", amount: "" },
|
||||
]);
|
||||
const [loyaltyRedeem, setLoyaltyRedeem] = useState(0);
|
||||
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5080";
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [search]);
|
||||
|
||||
const { data: openOrders = [], isLoading } = useQuery({
|
||||
queryKey: ["orders-open", cafeId, debouncedSearch],
|
||||
queryFn: () => {
|
||||
const qs = debouncedSearch
|
||||
? `?search=${encodeURIComponent(debouncedSearch)}`
|
||||
: "";
|
||||
return apiGet<Order[]>(`/api/cafes/${cafeId}/orders/open${qs}`);
|
||||
},
|
||||
enabled: !!cafeId,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const { data: tables = [] } = useQuery({
|
||||
queryKey: ["tables", cafeId],
|
||||
queryFn: () => apiGet<Table[]>(`/api/cafes/${cafeId}/tables`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const displayedOrders = useMemo(() => {
|
||||
if (!filterTableId) return openOrders;
|
||||
return openOrders.filter((o) => o.tableId === filterTableId);
|
||||
}, [openOrders, filterTableId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cafeId) return;
|
||||
const token =
|
||||
typeof window !== "undefined" ? localStorage.getItem("meezi_access_token") : null;
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(`${apiBase}/hubs/kds`, { accessTokenFactory: () => token ?? "" })
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
connection
|
||||
.start()
|
||||
.then(() => connection.invoke("JoinCafe", cafeId))
|
||||
.catch(() => undefined);
|
||||
const refresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
|
||||
};
|
||||
connection.on("TableStatusChanged", refresh);
|
||||
connection.on("OrderStatusChanged", refresh);
|
||||
return () => {
|
||||
void connection.stop();
|
||||
};
|
||||
}, [cafeId, apiBase, queryClient]);
|
||||
|
||||
const selectOrder = (order: Order, tableId?: string | null) => {
|
||||
setSelectedId(order.id);
|
||||
setSelectedTableId(tableId ?? order.tableId ?? null);
|
||||
setPayMessage(null);
|
||||
};
|
||||
|
||||
const handleTableSelect = (table: TableBoardItem, activeOrder: Order | null) => {
|
||||
setFilterTableId(table.id);
|
||||
setSelectedTableId(table.id);
|
||||
if (activeOrder) {
|
||||
selectOrder(activeOrder, table.id);
|
||||
return;
|
||||
}
|
||||
setSelectedId(null);
|
||||
setPayMessage(t("noOrderOnTable"));
|
||||
};
|
||||
|
||||
const selected = openOrders.find((o) => o.id === selectedId) ?? null;
|
||||
|
||||
const remaining = useMemo(() => {
|
||||
if (!selected) return 0;
|
||||
return Math.max(0, selected.total - (selected.paidAmount ?? 0));
|
||||
}, [selected]);
|
||||
|
||||
const { data: payCustomer } = useQuery({
|
||||
queryKey: ["customer", cafeId, selected?.customerId],
|
||||
queryFn: () =>
|
||||
apiGet<Customer>(`/api/cafes/${cafeId}/customers/${selected!.customerId}`),
|
||||
enabled: !!cafeId && !!selected?.customerId,
|
||||
});
|
||||
|
||||
const maxLoyaltyRedeem = useMemo(() => {
|
||||
if (!payCustomer || !selected) return 0;
|
||||
const byDue = Math.floor(remaining / 100);
|
||||
return Math.min(payCustomer.loyaltyPoints, byDue);
|
||||
}, [payCustomer, selected, remaining]);
|
||||
|
||||
const loyaltyDiscount = loyaltyRedeem * 100;
|
||||
const effectiveRemaining = Math.max(0, remaining - loyaltyDiscount);
|
||||
|
||||
useEffect(() => {
|
||||
setLoyaltyRedeem(0);
|
||||
}, [selected?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
setPaymentRows([{ method: "Cash", amount: String(effectiveRemaining) }]);
|
||||
}, [selected?.id, selected?.total, selected?.paidAmount, effectiveRemaining]);
|
||||
|
||||
const payOrder = useMutation({
|
||||
mutationFn: async (order: Order) => {
|
||||
const payments = paymentRows
|
||||
.map((row) => ({
|
||||
method: row.method,
|
||||
amount: parseFloat(row.amount.replace(/,/g, "")) || 0,
|
||||
}))
|
||||
.filter((p) => p.amount > 0);
|
||||
|
||||
if (payments.length === 0) throw new Error("no payments");
|
||||
|
||||
const cardTotal = payments
|
||||
.filter((p) => p.method === "Card")
|
||||
.reduce((s, p) => s + p.amount, 0);
|
||||
|
||||
const payBranchId = order.branchId ?? branchId;
|
||||
if (cardTotal > 0 && payBranchId) {
|
||||
await requestPosPayment(cafeId, payBranchId, order.id, cardTotal);
|
||||
}
|
||||
|
||||
return apiPost(`/api/cafes/${cafeId}/orders/${order.id}/payments`, {
|
||||
payments,
|
||||
loyaltyPointsToRedeem: loyaltyRedeem > 0 ? loyaltyRedeem : undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: async (_data, order) => {
|
||||
setPayMessage(t("paySuccess"));
|
||||
setLastPaidOrderId(order.id);
|
||||
try {
|
||||
const paid = await apiGet<Order>(`/api/cafes/${cafeId}/orders/${order!.id}`);
|
||||
setReceiptOrder(paid);
|
||||
} catch {
|
||||
setReceiptOrder(order ?? null);
|
||||
}
|
||||
setSelectedId(null);
|
||||
setSelectedTableId(null);
|
||||
setFilterTableId(null);
|
||||
setSearch("");
|
||||
setPaymentRows([{ method: "Cash", amount: "" }]);
|
||||
setLoyaltyRedeem(0);
|
||||
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["customer", cafeId] });
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ApiClientError) {
|
||||
if (err.code.startsWith("POS_DEVICE")) {
|
||||
setPayMessage(posDeviceErrorMessage(err, t));
|
||||
return;
|
||||
}
|
||||
if (err.code === "NO_OPEN_SHIFT") {
|
||||
setPayMessage(t("payNeedsOpenShift"));
|
||||
return;
|
||||
}
|
||||
if (err.code === "LOYALTY_NO_CUSTOMER") {
|
||||
setPayMessage(t("loyaltyNoCustomer"));
|
||||
return;
|
||||
}
|
||||
if (err.code === "LOYALTY_INSUFFICIENT_POINTS") {
|
||||
setPayMessage(t("loyaltyInsufficient"));
|
||||
return;
|
||||
}
|
||||
setPayMessage(err.message || t("payError"));
|
||||
return;
|
||||
}
|
||||
setPayMessage(t("payError"));
|
||||
},
|
||||
});
|
||||
|
||||
const cancelOrder = useMutation({
|
||||
mutationFn: (orderId: string) =>
|
||||
apiPatch(`/api/cafes/${cafeId}/orders/${orderId}/status`, {
|
||||
status: "Cancelled",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setPayMessage(t("cancelOrderSuccess"));
|
||||
setSelectedId(null);
|
||||
setSelectedTableId(null);
|
||||
setFilterTableId(null);
|
||||
setPaymentRows([{ method: "Cash", amount: "" }]);
|
||||
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
|
||||
},
|
||||
onError: (err) => {
|
||||
setPayMessage(
|
||||
err instanceof ApiClientError ? err.message : t("cancelOrderError")
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const paymentSum = paymentRows.reduce(
|
||||
(s, row) => s + (parseFloat(row.amount.replace(/,/g, "")) || 0),
|
||||
0
|
||||
);
|
||||
const canPay =
|
||||
selected && paymentSum > 0 && paymentSum <= effectiveRemaining + 0.01;
|
||||
|
||||
const payButtonLabel = confirmPayLabel(paymentRows, t);
|
||||
|
||||
const thermalPrint = useMutation({
|
||||
mutationFn: (orderId: string) => printReceipt(cafeId, orderId),
|
||||
onSuccess: () => setPayMessage(tPrint("success")),
|
||||
onError: (err) => setPayMessage(printErrorMessage(err, tPrint)),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full gap-4 overflow-hidden">
|
||||
<Card className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<CardHeader className="shrink-0 space-y-3 pb-2">
|
||||
<CardTitle className="text-base">{t("openOrders")}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{t("payOpenOrdersHint")}</p>
|
||||
|
||||
<PosTableBoard
|
||||
cafeId={cafeId}
|
||||
numberLocale={numberLocale}
|
||||
branchId={branchId}
|
||||
mode="pay"
|
||||
selectedTableId={selectedTableId}
|
||||
selectedOrderId={selectedId}
|
||||
onSelectTable={handleTableSelect}
|
||||
/>
|
||||
|
||||
<LabeledField label={t("selectTable")} htmlFor="pay-table-filter">
|
||||
<select
|
||||
id="pay-table-filter"
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={filterTableId ?? ""}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value || null;
|
||||
setFilterTableId(id);
|
||||
setSelectedTableId(id);
|
||||
if (!id) {
|
||||
setSelectedId(null);
|
||||
setPayMessage(null);
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const order = await apiGet<Order>(
|
||||
`/api/cafes/${cafeId}/tables/${id}/active-order`
|
||||
);
|
||||
selectOrder(order, id);
|
||||
} catch {
|
||||
const match = openOrders.find((o) => o.tableId === id);
|
||||
if (match) selectOrder(match, id);
|
||||
else {
|
||||
setSelectedId(null);
|
||||
setPayMessage(t("noOrderOnTable"));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<option value="">{t("allTables")}</option>
|
||||
{tables?.map((tbl) => (
|
||||
<option key={tbl.id} value={tbl.id}>
|
||||
{t("table")} {tbl.number}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t("payPickByName")}
|
||||
</p>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("searchOpenOrder")}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pt-0">
|
||||
{payMessage && !selected ? (
|
||||
<p className="mb-2 text-center text-sm text-amber-700">{payMessage}</p>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">...</p>
|
||||
) : displayedOrders.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filterTableId ? t("noOpenOrdersOnTable") : t("noOpenOrders")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{displayedOrders.map((order) => {
|
||||
const label = formatPosOrderLabel(order, t("table"));
|
||||
const isSelected = selectedId === order.id;
|
||||
const guestLine =
|
||||
order.guestName?.trim() ||
|
||||
order.customerName?.trim() ||
|
||||
order.guestPhone ||
|
||||
order.customerPhone;
|
||||
return (
|
||||
<li key={order.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectOrder(order)}
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-1 rounded-lg border border-border bg-card px-4 py-3 text-start shadow-sm transition hover:border-primary",
|
||||
isSelected && "border-primary ring-1 ring-primary/30"
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{label}</p>
|
||||
{guestLine ? (
|
||||
<p className="truncate text-xs text-[#0C447C]">
|
||||
{guestLine}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatNumber(order.items.length, numberLocale)}{" "}
|
||||
{t("itemsCount")} · {formatOrderNumber(order)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-sm font-bold text-primary">
|
||||
{formatCurrency(order.total, numberLocale)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="flex h-full min-h-0 w-[min(100%,20rem)] shrink-0 flex-col overflow-hidden sm:w-72 lg:w-80">
|
||||
<CardHeader className="shrink-0 space-y-2 pb-2">
|
||||
<CardTitle className="text-lg">{t("payOrder")}</CardTitle>
|
||||
{selected ? (
|
||||
<div className="rounded-lg border border-[#0F6E56]/30 bg-[#E1F5EE] px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">{t("payFor")}</p>
|
||||
<p className="text-base font-semibold text-[#0F6E56]">
|
||||
{formatPosOrderLabel(selected, t("table"))}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("selectOrderToPay")}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden pt-2">
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto overscroll-contain">
|
||||
{selected.items.map((line) => (
|
||||
<div
|
||||
key={line.id}
|
||||
className="flex justify-between gap-2 rounded-md border border-border/60 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
{line.menuItemName} × {formatNumber(line.quantity, numberLocale)}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{formatCurrency(line.unitPrice * line.quantity, numberLocale)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="shrink-0 space-y-2 border-t border-border pt-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{t("subtotal")}</span>
|
||||
<span>{formatCurrency(selected.subtotal, numberLocale)}</span>
|
||||
</div>
|
||||
{selected.discountAmount > 0 ? (
|
||||
<div className="flex justify-between text-sm text-[#0F6E56]">
|
||||
<span>{t("discount")}</span>
|
||||
<span>-{formatCurrency(selected.discountAmount, numberLocale)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{t("tax")}</span>
|
||||
<span>{formatCurrency(selected.taxTotal, numberLocale)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-bold">
|
||||
<span>{t("total")}</span>
|
||||
<span>{formatCurrency(selected.total, numberLocale)}</span>
|
||||
</div>
|
||||
{(selected.paidAmount ?? 0) > 0 ? (
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{t("paidSoFar")}</span>
|
||||
<span>{formatCurrency(selected.paidAmount, numberLocale)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-between text-sm font-semibold text-primary">
|
||||
<span>{t("remaining")}</span>
|
||||
<span>{formatCurrency(effectiveRemaining, numberLocale)}</span>
|
||||
</div>
|
||||
{loyaltyDiscount > 0 ? (
|
||||
<div className="flex justify-between text-sm text-[#0F6E56]">
|
||||
<span>{t("loyaltyRedeemApplied")}</span>
|
||||
<span>-{formatCurrency(loyaltyDiscount, numberLocale)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selected.customerId && payCustomer ? (
|
||||
<div className="space-y-2 rounded-lg border border-[#0F6E56]/25 bg-[#E1F5EE]/50 p-2">
|
||||
<p className="text-xs font-medium text-[#0F6E56]">
|
||||
{t("loyaltyBalance", {
|
||||
points: formatNumber(payCustomer.loyaltyPoints, numberLocale),
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={maxLoyaltyRedeem}
|
||||
value={loyaltyRedeem || ""}
|
||||
onChange={(e) => {
|
||||
const n = Math.min(
|
||||
maxLoyaltyRedeem,
|
||||
Math.max(0, parseInt(e.target.value, 10) || 0)
|
||||
);
|
||||
setLoyaltyRedeem(n);
|
||||
}}
|
||||
className="h-8 w-24 tabular-nums"
|
||||
disabled={maxLoyaltyRedeem === 0}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
disabled={maxLoyaltyRedeem === 0}
|
||||
onClick={() => setLoyaltyRedeem(maxLoyaltyRedeem)}
|
||||
>
|
||||
{t("loyaltyUseMax")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t("loyaltyRedeemHint")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2 pt-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t("splitPayments")}
|
||||
</p>
|
||||
{paymentRows.map((row, idx) => (
|
||||
<div key={idx} className="flex gap-2">
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
value={row.method}
|
||||
onChange={(e) => {
|
||||
const method = e.target.value as PaymentRow["method"];
|
||||
setPaymentRows((rows) =>
|
||||
rows.map((r, i) => (i === idx ? { ...r, method } : r))
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="Cash">{t("cash")}</option>
|
||||
<option value="Card">{t("card")}</option>
|
||||
<option value="Credit">{t("credit")}</option>
|
||||
</select>
|
||||
<Input
|
||||
dir="ltr"
|
||||
className="h-9 flex-1 text-end tabular-nums"
|
||||
value={row.amount}
|
||||
onChange={(e) => {
|
||||
const amount = e.target.value;
|
||||
setPaymentRows((rows) =>
|
||||
rows.map((r, i) => (i === idx ? { ...r, amount } : r))
|
||||
);
|
||||
}}
|
||||
placeholder="0"
|
||||
/>
|
||||
{paymentRows.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setPaymentRows((rows) => rows.filter((_, i) => i !== idx))
|
||||
}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
setPaymentRows((rows) => [
|
||||
...rows,
|
||||
{ method: "Card", amount: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
{t("addPaymentRow")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{payMessage ? (
|
||||
<p className="text-center text-sm text-primary">{payMessage}</p>
|
||||
) : null}
|
||||
{lastPaidOrderId ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={thermalPrint.isPending}
|
||||
onClick={() => thermalPrint.mutate(lastPaidOrderId)}
|
||||
>
|
||||
{thermalPrint.isPending ? "..." : tPrint("printReceipt")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setReceiptOrder(selected)}
|
||||
>
|
||||
{t("previewBill")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full border-[#A32D2D]/40 text-[#A32D2D] hover:bg-red-50"
|
||||
disabled={cancelOrder.isPending}
|
||||
onClick={async () => {
|
||||
if (!selected) return;
|
||||
const ok = await confirmDialog({
|
||||
description: t("cancelOrderConfirm"),
|
||||
variant: "destructive",
|
||||
confirmLabel: t("cancelOrder"),
|
||||
});
|
||||
if (!ok) return;
|
||||
cancelOrder.mutate(selected.id);
|
||||
}}
|
||||
>
|
||||
{cancelOrder.isPending ? "..." : t("cancelOrder")}
|
||||
</Button>
|
||||
<form
|
||||
className="w-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (canPay && selected && !payOrder.isPending) {
|
||||
payOrder.mutate(selected);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!canPay || payOrder.isPending}
|
||||
>
|
||||
{payOrder.isPending ? "..." : payButtonLabel}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{receiptOrder ? (
|
||||
<PosSlipModal
|
||||
variant="bill"
|
||||
order={receiptOrder}
|
||||
cafeName={cafeName}
|
||||
onClose={() => setReceiptOrder(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { apiGet, apiPost } from "@/lib/api/client";
|
||||
import type { QueueBoard } from "@/lib/api/types";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Link } from "@/i18n/routing";
|
||||
|
||||
type PosQueueBarProps = {
|
||||
cafeId: string;
|
||||
branchId: string | null;
|
||||
};
|
||||
|
||||
export function PosQueueBar({ cafeId, branchId }: PosQueueBarProps) {
|
||||
const t = useTranslations("queue");
|
||||
const locale = useLocale();
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
const queryClient = useQueryClient();
|
||||
const query = branchId ? `?branchId=${encodeURIComponent(branchId)}` : "";
|
||||
|
||||
const { data: board } = useQuery({
|
||||
queryKey: ["queue-today", cafeId, branchId],
|
||||
queryFn: () => apiGet<QueueBoard>(`/api/cafes/${cafeId}/queue/today${query}`),
|
||||
enabled: !!cafeId,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const callNext = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<QueueBoard>(`/api/cafes/${cafeId}/queue/call-next${query}`, {}),
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["queue-today"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 rounded-lg border border-primary/25 bg-primary/5 px-3 py-2 text-sm">
|
||||
<span className="font-medium text-primary">{t("title")}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("nowServing")}:{" "}
|
||||
<strong className="text-foreground tabular-nums">
|
||||
{board?.nowServing != null
|
||||
? formatNumber(board.nowServing, numberLocale)
|
||||
: "—"}
|
||||
</strong>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("lastIssued")}:{" "}
|
||||
<strong className="tabular-nums">
|
||||
{formatNumber(board?.lastIssued ?? 0, numberLocale)}
|
||||
</strong>
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
disabled={callNext.isPending || (board?.waitingCount ?? 0) === 0}
|
||||
onClick={() => callNext.mutate()}
|
||||
>
|
||||
{t("callNext")}
|
||||
</Button>
|
||||
<Link
|
||||
href="/queue"
|
||||
className="text-xs text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("issueNext")} →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { PosReceiptModal, PosSlipModal } from "@/components/pos/pos-slip-modal";
|
||||
export type { KitchenSlipLine } from "@/components/pos/pos-slip-modal";
|
||||
@@ -0,0 +1,44 @@
|
||||
@media print {
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
#pos-slip-print-area,
|
||||
#pos-slip-print-area *,
|
||||
#receipt-print-area,
|
||||
#receipt-print-area * {
|
||||
visibility: visible;
|
||||
}
|
||||
#pos-slip-print-area,
|
||||
#receipt-print-area {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#pos-slip-print-area,
|
||||
#receipt-print-area {
|
||||
width: 80mm;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
padding: 4mm;
|
||||
}
|
||||
|
||||
.receipt-divider {
|
||||
border-top: 1px dashed #000;
|
||||
margin: 3mm 0;
|
||||
}
|
||||
|
||||
.receipt-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.receipt-total {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import type { Order } from "@/lib/api/types";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { formatOrderNumber } from "@/lib/order-number";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import "./pos-receipt-print.css";
|
||||
|
||||
export type KitchenSlipLine = {
|
||||
name: string;
|
||||
quantity: number;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
type PosSlipModalProps = {
|
||||
variant: "kitchen" | "bill";
|
||||
cafeName: string;
|
||||
onClose: () => void;
|
||||
/** Full order for customer bill */
|
||||
order?: Order;
|
||||
/** Kitchen ticket lines (new items or full order) */
|
||||
kitchenLines?: KitchenSlipLine[];
|
||||
tableNumber?: string | number | null;
|
||||
orderId?: string;
|
||||
guestName?: string | null;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export function PosSlipModal({
|
||||
variant,
|
||||
cafeName,
|
||||
onClose,
|
||||
order,
|
||||
kitchenLines = [],
|
||||
tableNumber,
|
||||
orderId,
|
||||
guestName,
|
||||
createdAt,
|
||||
}: PosSlipModalProps) {
|
||||
const t = useTranslations("receipt");
|
||||
const locale = useLocale();
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
|
||||
const dateSource = order?.createdAt ?? createdAt ?? new Date().toISOString();
|
||||
const formattedDate = new Intl.DateTimeFormat(
|
||||
locale === "en" ? "en-US" : "fa-IR",
|
||||
{ dateStyle: "short", timeStyle: "short" }
|
||||
).format(new Date(dateSource));
|
||||
|
||||
const table =
|
||||
order?.tableNumber ?? tableNumber ?? "—";
|
||||
const orderNo = order ? formatOrderNumber(order) : orderId ? formatOrderNumber({ id: orderId }) : null;
|
||||
const guest = order?.guestName ?? guestName;
|
||||
const printId = "pos-slip-print-area";
|
||||
|
||||
const paymentKey = (method: string) => {
|
||||
const m = method.toLowerCase();
|
||||
if (m === "cash") return t("payment.cash");
|
||||
if (m === "card") return t("payment.card");
|
||||
if (m === "credit") return t("payment.credit");
|
||||
return method;
|
||||
};
|
||||
|
||||
const activeBillItems = order?.items.filter((i) => !i.isVoided) ?? [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-[340px] rounded-xl border border-border bg-background p-4 shadow-xl">
|
||||
<div
|
||||
id={printId}
|
||||
className="mb-4 rounded-md border border-dashed border-border p-3"
|
||||
>
|
||||
<div className="text-center text-base font-bold">{cafeName}</div>
|
||||
<div className="mb-1 text-center text-xs font-semibold">
|
||||
{variant === "kitchen" ? t("kitchenTitle") : t("billTitle")}
|
||||
</div>
|
||||
<div className="mb-2 text-center text-xs text-muted-foreground">
|
||||
{formattedDate}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
{t("table")}: {table}
|
||||
{orderNo ? (
|
||||
<>
|
||||
{" "}
|
||||
| {t("order")}: #{orderNo}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{guest ? (
|
||||
<div className="text-xs">
|
||||
{t("guest")}: {guest}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="receipt-divider" />
|
||||
|
||||
{variant === "kitchen"
|
||||
? kitchenLines.map((line, idx) => (
|
||||
<div key={`${line.name}-${idx}`} className="receipt-row mb-1 text-xs">
|
||||
<span>
|
||||
{line.name} × {line.quantity}
|
||||
{line.notes ? ` (${line.notes})` : ""}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
: activeBillItems.map((item) => (
|
||||
<div key={item.id} className="receipt-row mb-1 text-xs">
|
||||
<span>
|
||||
{item.menuItemName} × {item.quantity}
|
||||
</span>
|
||||
<span>
|
||||
{formatCurrency(item.unitPrice * item.quantity, numberLocale)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{variant === "bill" ? (
|
||||
<>
|
||||
<div className="receipt-divider" />
|
||||
<div className="receipt-row receipt-total">
|
||||
<span>{t("total")}</span>
|
||||
<span>{formatCurrency(order!.total, numberLocale)}</span>
|
||||
</div>
|
||||
{order!.payments?.map((p) => (
|
||||
<div key={p.id} className="receipt-row mt-1 text-xs">
|
||||
<span>{paymentKey(p.method)}</span>
|
||||
<span>{formatCurrency(p.amount, numberLocale)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="receipt-divider" />
|
||||
<div className="mt-2 text-center text-xs">{t("thankYou")}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-2 text-center text-[10px] text-muted-foreground">
|
||||
{t("kitchenFooter")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" className="flex-1" onClick={() => window.print()}>
|
||||
{t("print")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={onClose}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use PosSlipModal variant="bill" */
|
||||
export function PosReceiptModal({
|
||||
order,
|
||||
cafeName,
|
||||
onClose,
|
||||
}: {
|
||||
order: Order;
|
||||
cafeName: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PosSlipModal variant="bill" order={order} cafeName={cafeName} onClose={onClose} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import * as signalR from "@microsoft/signalr";
|
||||
import { apiGet, apiPatch } from "@/lib/api/client";
|
||||
import {
|
||||
branchTablesPath,
|
||||
fetchCafeTableBoard,
|
||||
setTableCleaning,
|
||||
type TableSectionDto,
|
||||
} from "@/lib/api/branch-tables";
|
||||
import type { Order, TableBoardItem } from "@/lib/api/types";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const statusStyles: Record<TableBoardItem["status"], string> = {
|
||||
Free: "bg-[#E1F5EE] text-[#0F6E56] border-[#0F6E56]/40 hover:border-[#0F6E56]",
|
||||
Busy: "bg-blue-50 text-[#0C447C] border-blue-300 hover:border-blue-500",
|
||||
Reserved: "bg-amber-50 text-[#BA7517] border-amber-300 hover:border-amber-500",
|
||||
Cleaning: "bg-slate-100 text-slate-600 border-slate-300 hover:border-slate-500",
|
||||
};
|
||||
|
||||
const selectedTableStyles =
|
||||
"border-primary bg-primary/10 text-primary shadow-[0_0_0_2px_hsl(var(--primary)/0.35)] z-[1]";
|
||||
|
||||
type PosTableBoardProps = {
|
||||
cafeId: string;
|
||||
numberLocale: string;
|
||||
selectedTableId: string | null;
|
||||
selectedOrderId?: string | null;
|
||||
branchId: string | null;
|
||||
mode?: "order" | "pay";
|
||||
onSelectTable: (table: TableBoardItem, activeOrder: Order | null) => void;
|
||||
};
|
||||
|
||||
function groupPosTables(
|
||||
tables: TableBoardItem[],
|
||||
sections: TableSectionDto[],
|
||||
noSectionLabel: string
|
||||
): { key: string; label: string | null; tables: TableBoardItem[] }[] {
|
||||
const groups: { key: string; label: string | null; tables: TableBoardItem[] }[] = [];
|
||||
for (const sec of sections) {
|
||||
const items = tables.filter((t) => t.sectionId === sec.id);
|
||||
if (items.length > 0) {
|
||||
groups.push({ key: sec.id, label: sec.name, tables: items });
|
||||
}
|
||||
}
|
||||
const unassigned = tables.filter((t) => !t.sectionId);
|
||||
if (unassigned.length > 0) {
|
||||
groups.push({ key: "_none", label: noSectionLabel, tables: unassigned });
|
||||
}
|
||||
if (groups.length === 0 && tables.length > 0) {
|
||||
groups.push({ key: "_all", label: null, tables });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function PosTableBoard({
|
||||
cafeId,
|
||||
numberLocale,
|
||||
selectedTableId,
|
||||
selectedOrderId = null,
|
||||
branchId,
|
||||
mode = "order",
|
||||
onSelectTable,
|
||||
}: PosTableBoardProps) {
|
||||
const t = useTranslations("pos");
|
||||
const tQr = useTranslations("qrMenu");
|
||||
const tTables = useTranslations("tables");
|
||||
const queryClient = useQueryClient();
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5080";
|
||||
|
||||
const {
|
||||
data: tables = [],
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["tables-board", cafeId, branchId, "pos"],
|
||||
queryFn: () => fetchCafeTableBoard(cafeId, branchId),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const { data: sections = [] } = useQuery({
|
||||
queryKey: ["table-sections", cafeId, branchId],
|
||||
queryFn: () =>
|
||||
apiGet<TableSectionDto[]>(
|
||||
`${branchTablesPath(cafeId, branchId!)}/sections`
|
||||
),
|
||||
enabled: !!cafeId && !!branchId,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const grouped = useMemo(
|
||||
() => groupPosTables(tables, sections, tTables("noSection")),
|
||||
[tables, sections, tTables]
|
||||
);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
|
||||
}, [queryClient, cafeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cafeId) return;
|
||||
const token =
|
||||
typeof window !== "undefined" ? localStorage.getItem("meezi_access_token") : null;
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(`${apiBase}/hubs/kds`, { accessTokenFactory: () => token ?? "" })
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
connection
|
||||
.start()
|
||||
.then(() => connection.invoke("JoinCafe", cafeId))
|
||||
.catch(() => undefined);
|
||||
connection.on("TableStatusChanged", refresh);
|
||||
connection.on("OrderCreated", refresh);
|
||||
connection.on("OrderStatusChanged", refresh);
|
||||
return () => {
|
||||
void connection.stop();
|
||||
};
|
||||
}, [cafeId, apiBase, refresh]);
|
||||
|
||||
const setCleaning = useMutation({
|
||||
mutationFn: ({
|
||||
tableId,
|
||||
isCleaning,
|
||||
tableBranchId,
|
||||
}: {
|
||||
tableId: string;
|
||||
isCleaning: boolean;
|
||||
tableBranchId: string;
|
||||
}) => setTableCleaning(cafeId, tableId, isCleaning, branchId ?? tableBranchId),
|
||||
onSuccess: () => refresh(),
|
||||
});
|
||||
|
||||
const handleClick = async (table: TableBoardItem) => {
|
||||
if (table.isCleaning ?? table.status === "Cleaning") return;
|
||||
if (mode === "pay" && table.status !== "Busy") return;
|
||||
|
||||
let activeOrder: Order | null = null;
|
||||
if (table.status === "Busy" && table.currentOrder?.orderId) {
|
||||
try {
|
||||
activeOrder = await apiGet<Order>(
|
||||
`/api/cafes/${cafeId}/orders/${table.currentOrder.orderId}`
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
activeOrder = await apiGet<Order>(
|
||||
`/api/cafes/${cafeId}/tables/${table.id}/active-order`
|
||||
);
|
||||
} catch {
|
||||
activeOrder = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
onSelectTable(table, activeOrder);
|
||||
};
|
||||
|
||||
const statusLabel = (status: TableBoardItem["status"]) => {
|
||||
switch (status) {
|
||||
case "Free":
|
||||
return tTables("status.free");
|
||||
case "Busy":
|
||||
return tTables("status.occupied");
|
||||
case "Reserved":
|
||||
return tTables("status.reserved");
|
||||
case "Cleaning":
|
||||
return tTables("status.cleaning");
|
||||
}
|
||||
};
|
||||
|
||||
const title =
|
||||
mode === "pay" ? t("paySelectTable") : t("selectTableBoard");
|
||||
|
||||
const renderTableButton = (table: TableBoardItem) => {
|
||||
const cleaning = table.isCleaning ?? table.status === "Cleaning";
|
||||
const isSelected =
|
||||
selectedTableId === table.id ||
|
||||
(selectedOrderId != null &&
|
||||
table.currentOrder?.orderId === selectedOrderId);
|
||||
const payDisabled =
|
||||
mode === "pay" &&
|
||||
(table.status === "Cleaning" || table.status !== "Busy");
|
||||
return (
|
||||
<div key={table.id} className="flex shrink-0 flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={cleaning || payDisabled}
|
||||
onClick={() => void handleClick(table)}
|
||||
className={cn(
|
||||
"flex min-w-[4.5rem] flex-col items-center rounded-lg border-2 px-3 py-2 text-center transition",
|
||||
isSelected ? selectedTableStyles : statusStyles[table.status],
|
||||
(cleaning || payDisabled) &&
|
||||
"cursor-not-allowed opacity-60"
|
||||
)}
|
||||
>
|
||||
<span className="text-lg font-bold">{table.number}</span>
|
||||
<span className="text-[10px]">{statusLabel(table.status)}</span>
|
||||
{table.currentOrder && table.status === "Busy" ? (
|
||||
<span className="mt-0.5 max-w-[4rem] truncate text-[10px] tabular-nums">
|
||||
{formatCurrency(table.currentOrder.total, numberLocale)}
|
||||
</span>
|
||||
) : null}
|
||||
{table.currentOrder?.guestLabel && table.status === "Busy" ? (
|
||||
<span className="mt-0.5 max-w-[4.5rem] truncate text-[9px] opacity-90">
|
||||
{table.currentOrder.guestLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{table.currentOrder?.source === "GuestQr" && table.status === "Busy" ? (
|
||||
<span className="mt-0.5 rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-900">
|
||||
{tQr("guestQrBadge")}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{mode === "order" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground underline-offset-2 hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCleaning.mutate({
|
||||
tableId: table.id,
|
||||
tableBranchId: table.branchId,
|
||||
isCleaning: !cleaning,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{cleaning
|
||||
? tTables("markReady")
|
||||
: tTables("markCleaning")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 space-y-2 rounded-lg border border-border/80 bg-muted/20 p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">{title}</p>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{t("loadingTables")}</p>
|
||||
) : null}
|
||||
|
||||
{isError ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-[#A32D2D]">{t("tablesLoadError")}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-[#0F6E56] underline-offset-2 hover:underline"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
{t("retryTables")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !isError && tables.length === 0 ? (
|
||||
<div className="space-y-2 rounded-md border border-dashed border-[#BA7517]/50 bg-amber-50/50 px-3 py-3">
|
||||
<p className="text-sm text-[#BA7517]">{t("noTablesOnBoard")}</p>
|
||||
<Link
|
||||
href="/tables"
|
||||
className="text-xs font-medium text-[#0F6E56] underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("manageTablesLink")}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !isError && tables.length > 0
|
||||
? grouped.map((group) => (
|
||||
<div key={group.key} className="space-y-1.5">
|
||||
{group.label ? (
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{group.label}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="-mx-0.5 flex gap-2 overflow-x-auto px-1 py-1">
|
||||
{group.tables.map(renderTableButton)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { ConfirmProvider } from "@/components/providers/confirm-provider";
|
||||
import { MeeziToaster } from "@/components/ui/meezi-toaster";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 30_000, retry: 1 },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfirmProvider>
|
||||
{children}
|
||||
<MeeziToaster />
|
||||
</ConfirmProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ConfirmOptions = {
|
||||
title?: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: "default" | "destructive";
|
||||
};
|
||||
|
||||
type ConfirmContextValue = {
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
|
||||
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const t = useTranslations("confirm");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [options, setOptions] = useState<ConfirmOptions | null>(null);
|
||||
const resolveRef = useRef<((value: boolean) => void) | null>(null);
|
||||
|
||||
const confirm = useCallback((opts: ConfirmOptions) => {
|
||||
setOptions(opts);
|
||||
setOpen(true);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveRef.current = resolve;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const finish = useCallback((value: boolean) => {
|
||||
setOpen(false);
|
||||
resolveRef.current?.(value);
|
||||
resolveRef.current = null;
|
||||
setTimeout(() => setOptions(null), 200);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ confirm }), [confirm]);
|
||||
|
||||
const isDestructive = options?.variant === "destructive";
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={value}>
|
||||
{children}
|
||||
<AlertDialog open={open} onOpenChange={(next) => !next && finish(false)}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<div className="flex items-start gap-3 sm:text-start">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
|
||||
isDestructive ? "bg-red-50 text-[#A32D2D]" : "bg-[#E1F5EE] text-[#0F6E56]"
|
||||
)}
|
||||
>
|
||||
<TriangleAlert className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0 space-y-1.5 pt-0.5">
|
||||
<AlertDialogTitle>
|
||||
{options?.title ?? t("title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>{options?.description}</AlertDialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="sm:justify-end">
|
||||
<AlertDialogCancel onClick={() => finish(false)}>
|
||||
{options?.cancelLabel ?? t("cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={cn(
|
||||
isDestructive &&
|
||||
"bg-destructive text-destructive-foreground hover:opacity-90"
|
||||
)}
|
||||
onClick={() => finish(true)}
|
||||
>
|
||||
{options?.confirmLabel ?? t("confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
const ctx = useContext(ConfirmContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useConfirm must be used within ConfirmProvider");
|
||||
}
|
||||
return ctx.confirm;
|
||||
}
|
||||
@@ -0,0 +1,880 @@
|
||||
"use client";
|
||||
|
||||
import { Search, ShoppingBag, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { QR_ALL_CATEGORY_ID } from "@/lib/qr-menu-constants";
|
||||
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
|
||||
import { CategoryVisual } from "@/components/menu/category-visual";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { resolveMediaUrl } from "@/lib/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { QrCartLine, QrPublicMenuCategory, QrPublicMenuItem } from "@/lib/api/qr-public";
|
||||
import type { CafeThemePalette } from "@/lib/cafe-theme";
|
||||
import { hasMenu3dView } from "@/lib/menu-3d";
|
||||
import { Box } from "lucide-react";
|
||||
|
||||
export type QrMenuBodyProps = {
|
||||
menuStyle: string;
|
||||
colors: CafeThemePalette;
|
||||
categories: QrPublicMenuCategory[];
|
||||
activeCategory: string;
|
||||
onCategoryChange: (id: string) => void;
|
||||
activeItems: QrPublicMenuItem[];
|
||||
showAllGrouped?: boolean;
|
||||
searchQuery: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
isSearching?: boolean;
|
||||
categoryNameById?: Map<string, string>;
|
||||
cart: QrCartLine[];
|
||||
onAdd: (item: QrPublicMenuItem) => void;
|
||||
onRemove: (itemId: string) => void;
|
||||
onView3d?: (item: QrPublicMenuItem) => void;
|
||||
totalItems: number;
|
||||
totalPrice: number;
|
||||
onOpenCart: () => void;
|
||||
labels: {
|
||||
emptyCategory: string;
|
||||
addToCart: string;
|
||||
checkout: string;
|
||||
searchPlaceholder: string;
|
||||
allCategories: string;
|
||||
clearSearch: string;
|
||||
view3d: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function QrGuestMenuBody({
|
||||
showCartBar = true,
|
||||
...props
|
||||
}: QrMenuBodyProps & { showCartBar?: boolean }) {
|
||||
const { colors } = props;
|
||||
const primary = colors.primary;
|
||||
const surface = colors.surface;
|
||||
|
||||
const style = props.menuStyle || "cards";
|
||||
|
||||
const listProps = {
|
||||
...props,
|
||||
primary,
|
||||
surface,
|
||||
colors,
|
||||
showCategoryLabel: props.isSearching ?? false,
|
||||
categoryNameById: props.categoryNameById,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<MenuSearchBar {...props} primary={primary} surface={surface} />
|
||||
<CategoryTabs {...props} primary={primary} surface={surface} colors={colors} />
|
||||
{props.showAllGrouped ? (
|
||||
<GroupedAllSections {...listProps} />
|
||||
) : style === "grid" ? (
|
||||
<GridItems {...listProps} />
|
||||
) : style === "list" ? (
|
||||
<ListItems {...listProps} compact={false} />
|
||||
) : style === "compact" ? (
|
||||
<ListItems {...listProps} compact />
|
||||
) : style === "magazine" ? (
|
||||
<MagazineItems {...listProps} />
|
||||
) : style === "classic" ? (
|
||||
<ClassicLayout {...props} surface={surface} />
|
||||
) : (
|
||||
<CardItems {...listProps} />
|
||||
)}
|
||||
{showCartBar ? <CartBar {...props} floating={false} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuSearchBar({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
primary,
|
||||
surface,
|
||||
labels,
|
||||
}: Pick<QrMenuBodyProps, "searchQuery" | "onSearchChange" | "labels"> & {
|
||||
surface: string;
|
||||
primary: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="sticky top-0 z-20 px-3 pb-2 pt-2.5"
|
||||
style={{ backgroundColor: surface }}
|
||||
>
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="pointer-events-none absolute top-1/2 size-4 -translate-y-1/2 qr-icon start-3"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={labels.searchPlaceholder}
|
||||
className="h-10 rounded-xl qr-border qr-surface ps-9 pe-9 text-sm qr-text"
|
||||
style={{ borderColor: `${primary}33` }}
|
||||
/>
|
||||
{searchQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-full qr-muted qr-fill-muted end-1"
|
||||
onClick={() => onSearchChange("")}
|
||||
aria-label={labels.clearSearch}
|
||||
>
|
||||
<X className="size-4 qr-icon" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryTabs({
|
||||
categories,
|
||||
activeCategory,
|
||||
onCategoryChange,
|
||||
primary,
|
||||
surface,
|
||||
colors,
|
||||
labels,
|
||||
}: Pick<QrMenuBodyProps, "categories" | "activeCategory" | "onCategoryChange" | "labels" | "colors"> & {
|
||||
surface: string;
|
||||
primary: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="sticky top-[3.25rem] z-10 flex gap-2 overflow-x-auto border-b qr-border px-3 py-2.5 shadow-sm"
|
||||
style={{ backgroundColor: surface }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCategoryChange(QR_ALL_CATEGORY_ID)}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition active:scale-[0.98]",
|
||||
activeCategory === QR_ALL_CATEGORY_ID ? "text-white" : "qr-border qr-text"
|
||||
)}
|
||||
style={
|
||||
activeCategory === QR_ALL_CATEGORY_ID
|
||||
? { backgroundColor: primary, borderColor: primary }
|
||||
: { backgroundColor: "transparent", color: colors.text }
|
||||
}
|
||||
>
|
||||
{labels.allCategories}
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => onCategoryChange(cat.id)}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition active:scale-[0.98]",
|
||||
activeCategory === cat.id ? "text-white" : "qr-border qr-text"
|
||||
)}
|
||||
style={
|
||||
activeCategory === cat.id
|
||||
? { backgroundColor: primary, borderColor: primary }
|
||||
: { backgroundColor: "transparent", color: colors.text }
|
||||
}
|
||||
>
|
||||
<CategoryVisual
|
||||
icon={cat.icon}
|
||||
iconPresetId={cat.iconPresetId}
|
||||
iconStyle={cat.iconStyle}
|
||||
imageUrl={cat.imageUrl}
|
||||
size="xs"
|
||||
brandColors={colors}
|
||||
/>
|
||||
{cat.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ItemListExtras = {
|
||||
surface: string;
|
||||
primary: string;
|
||||
colors: CafeThemePalette;
|
||||
showCategoryLabel?: boolean;
|
||||
categoryNameById?: Map<string, string>;
|
||||
};
|
||||
|
||||
function GroupedAllSections(
|
||||
props: Pick<
|
||||
QrMenuBodyProps,
|
||||
"categories" | "cart" | "onAdd" | "onRemove" | "onView3d" | "labels"
|
||||
> &
|
||||
ItemListExtras
|
||||
) {
|
||||
const { categories, labels, surface, primary, colors, onView3d } = props;
|
||||
const hasAny = categories.some((c) => (c.items?.length ?? 0) > 0);
|
||||
if (!hasAny) return <EmptyCategory text={labels.emptyCategory} />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-3 pb-4">
|
||||
{categories.map((cat) => {
|
||||
const items = cat.items ?? [];
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<section key={cat.id}>
|
||||
<p className="mb-2 px-1 text-[11px] font-medium uppercase tracking-[0.06em] qr-muted">
|
||||
{cat.name}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<ItemRowCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
cart={props.cart}
|
||||
primary={primary}
|
||||
surface={surface}
|
||||
colors={colors}
|
||||
onAdd={props.onAdd}
|
||||
onRemove={props.onRemove}
|
||||
onView3d={props.onView3d}
|
||||
addLabel={labels.addToCart}
|
||||
view3dLabel={labels.view3d}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardItems({
|
||||
activeItems,
|
||||
cart,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onView3d,
|
||||
primary,
|
||||
labels,
|
||||
surface,
|
||||
colors,
|
||||
showCategoryLabel,
|
||||
categoryNameById,
|
||||
}: Pick<
|
||||
QrMenuBodyProps,
|
||||
"activeItems" | "cart" | "onAdd" | "onRemove" | "onView3d" | "labels"
|
||||
> &
|
||||
ItemListExtras) {
|
||||
return (
|
||||
<div className="space-y-2 p-3 pb-4">
|
||||
{activeItems.length === 0 ? (
|
||||
<EmptyCategory text={labels.emptyCategory} />
|
||||
) : (
|
||||
activeItems.map((item) => (
|
||||
<ItemRowCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
cart={cart}
|
||||
primary={primary}
|
||||
surface={surface}
|
||||
colors={colors}
|
||||
onAdd={onAdd}
|
||||
onRemove={onRemove}
|
||||
onView3d={onView3d}
|
||||
addLabel={labels.addToCart}
|
||||
view3dLabel={labels.view3d}
|
||||
categoryLabel={
|
||||
showCategoryLabel
|
||||
? categoryNameById?.get(item.categoryId)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GridItems({
|
||||
activeItems,
|
||||
cart,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onView3d,
|
||||
primary,
|
||||
labels,
|
||||
surface,
|
||||
colors,
|
||||
showCategoryLabel,
|
||||
categoryNameById,
|
||||
}: Pick<
|
||||
QrMenuBodyProps,
|
||||
"activeItems" | "cart" | "onAdd" | "onRemove" | "onView3d" | "labels"
|
||||
> &
|
||||
ItemListExtras) {
|
||||
if (activeItems.length === 0) return <EmptyCategory text={labels.emptyCategory} />;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2.5 p-3 pb-4">
|
||||
{activeItems.map((item) => (
|
||||
<GridCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
cart={cart}
|
||||
primary={primary}
|
||||
surface={surface}
|
||||
colors={colors}
|
||||
onAdd={onAdd}
|
||||
onRemove={onRemove}
|
||||
onView3d={onView3d}
|
||||
addLabel={labels.addToCart}
|
||||
view3dLabel={labels.view3d}
|
||||
categoryLabel={
|
||||
showCategoryLabel ? categoryNameById?.get(item.categoryId) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListItems({
|
||||
activeItems,
|
||||
cart,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onView3d,
|
||||
primary,
|
||||
labels,
|
||||
surface,
|
||||
colors,
|
||||
compact,
|
||||
showCategoryLabel,
|
||||
categoryNameById,
|
||||
}: Pick<
|
||||
QrMenuBodyProps,
|
||||
"activeItems" | "cart" | "onAdd" | "onRemove" | "onView3d" | "labels"
|
||||
> &
|
||||
ItemListExtras & { compact: boolean }) {
|
||||
return (
|
||||
<div className="space-y-2 p-3 pb-4">
|
||||
{activeItems.length === 0 ? (
|
||||
<EmptyCategory text={labels.emptyCategory} />
|
||||
) : (
|
||||
activeItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg border border-border/60 px-2 shadow-sm",
|
||||
compact ? "py-1.5" : "py-2.5"
|
||||
)}
|
||||
style={{ backgroundColor: surface }}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
{resolveMediaUrl(item.imageUrl) ? (
|
||||
<img
|
||||
src={resolveMediaUrl(item.imageUrl)}
|
||||
alt=""
|
||||
className={cn(
|
||||
"rounded-md object-cover",
|
||||
compact ? "size-10" : "size-12"
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn("rounded-md qr-fill-muted", compact ? "size-10" : "size-12")}
|
||||
/>
|
||||
)}
|
||||
{hasMenu3dView(item) && onView3d ? (
|
||||
<View3dChip
|
||||
label={labels.view3d}
|
||||
onClick={() => onView3d(item)}
|
||||
className="absolute -bottom-1 end-0 scale-90"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{showCategoryLabel && categoryNameById?.get(item.categoryId) ? (
|
||||
<p className="mb-0.5 text-[10px] qr-muted">
|
||||
{categoryNameById.get(item.categoryId)}
|
||||
</p>
|
||||
) : null}
|
||||
<MenuItemLabels item={item} lines={1} primaryClassName="text-sm font-medium" />
|
||||
<p className="text-xs font-semibold" style={{ color: primary }}>
|
||||
{formatCurrency(effectivePrice(item), "fa-IR")}
|
||||
</p>
|
||||
</div>
|
||||
<QtyControls
|
||||
item={item}
|
||||
cart={cart}
|
||||
primary={primary}
|
||||
onAdd={onAdd}
|
||||
onRemove={onRemove}
|
||||
addLabel={labels.addToCart}
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MagazineItems({
|
||||
activeItems,
|
||||
cart,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onView3d,
|
||||
primary,
|
||||
labels,
|
||||
surface,
|
||||
colors,
|
||||
showCategoryLabel,
|
||||
categoryNameById,
|
||||
}: Pick<
|
||||
QrMenuBodyProps,
|
||||
"activeItems" | "cart" | "onAdd" | "onRemove" | "onView3d" | "labels"
|
||||
> &
|
||||
ItemListExtras) {
|
||||
return (
|
||||
<div className="space-y-3 p-3 pb-4">
|
||||
{activeItems.length === 0 ? (
|
||||
<EmptyCategory text={labels.emptyCategory} />
|
||||
) : (
|
||||
activeItems.map((item) => {
|
||||
const img = resolveMediaUrl(item.imageUrl);
|
||||
return (
|
||||
<article
|
||||
key={item.id}
|
||||
className="overflow-hidden rounded-xl border border-border/80 shadow-sm"
|
||||
style={{ backgroundColor: surface }}
|
||||
>
|
||||
<div className="relative">
|
||||
{img ? (
|
||||
<img src={img} alt="" className="aspect-[16/9] w-full object-cover" />
|
||||
) : (
|
||||
<div className="aspect-[16/9] w-full qr-fill-muted" />
|
||||
)}
|
||||
{hasMenu3dView(item) && onView3d ? (
|
||||
<View3dChip
|
||||
label={labels.view3d}
|
||||
onClick={() => onView3d(item)}
|
||||
className="absolute bottom-3 start-3"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{showCategoryLabel && categoryNameById?.get(item.categoryId) ? (
|
||||
<p className="mb-1 text-[10px] qr-muted">
|
||||
{categoryNameById.get(item.categoryId)}
|
||||
</p>
|
||||
) : null}
|
||||
<MenuItemLabels item={item} lines={2} primaryClassName="text-base font-semibold" />
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="font-bold" style={{ color: primary }}>
|
||||
{formatCurrency(effectivePrice(item), "fa-IR")}
|
||||
</span>
|
||||
<QtyControls
|
||||
item={item}
|
||||
cart={cart}
|
||||
primary={primary}
|
||||
onAdd={onAdd}
|
||||
onRemove={onRemove}
|
||||
addLabel={labels.addToCart}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicLayout({
|
||||
categories,
|
||||
activeCategory,
|
||||
onCategoryChange,
|
||||
activeItems,
|
||||
showAllGrouped,
|
||||
cart,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onView3d,
|
||||
colors,
|
||||
labels,
|
||||
surface,
|
||||
}: QrMenuBodyProps & { surface: string }) {
|
||||
const primary = colors.primary;
|
||||
return (
|
||||
<div className="flex min-h-[50vh]">
|
||||
<aside
|
||||
className="w-[4.5rem] shrink-0 space-y-2 border-e border-border/60 py-3"
|
||||
style={{ backgroundColor: surface }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCategoryChange(QR_ALL_CATEGORY_ID)}
|
||||
className={cn(
|
||||
"mx-auto flex w-14 flex-col items-center gap-1 rounded-lg py-2 text-[10px] font-medium transition",
|
||||
activeCategory === QR_ALL_CATEGORY_ID ? "text-white" : "qr-text"
|
||||
)}
|
||||
style={
|
||||
activeCategory === QR_ALL_CATEGORY_ID
|
||||
? { backgroundColor: primary }
|
||||
: { color: colors.text }
|
||||
}
|
||||
>
|
||||
<span className="text-base">☰</span>
|
||||
<span className="line-clamp-2 text-center leading-tight">{labels.allCategories}</span>
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => onCategoryChange(cat.id)}
|
||||
className={cn(
|
||||
"mx-auto flex w-14 flex-col items-center gap-1 rounded-lg py-2 text-[10px] font-medium transition",
|
||||
activeCategory === cat.id ? "text-white" : "qr-text"
|
||||
)}
|
||||
style={
|
||||
activeCategory === cat.id
|
||||
? { backgroundColor: primary }
|
||||
: { color: colors.text }
|
||||
}
|
||||
>
|
||||
<CategoryVisual
|
||||
icon={cat.icon}
|
||||
iconPresetId={cat.iconPresetId}
|
||||
iconStyle={cat.iconStyle}
|
||||
imageUrl={cat.imageUrl}
|
||||
size="sm"
|
||||
brandColors={colors}
|
||||
/>
|
||||
<span className="line-clamp-2 text-center leading-tight">{cat.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
<div className="min-w-0 flex-1">
|
||||
{showAllGrouped ? (
|
||||
<GroupedAllSections
|
||||
categories={categories}
|
||||
cart={cart}
|
||||
onAdd={onAdd}
|
||||
onRemove={onRemove}
|
||||
onView3d={onView3d}
|
||||
primary={primary}
|
||||
labels={labels}
|
||||
surface={surface}
|
||||
colors={colors}
|
||||
/>
|
||||
) : (
|
||||
<CardItems
|
||||
activeItems={activeItems}
|
||||
cart={cart}
|
||||
onAdd={onAdd}
|
||||
onRemove={onRemove}
|
||||
onView3d={onView3d}
|
||||
primary={primary}
|
||||
labels={labels}
|
||||
surface={surface}
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemRowCard({
|
||||
item,
|
||||
cart,
|
||||
primary,
|
||||
surface,
|
||||
colors,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onView3d,
|
||||
addLabel,
|
||||
view3dLabel,
|
||||
categoryLabel,
|
||||
}: {
|
||||
item: QrPublicMenuItem;
|
||||
cart: QrCartLine[];
|
||||
primary: string;
|
||||
surface: string;
|
||||
colors: CafeThemePalette;
|
||||
onAdd: (item: QrPublicMenuItem) => void;
|
||||
onRemove: (itemId: string) => void;
|
||||
onView3d?: (item: QrPublicMenuItem) => void;
|
||||
addLabel: string;
|
||||
view3dLabel: string;
|
||||
categoryLabel?: string;
|
||||
}) {
|
||||
const img = resolveMediaUrl(item.imageUrl);
|
||||
return (
|
||||
<div
|
||||
className="flex gap-3 rounded-xl border border-border/70 p-3 shadow-sm"
|
||||
style={{ backgroundColor: surface }}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
{img ? (
|
||||
<img src={img} alt="" className="size-[4.5rem] rounded-lg object-cover" />
|
||||
) : (
|
||||
<div className="size-[4.5rem] rounded-lg qr-fill-muted" />
|
||||
)}
|
||||
{hasMenu3dView(item) && onView3d ? (
|
||||
<View3dChip
|
||||
label={view3dLabel}
|
||||
onClick={() => onView3d(item)}
|
||||
className="absolute bottom-1 end-1"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{categoryLabel ? (
|
||||
<p className="mb-0.5 text-[10px] font-medium qr-muted">{categoryLabel}</p>
|
||||
) : null}
|
||||
<div className="qr-text">
|
||||
<MenuItemLabels item={item} lines={2} primaryClassName="text-sm font-semibold" />
|
||||
</div>
|
||||
{item.description ? (
|
||||
<p className="mt-0.5 line-clamp-2 text-[11px] qr-muted">
|
||||
{item.description}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: primary }}>
|
||||
{formatCurrency(effectivePrice(item), "fa-IR")}
|
||||
</span>
|
||||
<QtyControls
|
||||
item={item}
|
||||
cart={cart}
|
||||
primary={primary}
|
||||
onAdd={onAdd}
|
||||
onRemove={onRemove}
|
||||
addLabel={addLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GridCard({
|
||||
item,
|
||||
cart,
|
||||
primary,
|
||||
surface,
|
||||
colors,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onView3d,
|
||||
addLabel,
|
||||
view3dLabel,
|
||||
categoryLabel,
|
||||
}: {
|
||||
item: QrPublicMenuItem;
|
||||
cart: QrCartLine[];
|
||||
primary: string;
|
||||
surface: string;
|
||||
colors: CafeThemePalette;
|
||||
onAdd: (item: QrPublicMenuItem) => void;
|
||||
onRemove: (itemId: string) => void;
|
||||
onView3d?: (item: QrPublicMenuItem) => void;
|
||||
addLabel: string;
|
||||
view3dLabel: string;
|
||||
categoryLabel?: string;
|
||||
}) {
|
||||
const img = resolveMediaUrl(item.imageUrl);
|
||||
return (
|
||||
<article
|
||||
className="flex flex-col overflow-hidden rounded-xl border border-border/80 shadow-sm"
|
||||
style={{ backgroundColor: surface }}
|
||||
>
|
||||
<div className="relative">
|
||||
{img ? (
|
||||
<img src={img} alt="" className="aspect-square w-full object-cover" />
|
||||
) : (
|
||||
<div className="aspect-square w-full qr-fill-muted" />
|
||||
)}
|
||||
{hasMenu3dView(item) && onView3d ? (
|
||||
<View3dChip label={view3dLabel} onClick={() => onView3d(item)} className="absolute bottom-2 start-2" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-2">
|
||||
{categoryLabel ? (
|
||||
<p className="mb-0.5 text-[10px] qr-muted">{categoryLabel}</p>
|
||||
) : null}
|
||||
<div className="qr-text">
|
||||
<MenuItemLabels item={item} lines={2} primaryClassName="text-xs font-semibold" />
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-bold" style={{ color: primary }}>
|
||||
{formatCurrency(effectivePrice(item), "fa-IR")}
|
||||
</p>
|
||||
<div className="mt-auto pt-2">
|
||||
<QtyControls
|
||||
item={item}
|
||||
cart={cart}
|
||||
primary={primary}
|
||||
onAdd={onAdd}
|
||||
onRemove={onRemove}
|
||||
addLabel={addLabel}
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function QtyControls({
|
||||
item,
|
||||
cart,
|
||||
primary,
|
||||
onAdd,
|
||||
onRemove,
|
||||
addLabel,
|
||||
small,
|
||||
}: {
|
||||
item: QrPublicMenuItem;
|
||||
cart: QrCartLine[];
|
||||
primary: string;
|
||||
onAdd: (item: QrPublicMenuItem) => void;
|
||||
onRemove: (itemId: string) => void;
|
||||
addLabel: string;
|
||||
small?: boolean;
|
||||
}) {
|
||||
const inCart = cart.find((c) => c.item.id === item.id);
|
||||
const size = small ? "size-7 text-base" : "size-8 text-lg";
|
||||
if (inCart) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<QtyBtn label="−" className={size} variant="outline" color={primary} onClick={() => onRemove(item.id)} />
|
||||
<span className="min-w-5 text-center text-sm font-bold">{inCart.qty}</span>
|
||||
<QtyBtn label="+" className={size} variant="filled" color={primary} onClick={() => onAdd(item)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 rounded-full px-3 text-xs"
|
||||
style={{ backgroundColor: primary }}
|
||||
onClick={() => onAdd(item)}
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function QtyBtn({
|
||||
label,
|
||||
className,
|
||||
variant,
|
||||
color,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
className: string;
|
||||
variant: "outline" | "filled";
|
||||
color: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full leading-none",
|
||||
className,
|
||||
variant === "filled" ? "text-white" : ""
|
||||
)}
|
||||
style={
|
||||
variant === "filled"
|
||||
? { backgroundColor: color }
|
||||
: { border: `1.5px solid ${color}`, color }
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function View3dChip({
|
||||
label,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md bg-black/70 px-2 py-1 text-[10px] font-medium text-white shadow-sm backdrop-blur-sm transition active:scale-[0.98]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Box className="h-3 w-3 shrink-0" aria-hidden />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function QrFloatingCartBar(
|
||||
props: Pick<QrMenuBodyProps, "totalItems" | "totalPrice" | "colors" | "onOpenCart" | "labels">
|
||||
) {
|
||||
return <CartBar {...props} floating />;
|
||||
}
|
||||
|
||||
function CartBar({
|
||||
totalItems,
|
||||
totalPrice,
|
||||
colors,
|
||||
onOpenCart,
|
||||
labels,
|
||||
floating = true,
|
||||
}: Pick<QrMenuBodyProps, "totalItems" | "totalPrice" | "colors" | "onOpenCart" | "labels"> & {
|
||||
floating?: boolean;
|
||||
}) {
|
||||
const primary = colors.primary;
|
||||
if (totalItems <= 0) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
floating
|
||||
? "shadow-lg"
|
||||
: "sticky bottom-0 z-20 border-t border-border/60 qr-surface/95 backdrop-blur"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className="flex h-12 w-full items-center justify-between gap-3 rounded-2xl px-4 shadow-md"
|
||||
style={{ backgroundColor: primary }}
|
||||
onClick={onOpenCart}
|
||||
>
|
||||
<span className="flex size-7 items-center justify-center rounded-full bg-white/25 text-sm font-bold">
|
||||
{totalItems.toLocaleString("fa-IR")}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 font-semibold">
|
||||
<ShoppingBag className="size-4 shrink-0 text-white" aria-hidden />
|
||||
{labels.checkout}
|
||||
</span>
|
||||
<span className="text-sm font-bold">{formatCurrency(totalPrice, "fa-IR")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyCategory({ text }: { text: string }) {
|
||||
return <p className="p-8 text-center text-sm qr-muted">{text}</p>;
|
||||
}
|
||||
|
||||
function effectivePrice(item: QrPublicMenuItem): number {
|
||||
const discount = item.discountPercent > 0 ? item.discountPercent : 0;
|
||||
return Math.round(item.price * (1 - discount / 100));
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { menuItemMatchesSearch } from "@/lib/menu-display";
|
||||
import { QR_ALL_CATEGORY_ID } from "@/lib/qr-menu-constants";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { resolveMediaUrl } from "@/lib/api/client";
|
||||
import { ApiClientError } from "@/lib/api/client";
|
||||
import {
|
||||
callWaiter,
|
||||
fetchBranchPublicMenu,
|
||||
fetchPublicSecurityConfig,
|
||||
placeBranchGuestOrder,
|
||||
resolveQrCode,
|
||||
type PublicSecurityConfig,
|
||||
type QrCartLine,
|
||||
type QrPublicMenuItem,
|
||||
type QrResolve,
|
||||
} from "@/lib/api/qr-public";
|
||||
import {
|
||||
buildQrThemeCssVars,
|
||||
normalizeCafeTheme,
|
||||
normalizeMenuTexture,
|
||||
qrMenuTextureShellProps,
|
||||
resolveQrGuestColors,
|
||||
type CafeTheme,
|
||||
} from "@/lib/cafe-theme";
|
||||
import { QrFloatingCartBar, QrGuestMenuBody } from "@/components/qr/qr-guest-menu-body";
|
||||
import { QrMenu3dSheet } from "@/components/qr/qr-menu-3d-sheet";
|
||||
import { QrTurnstile } from "@/components/qr/qr-turnstile";
|
||||
import { QrOrderTrack } from "@/components/qr/qr-order-track";
|
||||
import {
|
||||
loadGuestOrders,
|
||||
ordersForTable,
|
||||
saveGuestOrder,
|
||||
type GuestOrderRef,
|
||||
} from "@/lib/guest-order-storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Screen = "loading" | "error" | "menu" | "cart" | "success" | "track" | "orders";
|
||||
|
||||
type QrGuestMenuProps = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
export function QrGuestMenu({ code }: QrGuestMenuProps) {
|
||||
const t = useTranslations("qrMenu");
|
||||
const locale = useLocale();
|
||||
const [screen, setScreen] = useState<Screen>("loading");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [branch, setBranch] = useState<QrResolve | null>(null);
|
||||
const [categories, setCategories] = useState<
|
||||
Awaited<ReturnType<typeof fetchBranchPublicMenu>>["categories"]
|
||||
>([]);
|
||||
const [activeCategory, setActiveCategory] = useState("");
|
||||
const [cart, setCart] = useState<QrCartLine[]>([]);
|
||||
const [guestName, setGuestName] = useState("");
|
||||
const [guestPhone, setGuestPhone] = useState("");
|
||||
const [orderNumber, setOrderNumber] = useState("");
|
||||
const [activeTrack, setActiveTrack] = useState<{ orderId: string; token: string } | null>(null);
|
||||
const [tableOrders, setTableOrders] = useState<GuestOrderRef[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [menuTheme, setMenuTheme] = useState<CafeTheme | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [view3dItem, setView3dItem] = useState<QrPublicMenuItem | null>(null);
|
||||
const [security, setSecurity] = useState<PublicSecurityConfig | null>(null);
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||
const [callWaiterState, setCallWaiterState] = useState<"idle" | "sending" | "sent" | "cooldown">("idle");
|
||||
|
||||
const themeColors = useMemo(
|
||||
() => resolveQrGuestColors(menuTheme, branch?.primaryColor),
|
||||
[menuTheme, branch?.primaryColor]
|
||||
);
|
||||
const primary = themeColors.primary;
|
||||
const menuStyle = menuTheme?.menuStyle ?? "cards";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchPublicSecurityConfig()
|
||||
.then((cfg) => {
|
||||
if (!cancelled) setSecurity(cfg);
|
||||
})
|
||||
.catch(() => {
|
||||
/* optional — orders still work when captcha is off */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const resolved = await resolveQrCode(code);
|
||||
if (cancelled) return;
|
||||
if (resolved.isCleaning) {
|
||||
setError(t("tableCleaning"));
|
||||
setScreen("error");
|
||||
return;
|
||||
}
|
||||
setBranch(resolved);
|
||||
const menu = await fetchBranchPublicMenu(resolved.cafeId, resolved.branchId);
|
||||
if (cancelled) return;
|
||||
const cats = menu.categories ?? [];
|
||||
setCategories(cats);
|
||||
setMenuTheme(normalizeCafeTheme(menu.theme ?? undefined));
|
||||
setActiveCategory(QR_ALL_CATEGORY_ID);
|
||||
if (cats.length === 0) {
|
||||
setError(t("emptyMenu"));
|
||||
setScreen("error");
|
||||
return;
|
||||
}
|
||||
setScreen("menu");
|
||||
setError("");
|
||||
setTableOrders(ordersForTable(loadGuestOrders(), resolved.cafeId, resolved.tableId));
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
const message =
|
||||
err instanceof ApiClientError
|
||||
? err.code === "NOT_FOUND"
|
||||
? t("tableNotFound")
|
||||
: `${t("loadError")} (${err.message})`
|
||||
: t("loadError");
|
||||
setError(message);
|
||||
setScreen("error");
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, t]);
|
||||
|
||||
const totalItems = cart.reduce((s, c) => s + c.qty, 0);
|
||||
const totalPrice = cart.reduce(
|
||||
(s, c) => s + effectiveLinePrice(c.item) * c.qty,
|
||||
0
|
||||
);
|
||||
|
||||
const allItems = useMemo(
|
||||
() => categories.flatMap((c) => c.items ?? []),
|
||||
[categories]
|
||||
);
|
||||
|
||||
const searchTrimmed = searchQuery.trim();
|
||||
const isSearching = searchTrimmed.length > 0;
|
||||
const showAllGrouped =
|
||||
!isSearching && activeCategory === QR_ALL_CATEGORY_ID;
|
||||
|
||||
const activeItems = useMemo(() => {
|
||||
const pool = isSearching
|
||||
? allItems
|
||||
: activeCategory === QR_ALL_CATEGORY_ID
|
||||
? allItems
|
||||
: categories.find((c) => c.id === activeCategory)?.items ?? [];
|
||||
|
||||
if (!isSearching) return pool;
|
||||
return pool.filter((item) => menuItemMatchesSearch(item, searchTrimmed, locale));
|
||||
}, [allItems, categories, activeCategory, isSearching, searchTrimmed, locale]);
|
||||
|
||||
const categoryNameById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const c of categories) map.set(c.id, c.name);
|
||||
return map;
|
||||
}, [categories]);
|
||||
|
||||
const addToCart = useCallback((item: QrPublicMenuItem) => {
|
||||
setCart((prev) => {
|
||||
const idx = prev.findIndex((c) => c.item.id === item.id);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx]!, qty: next[idx]!.qty + 1 };
|
||||
return next;
|
||||
}
|
||||
return [...prev, { item, qty: 1 }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeFromCart = useCallback((itemId: string) => {
|
||||
setCart((prev) => {
|
||||
const idx = prev.findIndex((c) => c.item.id === itemId);
|
||||
if (idx < 0) return prev;
|
||||
const next = [...prev];
|
||||
if (next[idx]!.qty > 1) {
|
||||
next[idx] = { ...next[idx]!, qty: next[idx]!.qty - 1 };
|
||||
return next;
|
||||
}
|
||||
next.splice(idx, 1);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refreshTableOrders = useCallback(() => {
|
||||
if (!branch) return;
|
||||
setTableOrders(
|
||||
ordersForTable(loadGuestOrders(), branch.cafeId, branch.tableId)
|
||||
);
|
||||
}, [branch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (screen === "orders") refreshTableOrders();
|
||||
}, [screen, refreshTableOrders]);
|
||||
|
||||
const handleCallWaiter = useCallback(async () => {
|
||||
if (!branch || callWaiterState !== "idle") return;
|
||||
setCallWaiterState("sending");
|
||||
try {
|
||||
await callWaiter(branch.cafeId, branch.tableId);
|
||||
setCallWaiterState("sent");
|
||||
setTimeout(() => setCallWaiterState("cooldown"), 2500);
|
||||
setTimeout(() => setCallWaiterState("idle"), 62_000);
|
||||
} catch (err) {
|
||||
const code = err instanceof ApiClientError ? err.code : null;
|
||||
setCallWaiterState(code === "RATE_LIMITED" ? "cooldown" : "idle");
|
||||
if (code !== "RATE_LIMITED") setTimeout(() => setCallWaiterState("idle"), 3000);
|
||||
}
|
||||
}, [branch, callWaiterState]);
|
||||
|
||||
const captchaRequired =
|
||||
!!security?.captchaRequired && !!security.turnstileSiteKey;
|
||||
|
||||
const submitOrder = async () => {
|
||||
if (!branch || cart.length === 0) return;
|
||||
if (captchaRequired && !captchaToken) {
|
||||
setError(t("captchaRequired"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await placeBranchGuestOrder(branch.cafeId, branch.branchId, {
|
||||
tableId: branch.tableId,
|
||||
guestName: guestName.trim() || null,
|
||||
guestPhone: guestPhone.trim() || null,
|
||||
captchaToken: captchaToken ?? undefined,
|
||||
items: cart.map((c) => ({
|
||||
menuItemId: c.item.id,
|
||||
quantity: c.qty,
|
||||
notes: c.note ?? null,
|
||||
})),
|
||||
});
|
||||
setOrderNumber(result.orderNumber);
|
||||
const orderRef: GuestOrderRef = {
|
||||
orderId: result.orderId,
|
||||
trackingToken: result.trackingToken,
|
||||
orderNumber: result.orderNumber,
|
||||
createdAt: new Date().toISOString(),
|
||||
cafeId: branch.cafeId,
|
||||
branchId: branch.branchId,
|
||||
tableId: branch.tableId,
|
||||
};
|
||||
const saved = saveGuestOrder(orderRef);
|
||||
setCart([]);
|
||||
setCaptchaToken(null);
|
||||
if (saved) {
|
||||
refreshTableOrders();
|
||||
} else {
|
||||
setTableOrders((prev) => {
|
||||
const filtered = prev.filter((o) => o.orderId !== orderRef.orderId);
|
||||
return [orderRef, ...filtered];
|
||||
});
|
||||
}
|
||||
setActiveTrack({ orderId: result.orderId, token: result.trackingToken });
|
||||
setScreen("track");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiClientError) {
|
||||
if (err.code === "RATE_LIMITED") setError(t("rateLimited"));
|
||||
else if (err.code?.startsWith("CAPTCHA")) setError(t("captchaRequired"));
|
||||
else if (err.code === "CAFE_SUSPENDED") setError(t("cafeUnavailable"));
|
||||
else setError(err.message || t("orderError"));
|
||||
} else {
|
||||
setError(t("orderError"));
|
||||
}
|
||||
setScreen("cart");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (screen === "loading") {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-svh flex-col items-center justify-center gap-3 p-6"
|
||||
data-qr-guest-menu
|
||||
style={buildQrThemeCssVars(themeColors)}
|
||||
>
|
||||
<div
|
||||
className="size-10 animate-spin rounded-full border-[3px] border-t-transparent"
|
||||
style={{ borderColor: primary, borderTopColor: "transparent" }}
|
||||
/>
|
||||
<p className="text-sm qr-muted">{t("loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === "error") {
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-svh flex-col items-center justify-center p-6 text-center"
|
||||
data-qr-guest-menu
|
||||
style={buildQrThemeCssVars(themeColors)}
|
||||
>
|
||||
<p className="text-4xl">😕</p>
|
||||
<p className="mt-4 font-medium qr-text">{error}</p>
|
||||
<p className="mt-2 text-sm qr-muted">{t("scanAgain")}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === "track" && activeTrack) {
|
||||
return (
|
||||
<main
|
||||
className="mx-auto min-h-svh max-w-md"
|
||||
dir="rtl"
|
||||
data-qr-guest-menu
|
||||
style={buildQrThemeCssVars(themeColors)}
|
||||
>
|
||||
<QrOrderTrack
|
||||
orderId={activeTrack.orderId}
|
||||
trackingToken={activeTrack.token}
|
||||
primary={primary}
|
||||
onBack={() => setScreen("menu")}
|
||||
/>
|
||||
<QrBottomNav
|
||||
screen={screen}
|
||||
primary={primary}
|
||||
onMenu={() => setScreen("menu")}
|
||||
onOrders={() => {
|
||||
refreshTableOrders();
|
||||
setScreen("orders");
|
||||
}}
|
||||
callWaiterState={callWaiterState}
|
||||
onCallWaiter={() => void handleCallWaiter()}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === "orders" && branch) {
|
||||
return (
|
||||
<main
|
||||
className="mx-auto flex min-h-svh max-w-md flex-col"
|
||||
dir="rtl"
|
||||
data-qr-guest-menu
|
||||
style={buildQrThemeCssVars(themeColors)}
|
||||
>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<h2 className="mb-3 text-lg font-semibold qr-text">{t("myOrders")}</h2>
|
||||
{tableOrders.length === 0 ? (
|
||||
<p className="text-sm qr-muted">{t("noOrders")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tableOrders.map((o) => (
|
||||
<button
|
||||
key={o.orderId}
|
||||
type="button"
|
||||
className="w-full rounded-xl border qr-border qr-surface p-4 text-start transition"
|
||||
style={{ borderColor: `color-mix(in srgb, ${primary} 35%, transparent)` }}
|
||||
onClick={() => {
|
||||
setActiveTrack({ orderId: o.orderId, token: o.trackingToken });
|
||||
setScreen("track");
|
||||
}}
|
||||
>
|
||||
<p className="font-medium qr-text">{o.orderNumber}</p>
|
||||
<p className="text-xs qr-muted">
|
||||
{new Date(o.createdAt).toLocaleString("fa-IR")}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<QrBottomNav
|
||||
screen={screen}
|
||||
primary={primary}
|
||||
onMenu={() => setScreen("menu")}
|
||||
onOrders={() => setScreen("orders")}
|
||||
callWaiterState={callWaiterState}
|
||||
onCallWaiter={() => void handleCallWaiter()}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === "cart") {
|
||||
return (
|
||||
<div
|
||||
className="mx-auto min-h-svh max-w-md p-4"
|
||||
dir="rtl"
|
||||
data-qr-guest-menu
|
||||
style={buildQrThemeCssVars(themeColors)}
|
||||
>
|
||||
<header className="mb-4 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setScreen("menu")}>
|
||||
←
|
||||
</Button>
|
||||
<h2 className="text-lg font-semibold qr-text">{t("cartTitle")}</h2>
|
||||
</header>
|
||||
<div className="rounded-xl border qr-border qr-surface">
|
||||
{cart.map((c) => (
|
||||
<div
|
||||
key={c.item.id}
|
||||
className="flex items-center justify-between gap-3 border-b px-3 py-3 last:border-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<MenuItemLabels item={c.item} lines={1} primaryClassName="text-sm" />
|
||||
<p className="text-sm font-medium" style={{ color: primary }}>
|
||||
{formatCurrency(effectiveLinePrice(c.item), "fa-IR")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<QtyButton
|
||||
label="−"
|
||||
onClick={() => removeFromCart(c.item.id)}
|
||||
variant="outline"
|
||||
color={primary}
|
||||
/>
|
||||
<span className="min-w-6 text-center font-semibold">{c.qty}</span>
|
||||
<QtyButton
|
||||
label="+"
|
||||
onClick={() => addToCart(c.item)}
|
||||
variant="filled"
|
||||
color={primary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<Input
|
||||
value={guestName}
|
||||
onChange={(e) => setGuestName(e.target.value)}
|
||||
placeholder={t("guestName")}
|
||||
className="text-end"
|
||||
/>
|
||||
<Input
|
||||
value={guestPhone}
|
||||
onChange={(e) => setGuestPhone(e.target.value)}
|
||||
placeholder={t("guestPhone")}
|
||||
inputMode="tel"
|
||||
className="text-end"
|
||||
/>
|
||||
</div>
|
||||
{captchaRequired && security?.turnstileSiteKey ? (
|
||||
<div className="mt-4">
|
||||
<QrTurnstile
|
||||
siteKey={security.turnstileSiteKey}
|
||||
onToken={(token) => {
|
||||
setCaptchaToken(token);
|
||||
if (error === t("captchaRequired")) setError("");
|
||||
}}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="mt-3 text-sm text-destructive">{error}</p>
|
||||
) : null}
|
||||
<div className="mt-4 rounded-xl border qr-border qr-surface p-4">
|
||||
<div className="mb-3 flex justify-between font-semibold">
|
||||
<span>{t("subtotal")}</span>
|
||||
<span style={{ color: primary }}>
|
||||
{formatCurrency(totalPrice, "fa-IR")}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={submitting}
|
||||
style={{ backgroundColor: primary }}
|
||||
onClick={() => void submitOrder()}
|
||||
>
|
||||
{submitting ? t("loading") : t("placeOrder")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const menuTexture = normalizeMenuTexture(menuTheme?.menuTexture);
|
||||
const textureShell = qrMenuTextureShellProps(menuTexture, themeColors.background);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx-auto flex min-h-svh max-w-md flex-col"
|
||||
dir="rtl"
|
||||
data-qr-guest-menu
|
||||
data-qr-texture={textureShell["data-qr-texture"]}
|
||||
style={{
|
||||
...textureShell.style,
|
||||
...buildQrThemeCssVars(themeColors),
|
||||
}}
|
||||
>
|
||||
<header
|
||||
className="border-b qr-border px-4 py-5 text-center qr-surface"
|
||||
>
|
||||
{branch?.logoUrl ? (
|
||||
<img
|
||||
src={resolveMediaUrl(branch.logoUrl)}
|
||||
alt={branch.cafeName}
|
||||
className="mx-auto mb-2 size-14 rounded-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<h1 className="text-lg font-bold qr-text">{branch?.cafeName}</h1>
|
||||
<p className="text-sm qr-muted">{branch?.branchName}</p>
|
||||
<p className="mt-1 text-xs qr-muted">
|
||||
{branch?.welcomeText} — {t("tableLabel")} {branch?.tableNumber}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-auto",
|
||||
totalItems > 0 ? "pb-[8.5rem]" : "pb-20"
|
||||
)}
|
||||
>
|
||||
<QrGuestMenuBody
|
||||
showCartBar={false}
|
||||
menuStyle={menuStyle}
|
||||
colors={themeColors}
|
||||
categories={categories}
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={setActiveCategory}
|
||||
activeItems={activeItems}
|
||||
showAllGrouped={showAllGrouped}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
isSearching={isSearching}
|
||||
categoryNameById={categoryNameById}
|
||||
cart={cart}
|
||||
onAdd={addToCart}
|
||||
onRemove={removeFromCart}
|
||||
onView3d={setView3dItem}
|
||||
totalItems={totalItems}
|
||||
totalPrice={totalPrice}
|
||||
onOpenCart={() => setScreen("cart")}
|
||||
labels={{
|
||||
emptyCategory: isSearching ? t("searchNoResults") : t("emptyCategory"),
|
||||
addToCart: t("addToCart"),
|
||||
checkout: t("placeOrder"),
|
||||
searchPlaceholder: t("searchPlaceholder"),
|
||||
allCategories: t("allCategories"),
|
||||
clearSearch: t("clearSearch"),
|
||||
view3d: t("view3d"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{totalItems > 0 ? (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-[3.25rem] z-40 mx-auto max-w-md px-3 pb-1">
|
||||
<div
|
||||
className="pointer-events-auto rounded-2xl p-1 shadow-lg backdrop-blur-sm qr-surface"
|
||||
style={{ backgroundColor: `color-mix(in srgb, ${themeColors.surface} 95%, transparent)` }}
|
||||
>
|
||||
<QrFloatingCartBar
|
||||
totalItems={totalItems}
|
||||
totalPrice={totalPrice}
|
||||
colors={themeColors}
|
||||
onOpenCart={() => setScreen("cart")}
|
||||
labels={{
|
||||
emptyCategory: "",
|
||||
addToCart: t("addToCart"),
|
||||
checkout: t("placeOrder"),
|
||||
searchPlaceholder: "",
|
||||
allCategories: "",
|
||||
clearSearch: "",
|
||||
view3d: "",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{view3dItem ? (
|
||||
<QrMenu3dSheet
|
||||
item={view3dItem}
|
||||
primary={primary}
|
||||
onClose={() => setView3dItem(null)}
|
||||
onAdd={() => addToCart(view3dItem)}
|
||||
addLabel={t("addToCart")}
|
||||
/>
|
||||
) : null}
|
||||
<QrBottomNav
|
||||
screen={screen}
|
||||
primary={primary}
|
||||
onMenu={() => setScreen("menu")}
|
||||
onOrders={() => {
|
||||
refreshTableOrders();
|
||||
setScreen("orders");
|
||||
}}
|
||||
callWaiterState={callWaiterState}
|
||||
onCallWaiter={() => void handleCallWaiter()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QrBottomNav({
|
||||
screen,
|
||||
primary,
|
||||
onMenu,
|
||||
onOrders,
|
||||
callWaiterState,
|
||||
onCallWaiter,
|
||||
}: {
|
||||
screen: Screen;
|
||||
primary: string;
|
||||
onMenu: () => void;
|
||||
onOrders: () => void;
|
||||
callWaiterState: "idle" | "sending" | "sent" | "cooldown";
|
||||
onCallWaiter: () => void;
|
||||
}) {
|
||||
const t = useTranslations("qrMenu");
|
||||
|
||||
const callLabel =
|
||||
callWaiterState === "sending"
|
||||
? "..."
|
||||
: callWaiterState === "sent"
|
||||
? t("callWaiterSent")
|
||||
: callWaiterState === "cooldown"
|
||||
? t("callWaiterCooldown")
|
||||
: t("callWaiter");
|
||||
|
||||
return (
|
||||
<nav className="fixed inset-x-0 bottom-0 z-30 mx-auto flex max-w-md items-stretch border-t qr-border qr-surface">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-3 text-sm font-medium",
|
||||
screen === "menu" || screen === "cart" ? "qr-text" : "qr-muted"
|
||||
)}
|
||||
style={screen === "menu" || screen === "cart" ? { color: primary } : undefined}
|
||||
onClick={onMenu}
|
||||
>
|
||||
{t("tabMenu")}
|
||||
</button>
|
||||
|
||||
{/* Call waiter — centre prominent button */}
|
||||
<div className="flex items-center justify-center px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCallWaiter}
|
||||
disabled={callWaiterState !== "idle"}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-semibold transition-all duration-200 shadow-md active:scale-95",
|
||||
callWaiterState === "sent"
|
||||
? "bg-emerald-500 text-white"
|
||||
: callWaiterState === "cooldown"
|
||||
? "bg-gray-200 text-gray-400 cursor-not-allowed"
|
||||
: callWaiterState === "sending"
|
||||
? "opacity-70 cursor-wait text-white"
|
||||
: "text-white"
|
||||
)}
|
||||
style={
|
||||
callWaiterState === "idle" || callWaiterState === "sending"
|
||||
? { backgroundColor: primary }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block transition-transform",
|
||||
callWaiterState === "sent" && "animate-bounce"
|
||||
)}
|
||||
>
|
||||
🔔
|
||||
</span>
|
||||
<span className="max-w-[7rem] truncate">{callLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-3 text-sm font-medium",
|
||||
screen === "orders" || screen === "track" ? "qr-text" : "qr-muted"
|
||||
)}
|
||||
style={screen === "orders" || screen === "track" ? { color: primary } : undefined}
|
||||
onClick={onOrders}
|
||||
>
|
||||
{t("tabOrders")}
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function effectiveLinePrice(item: QrPublicMenuItem): number {
|
||||
const discount = item.discountPercent > 0 ? item.discountPercent : 0;
|
||||
return Math.round(item.price * (1 - discount / 100));
|
||||
}
|
||||
|
||||
function QtyButton({
|
||||
label,
|
||||
onClick,
|
||||
variant,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant: "outline" | "filled";
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex size-8 items-center justify-center rounded-full text-lg leading-none ${
|
||||
variant === "filled" ? "text-white" : ""
|
||||
}`}
|
||||
style={
|
||||
variant === "filled"
|
||||
? { backgroundColor: color }
|
||||
: { border: `1.5px solid ${color}`, color }
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { MenuItemModelViewer } from "@/components/menu/menu-item-model-viewer";
|
||||
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import type { QrPublicMenuItem } from "@/lib/api/qr-public";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type QrMenu3dSheetProps = {
|
||||
item: QrPublicMenuItem;
|
||||
primary: string;
|
||||
onClose: () => void;
|
||||
onAdd: () => void;
|
||||
addLabel: string;
|
||||
};
|
||||
|
||||
export function QrMenu3dSheet({ item, primary, onClose, onAdd, addLabel }: QrMenu3dSheetProps) {
|
||||
const t = useTranslations("qrMenu");
|
||||
|
||||
if (!item.model3dUrl) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col bg-black/50 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("view3d")}
|
||||
>
|
||||
<div className="mx-auto mt-auto flex w-full max-w-md flex-col rounded-t-2xl qr-surface shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<MenuItemLabels item={item} lines={1} primaryClassName="text-base font-semibold" />
|
||||
<p className="text-sm font-medium" style={{ color: primary }}>
|
||||
{formatCurrency(effectiveItemPrice(item), "fa-IR")}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" size="icon" variant="ghost" onClick={onClose} aria-label={t("close3d")}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="px-4 pt-2 text-center text-xs qr-muted">{t("view3dHint")}</p>
|
||||
<div className="min-h-[50vh] w-full px-2 pb-2">
|
||||
<MenuItemModelViewer
|
||||
modelUrl={item.model3dUrl}
|
||||
posterUrl={item.imageUrl}
|
||||
alt={item.name}
|
||||
className="rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t p-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full text-white"
|
||||
style={{ backgroundColor: primary }}
|
||||
onClick={() => {
|
||||
onAdd();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function effectiveItemPrice(item: QrPublicMenuItem): number {
|
||||
const discount = item.discountPercent > 0 ? item.discountPercent : 0;
|
||||
return Math.round(item.price * (1 - discount / 100));
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import * as signalR from "@microsoft/signalr";
|
||||
import { Check } from "lucide-react";
|
||||
import { fetchOrderTrack, type QrOrderTrack } from "@/lib/api/qr-public";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type QrOrderTrackProps = {
|
||||
orderId: string;
|
||||
trackingToken: string;
|
||||
primary: string;
|
||||
onBack?: () => void;
|
||||
};
|
||||
|
||||
export function QrOrderTrack({ orderId, trackingToken, primary, onBack }: QrOrderTrackProps) {
|
||||
const t = useTranslations("qrMenu.tracking");
|
||||
const [track, setTrack] = useState<QrOrderTrack | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchOrderTrack(orderId, trackingToken);
|
||||
setTrack(data);
|
||||
setError(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
}, [orderId, trackingToken]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const id = setInterval(() => void load(), 8000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(`${baseUrl}/hubs/guest-order`)
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
connection
|
||||
.start()
|
||||
.then(() => connection.invoke("JoinOrder", orderId, trackingToken))
|
||||
.catch(() => undefined);
|
||||
|
||||
connection.on("OrderTrackUpdated", (payload: QrOrderTrack) => {
|
||||
setTrack(payload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
void connection.stop();
|
||||
};
|
||||
}, [orderId, trackingToken]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="p-6 text-center text-sm qr-muted">{t("loadError")}</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!track) {
|
||||
return (
|
||||
<div className="flex justify-center p-8">
|
||||
<div
|
||||
className="size-8 animate-spin rounded-full border-2 border-t-transparent"
|
||||
style={{ borderColor: primary, borderTopColor: "transparent" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusKey = track.statusLabelKey;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
{onBack ? (
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
← {t("back")}
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="rounded-xl border qr-border qr-surface p-4 text-center">
|
||||
<p className="text-[11px] uppercase tracking-[0.06em] qr-muted">
|
||||
{t("orderNumber")}
|
||||
</p>
|
||||
<p className="text-lg font-bold qr-text">{track.orderNumber}</p>
|
||||
<p className="mt-2 text-sm font-medium" style={{ color: primary }}>
|
||||
{t(`status.${statusKey}`)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs qr-muted">
|
||||
{formatCurrency(track.total, "fa-IR")}
|
||||
{track.tableNumber ? ` · ${t("table")} ${track.tableNumber}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol className="space-y-0 rounded-xl border qr-border qr-surface p-4">
|
||||
{track.steps.map((step) => (
|
||||
<li key={step.key} className="flex gap-3 pb-4 last:pb-0">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-full border-2",
|
||||
step.isComplete ? "border-transparent text-white" : "qr-border qr-fill-muted"
|
||||
)}
|
||||
style={step.isComplete ? { backgroundColor: primary } : undefined}
|
||||
>
|
||||
{step.isComplete ? <Check className="size-4" /> : null}
|
||||
</div>
|
||||
<div className="min-w-0 pt-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
step.isCurrent && "qr-text",
|
||||
!step.isCurrent && !step.isComplete && "qr-muted"
|
||||
)}
|
||||
>
|
||||
{t(`steps.${step.labelKey}`)}
|
||||
</p>
|
||||
{step.isCurrent ? (
|
||||
<p className="text-xs qr-muted">{t("currentStep")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{statusKey === "ready" ? (
|
||||
<p
|
||||
className="rounded-lg px-3 py-2 text-center text-sm font-medium"
|
||||
style={{ backgroundColor: `color-mix(in srgb, ${primary} 12%, #fff)`, color: primary }}
|
||||
>
|
||||
{t("readyHint")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type TurnstileApi = {
|
||||
render: (
|
||||
container: HTMLElement,
|
||||
options: {
|
||||
sitekey: string;
|
||||
callback: (token: string) => void;
|
||||
"expired-callback"?: () => void;
|
||||
"error-callback"?: () => void;
|
||||
theme?: "light" | "dark" | "auto";
|
||||
}
|
||||
) => string;
|
||||
remove: (widgetId: string) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileApi;
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT_ID = "cf-turnstile-script";
|
||||
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
||||
|
||||
type QrTurnstileProps = {
|
||||
siteKey: string;
|
||||
onToken: (token: string) => void;
|
||||
onExpire?: () => void;
|
||||
};
|
||||
|
||||
export function QrTurnstile({ siteKey, onToken, onExpire }: QrTurnstileProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const widgetIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !siteKey) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const renderWidget = () => {
|
||||
if (cancelled || !containerRef.current || !window.turnstile) return;
|
||||
if (widgetIdRef.current) {
|
||||
window.turnstile.remove(widgetIdRef.current);
|
||||
widgetIdRef.current = null;
|
||||
}
|
||||
widgetIdRef.current = window.turnstile.render(containerRef.current, {
|
||||
sitekey: siteKey,
|
||||
theme: "auto",
|
||||
callback: (token) => onToken(token),
|
||||
"expired-callback": () => onExpire?.(),
|
||||
"error-callback": () => onExpire?.(),
|
||||
});
|
||||
};
|
||||
|
||||
const ensureScript = () => {
|
||||
if (window.turnstile) {
|
||||
renderWidget();
|
||||
return;
|
||||
}
|
||||
const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null;
|
||||
if (existing) {
|
||||
existing.addEventListener("load", renderWidget);
|
||||
return () => existing.removeEventListener("load", renderWidget);
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
script.id = SCRIPT_ID;
|
||||
script.src = SCRIPT_SRC;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = renderWidget;
|
||||
document.head.appendChild(script);
|
||||
return () => {
|
||||
script.onload = null;
|
||||
};
|
||||
};
|
||||
|
||||
const cleanupScript = ensureScript();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanupScript?.();
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
window.turnstile.remove(widgetIdRef.current);
|
||||
widgetIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [siteKey, onToken, onExpire]);
|
||||
|
||||
return <div ref={containerRef} className="flex justify-center py-2" />;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { apiGet } from "@/lib/api/client";
|
||||
import type { QueueBoard } from "@/lib/api/types";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
|
||||
export function QueueDisplayScreen() {
|
||||
const t = useTranslations("queue");
|
||||
const locale = useLocale();
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const searchParams = useSearchParams();
|
||||
const branchId = searchParams.get("branchId");
|
||||
const branchQuery = branchId ? `?branchId=${encodeURIComponent(branchId)}` : "";
|
||||
|
||||
const { data: board } = useQuery({
|
||||
queryKey: ["queue-today", cafeId, branchId, "display"],
|
||||
queryFn: () => apiGet<QueueBoard>(`/api/cafes/${cafeId}/queue/today${branchQuery}`),
|
||||
enabled: !!cafeId,
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
|
||||
const waiting = board?.tickets.filter((x) => x.status === "Waiting") ?? [];
|
||||
const nowServing = board?.nowServing;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col bg-neutral-950 text-white">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-6 py-3">
|
||||
<p className="text-sm font-medium uppercase tracking-[0.12em] text-white/60">
|
||||
{t("title")} · {t("displayMode")}
|
||||
</p>
|
||||
<Link
|
||||
href="/queue"
|
||||
className="text-xs text-white/50 underline-offset-2 hover:text-white hover:underline"
|
||||
>
|
||||
{t("exitDisplay")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-10 px-6 py-8">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium uppercase tracking-[0.14em] text-amber-400/90">
|
||||
{t("nowServing")}
|
||||
</p>
|
||||
<p className="mt-2 text-[min(28vw,12rem)] font-bold tabular-nums leading-none text-white">
|
||||
{nowServing != null ? formatNumber(nowServing, numberLocale) : "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full max-w-4xl gap-6 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 px-6 py-5 text-center">
|
||||
<p className="text-xs uppercase tracking-wide text-white/50">{t("lastIssued")}</p>
|
||||
<p className="mt-2 text-4xl font-semibold tabular-nums">
|
||||
{formatNumber(board?.lastIssued ?? 0, numberLocale)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 px-6 py-5 text-center">
|
||||
<p className="text-xs uppercase tracking-wide text-white/50">{t("displayWaitingLabel")}</p>
|
||||
<p className="mt-2 text-4xl font-semibold tabular-nums text-amber-300">
|
||||
{formatNumber(board?.waitingCount ?? 0, numberLocale)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{waiting.length > 0 ? (
|
||||
<div className="w-full max-w-3xl">
|
||||
<p className="mb-3 text-center text-xs uppercase tracking-wide text-white/40">
|
||||
{t("displayUpNext")}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{waiting.slice(0, 12).map((ticket) => (
|
||||
<span
|
||||
key={ticket.id}
|
||||
className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-5 py-3 text-2xl font-bold tabular-nums"
|
||||
>
|
||||
{formatNumber(ticket.number, numberLocale)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { apiGet, apiPost } from "@/lib/api/client";
|
||||
import type { QueueBoard, QueueTicket, QueueTicketStatus } from "@/lib/api/types";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useBranchStore } from "@/lib/stores/branch.store";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { Monitor } from "lucide-react";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
|
||||
const statusVariant: Record<QueueTicketStatus, string> = {
|
||||
Waiting: "bg-amber-100 text-amber-900 border-amber-200",
|
||||
Called: "bg-blue-100 text-blue-900 border-blue-200",
|
||||
Done: "bg-slate-100 text-slate-600 border-slate-200",
|
||||
Cancelled: "bg-red-50 text-red-800 border-red-200",
|
||||
};
|
||||
|
||||
export function QueueScreen() {
|
||||
const t = useTranslations("queue");
|
||||
const tCommon = useTranslations("common");
|
||||
const locale = useLocale();
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const branchId = useBranchStore((s) => s.branchId);
|
||||
const queryClient = useQueryClient();
|
||||
const [label, setLabel] = useState("");
|
||||
const branchQuery = branchId ? `?branchId=${encodeURIComponent(branchId)}` : "";
|
||||
|
||||
const { data: board, isLoading } = useQuery({
|
||||
queryKey: ["queue-today", cafeId, branchId],
|
||||
queryFn: () =>
|
||||
apiGet<QueueBoard>(`/api/cafes/${cafeId}/queue/today${branchQuery}`),
|
||||
enabled: !!cafeId,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["queue-today", cafeId] });
|
||||
}, [queryClient, cafeId]);
|
||||
|
||||
const issueNext = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<QueueTicket>(`/api/cafes/${cafeId}/queue/next`, {
|
||||
branchId: branchId ?? undefined,
|
||||
customerLabel: label.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setLabel("");
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const callNext = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<QueueBoard>(`/api/cafes/${cafeId}/queue/call-next${branchQuery}`, {}),
|
||||
onSuccess: () => refresh(),
|
||||
});
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
const displayHref = branchId
|
||||
? `/queue/display?branchId=${encodeURIComponent(branchId)}`
|
||||
: "/queue/display";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
action={
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={displayHref} target="_blank" rel="noopener noreferrer">
|
||||
<Monitor className="h-4 w-4 me-2" aria-hidden />
|
||||
{t("openDisplay")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card className="rounded-xl border-2 border-primary/30 bg-primary/5 lg:col-span-1">
|
||||
<CardContent className="flex flex-col items-center justify-center gap-2 pt-8 pb-8">
|
||||
<p className="text-sm text-muted-foreground">{t("nowServing")}</p>
|
||||
<p className="text-6xl font-bold tabular-nums text-primary">
|
||||
{board?.nowServing != null
|
||||
? formatNumber(board.nowServing, numberLocale)
|
||||
: "—"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("lastIssued")}: {formatNumber(board?.lastIssued ?? 0, numberLocale)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("waitingCount", { count: board?.waitingCount ?? 0 })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-xl lg:col-span-2">
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t("customerLabelPlaceholder")}
|
||||
className="min-w-[12rem] flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !issueNext.isPending) {
|
||||
e.preventDefault();
|
||||
issueNext.mutate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => issueNext.mutate()}
|
||||
disabled={issueNext.isPending}
|
||||
>
|
||||
{issueNext.isPending ? "..." : t("issueNext")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => callNext.mutate()}
|
||||
disabled={callNext.isPending || (board?.waitingCount ?? 0) === 0}
|
||||
>
|
||||
{callNext.isPending ? "..." : t("callNext")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("dailyResetHint")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
||||
) : !board?.tickets.length ? (
|
||||
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
|
||||
{board.tickets.map((ticket) => (
|
||||
<Card
|
||||
key={ticket.id}
|
||||
className={cn(
|
||||
"rounded-lg border",
|
||||
ticket.status === "Called" && "ring-2 ring-primary"
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex items-center justify-between gap-2 pt-4 pb-4">
|
||||
<span className="text-2xl font-bold tabular-nums">
|
||||
{formatNumber(ticket.number, numberLocale)}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-[10px]", statusVariant[ticket.status])}
|
||||
>
|
||||
{t(`status.${ticket.status}`)}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
{ticket.customerLabel ? (
|
||||
<p className="truncate px-4 pb-3 text-xs text-muted-foreground">
|
||||
{ticket.customerLabel}
|
||||
</p>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
function ChartAreaSkeleton() {
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-end gap-2 pt-4">
|
||||
<div className="flex h-full items-end gap-1">
|
||||
{[40, 65, 50, 80, 55, 70, 45].map((h, i) => (
|
||||
<Skeleton key={i} className="flex-1 rounded-t-md" style={{ height: `${h}%` }} />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartPieSkeleton() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Skeleton className="h-40 w-40 rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportsChartsFallback() {
|
||||
const t = useTranslations("reports");
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card className="rounded-xl border border-border/80 bg-card lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("revenueChartTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-72">
|
||||
<ChartAreaSkeleton />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="rounded-xl border border-border/80 bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("paymentMixTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-72">
|
||||
<ChartPieSkeleton />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
type LegendProps,
|
||||
} from "recharts";
|
||||
import { chartColor } from "@/lib/reports/analytics";
|
||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { ReportsChartsProps } from "@/components/reports/reports-charts.types";
|
||||
|
||||
export type { ReportsChartPoint, ReportsPieSlice, ReportsChartsProps } from "@/components/reports/reports-charts.types";
|
||||
|
||||
export function ReportsCharts({
|
||||
isLoading,
|
||||
numberLocale,
|
||||
chartData,
|
||||
pieData,
|
||||
branchCompareData,
|
||||
showBranchCompare,
|
||||
branches,
|
||||
}: ReportsChartsProps) {
|
||||
const t = useTranslations("reports");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card className="rounded-xl border border-border/80 bg-card lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("revenueChartTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-72">
|
||||
{isLoading ? (
|
||||
<ChartAreaSkeleton />
|
||||
) : chartData.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("noData")}</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="revFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#0F6E56" stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor="#0F6E56" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11 }} interval="preserveStartEnd" />
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => formatNumber(Number(v), numberLocale)}
|
||||
width={56}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => formatCurrency(value, numberLocale)}
|
||||
labelFormatter={(_, payload) =>
|
||||
payload?.[0]?.payload?.date
|
||||
? String(payload[0].payload.date)
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name={t("revenue")}
|
||||
stroke="#0F6E56"
|
||||
fill="url(#revFill)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-xl border border-border/80 bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("paymentMixTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-72">
|
||||
{isLoading ? (
|
||||
<ChartPieSkeleton />
|
||||
) : pieData.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("noData")}</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={48}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{pieData.map((entry) => (
|
||||
<Cell key={entry.key} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value: number) => formatCurrency(value, numberLocale)} />
|
||||
<Legend content={<ChartLegend />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{showBranchCompare ? (
|
||||
<Card className="rounded-xl border border-border/80 bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("branchCompareTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-80">
|
||||
{isLoading ? (
|
||||
<ChartBarSkeleton />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={branchCompareData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis
|
||||
tickFormatter={(v) => formatNumber(Number(v), numberLocale)}
|
||||
width={56}
|
||||
/>
|
||||
<Tooltip formatter={(value: number) => formatCurrency(value, numberLocale)} />
|
||||
<Legend content={<ChartLegend />} />
|
||||
{branches.map((b, i) => (
|
||||
<Bar
|
||||
key={b.id}
|
||||
dataKey={b.id}
|
||||
name={b.name}
|
||||
fill={chartColor(i)}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartLegend({ payload }: LegendProps) {
|
||||
if (!payload?.length) return null;
|
||||
return (
|
||||
<ul className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 pt-3">
|
||||
{payload.map((entry, index) => (
|
||||
<li
|
||||
key={`legend-${String(entry.value)}-${index}`}
|
||||
className="flex items-center gap-2 text-xs text-foreground"
|
||||
>
|
||||
<span
|
||||
className="inline-block size-2.5 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="leading-none">{entry.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartAreaSkeleton() {
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-end gap-2 pt-4">
|
||||
<div className="flex h-full items-end gap-1">
|
||||
{[40, 65, 50, 80, 55, 70, 45].map((h, i) => (
|
||||
<Skeleton key={i} className="flex-1 rounded-t-md" style={{ height: `${h}%` }} />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartPieSkeleton() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Skeleton className="h-40 w-40 rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartBarSkeleton() {
|
||||
return (
|
||||
<div className="flex h-full items-end gap-2 pt-4">
|
||||
{[55, 70, 45, 80, 60].map((h, i) => (
|
||||
<Skeleton key={i} className="flex-1 rounded-t-md" style={{ height: `${h}%` }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export type ReportsChartPoint = {
|
||||
date: string;
|
||||
label: string;
|
||||
revenue: number;
|
||||
};
|
||||
|
||||
export type ReportsPieSlice = {
|
||||
key: string;
|
||||
name: string;
|
||||
value: number;
|
||||
fill: string;
|
||||
};
|
||||
|
||||
export type ReportsChartsProps = {
|
||||
isLoading: boolean;
|
||||
numberLocale: string;
|
||||
chartData: ReportsChartPoint[];
|
||||
pieData: ReportsPieSlice[];
|
||||
branchCompareData: Array<Record<string, string | number>>;
|
||||
showBranchCompare: boolean;
|
||||
branches: { id: string; name: string }[];
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user