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:
soroush.asadi
2026-05-27 21:34:12 +03:30
parent ef15fd6247
commit 131ecdbbe6
208 changed files with 37123 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Local dotnet run (HTTPS)
# Merchant API (POS / dashboard café features)
# NEXT_PUBLIC_API_URL=https://localhost:7208
# Platform admin API (run Meezi.Admin.API — default https://localhost:7210)
# NEXT_PUBLIC_ADMIN_API_URL=https://localhost:7210
# Docker Compose (HTTP, default API_PORT=5080)
NEXT_PUBLIC_API_URL=http://localhost:5080
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
out
.env
.env.local
*.tsbuildinfo
next-env.d.ts
+29
View File
@@ -0,0 +1,29 @@
# Meezi Dashboard
Next.js 14 POS dashboard for Meezi (میزی).
## Setup
```bash
cd web/dashboard
npm install
cp .env.example .env.local
npm run dev
```
Open [http://localhost:3000/fa/login](http://localhost:3000/fa/login)
Ensure the API is running at `NEXT_PUBLIC_API_URL` (default `https://localhost:7208`).
## Demo login
- Phone: `09121234567`
- OTP: check API console log (`DEV OTP for ...`) when Kavenegar is not configured
## Features (Sprint 4)
- i18n: `fa` (default, RTL), `ar` (RTL), `en` (LTR)
- Dashboard layout with RTL-aware sidebar
- POS screen: menu grid + cart + order submit
- KDS screen: live orders + SignalR + status advance
- Placeholder pages for upcoming modules
+28
View File
@@ -0,0 +1,28 @@
import { test, expect } from "@playwright/test";
const apiURL = process.env.PLAYWRIGHT_API_URL ?? "http://localhost:5080";
test.describe("API health", () => {
test("GET /health returns 200", async ({ request }) => {
const res = await request.get(`${apiURL}/health`);
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body.status).toBe("healthy");
});
test("GET /api/public/security-config", async ({ request }) => {
const res = await request.get(`${apiURL}/api/public/security-config`);
expect(res.ok()).toBeTruthy();
const json = await res.json();
expect(json.success).toBe(true);
expect(json.data).toBeDefined();
});
test("GET /api/public/discover returns list", async ({ request }) => {
const res = await request.get(`${apiURL}/api/public/discover?city=تهران`);
expect(res.ok()).toBeTruthy();
const json = await res.json();
expect(json.success).toBe(true);
expect(Array.isArray(json.data)).toBe(true);
});
});
+10
View File
@@ -0,0 +1,10 @@
import { test, expect } from "@playwright/test";
test.describe("Public discover", () => {
test("discover page loads", async ({ page }) => {
await page.goto("/fa/discover");
await expect(page.getByRole("heading", { name: /کافه‌یاب|Discover cafés/i })).toBeVisible({
timeout: 15_000,
});
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
import withPWAInit from "@ducanh2912/next-pwa";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const withPWA = withPWAInit({
dest: "public",
cacheOnFrontEndNav: true,
aggressiveFrontEndNavCaching: true,
reloadOnOnline: true,
disable: process.env.NODE_ENV === "development",
workboxOptions: {
disableDevLogs: true,
runtimeCaching: [
// App shell: cache-first, very long TTL
{
urlPattern: /\/_next\/static\//,
handler: "CacheFirst",
options: {
cacheName: "static-assets",
expiration: { maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 },
},
},
// API: NetworkFirst — show cached data when offline
{
urlPattern: /\/api\//,
handler: "NetworkFirst",
options: {
cacheName: "api-data",
networkTimeoutSeconds: 5,
expiration: { maxEntries: 300, maxAgeSeconds: 10 * 60 },
},
},
// Menu images & media
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/,
handler: "StaleWhileRevalidate",
options: {
cacheName: "media-cache",
expiration: { maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 },
},
},
],
},
});
const adminWebOrigin =
process.env.ADMIN_WEB_ORIGIN ?? "http://localhost:3102";
const nextConfig: NextConfig = {
output: "standalone",
experimental: {
optimizePackageImports: ["recharts", "lucide-react"],
},
async redirects() {
return [
{
source: "/:locale(fa|ar|en)/admin",
destination: `${adminWebOrigin}/:locale/admin`,
permanent: false,
},
{
source: "/:locale(fa|ar|en)/admin/:path*",
destination: `${adminWebOrigin}/:locale/admin/:path*`,
permanent: false,
},
];
},
};
export default withPWA(withNextIntl(nextConfig));
+9265
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
{
"name": "meezi-dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@google/model-viewer": "^4.2.0",
"three": "^0.163.0",
"@microsoft/signalr": "^8.0.7",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@tanstack/react-query": "^5.59.20",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns-jalali": "^4.1.0-0",
"lucide-react": "^0.454.0",
"next": "16.2.6",
"next-intl": "4.12.0",
"@ducanh2912/next-pwa": "^10.2.9",
"react": "19.2.6",
"react-dom": "19.2.6",
"recharts": "^2.13.3",
"sonner": "^2.0.7",
"tailwind-merge": "^2.5.4",
"zustand": "^5.0.1"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"@types/node": "^22.9.0",
"@types/react": "19.1.4",
"@types/react-dom": "19.1.4",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-next": "16.2.6",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3"
}
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3101";
const apiURL = process.env.PLAYWRIGHT_API_URL ?? "http://localhost:5080";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "list",
use: {
baseURL,
trace: "on-first-retry",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: process.env.CI
? undefined
: {
command: "npm run dev",
url: baseURL,
reuseExistingServer: true,
timeout: 120_000,
},
globalSetup: undefined,
});
export { apiURL };
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -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>;
}
+61
View File
@@ -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>
);
}
+10
View File
@@ -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 });
}
+253
View File
@@ -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);
}
+7
View File
@@ -0,0 +1,7 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
+46
View File
@@ -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: "سیستم فروش حضوری",
},
],
};
}
+11
View File
@@ -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} />;
}
+29
View File
@@ -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>
);
}
+27
View File
@@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show More