feat(dashboard): Next.js 16 merchant panel with offline POS and PWA
Complete merchant dashboard upgrade:
Next.js 16 compatibility:
- Fix params/searchParams typed as Promise<{}> throughout App Router
- Replace middleware.ts with proxy.ts (Next.js 16 convention)
- Remove unused @ts-expect-error directives caught by stricter TS
- Cast dynamic next-intl t() keys to fix TranslateArgs type errors
Offline POS:
- IndexedDB queue (meezi_pos_offline) for orders created while offline
- Zustand sync store tracking queueCount, isSyncing, isOnline
- useOfflineSync hook: auto-syncs on reconnect/visibility-change
- SyncStatusIndicator chip in topbar (amber=offline, blue=syncing)
- submitOrderToApi falls back to local order on network failure
- Local orders skip payment flow; sync on reconnect
PWA (installable):
- @ducanh2912/next-pwa with Workbox runtime caching rules
- Web App Manifest (manifest.ts) — RTL/Farsi, theme #0F6E56
- PWA icons: 192px, 512px, maskable 512px
- next.config.ts replaces next.config.mjs
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
.env
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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));
|
||||
Generated
+9265
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import localFont from "next/font/local";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { Providers } from "@/components/providers";
|
||||
import "../globals.css";
|
||||
|
||||
const vazirmatn = localFont({
|
||||
src: "../../fonts/Vazirmatn-Variable.woff2",
|
||||
variable: "--font-vazirmatn",
|
||||
display: "swap",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
const inter = localFont({
|
||||
src: "../../fonts/Inter-Variable.woff2",
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
if (!routing.locales.includes(locale as "fa" | "ar" | "en")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
setRequestLocale(locale);
|
||||
const messages = await getMessages();
|
||||
const dir = locale === "en" ? "ltr" : "rtl";
|
||||
const fontClass =
|
||||
locale === "en"
|
||||
? inter.variable
|
||||
: vazirmatn.variable;
|
||||
|
||||
return (
|
||||
<html lang={locale} dir={dir}>
|
||||
<body
|
||||
className={`${fontClass} font-sans antialiased ${
|
||||
locale === "en" ? "font-[family-name:var(--font-inter)]" : "font-[family-name:var(--font-vazirmatn)]"
|
||||
}`}
|
||||
>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<Providers>{children}</Providers>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { apiPost, ApiClientError } from "@/lib/api/client";
|
||||
import type { AuthTokenResponse } from "@/lib/api/types";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("auth");
|
||||
const router = useRouter();
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
|
||||
const [phone, setPhone] = useState("09121234567");
|
||||
const [code, setCode] = useState("");
|
||||
const [step, setStep] = useState<"phone" | "otp">("phone");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const authErrorMessage = (err: unknown) => {
|
||||
if (err instanceof ApiClientError) {
|
||||
switch (err.code) {
|
||||
case "RATE_LIMITED":
|
||||
return t("rateLimited");
|
||||
case "NOT_FOUND":
|
||||
return t("notFound");
|
||||
case "SMS_FAILED":
|
||||
return t("smsFailed");
|
||||
case "INVALID_OTP":
|
||||
return t("invalidOtp");
|
||||
default:
|
||||
return err.message;
|
||||
}
|
||||
}
|
||||
return err instanceof Error ? err.message : t("title");
|
||||
};
|
||||
|
||||
const sendOtp = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiPost("/api/auth/send-otp", { phone });
|
||||
setStep("otp");
|
||||
} catch (e) {
|
||||
setError(authErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyOtp = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apiPost<AuthTokenResponse>("/api/auth/verify-otp", {
|
||||
phone,
|
||||
code,
|
||||
});
|
||||
setAuth(data);
|
||||
router.push("/pos");
|
||||
} catch (e) {
|
||||
setError(authErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-primary">{t("title")}</CardTitle>
|
||||
<p className="text-center text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{step === "phone" ? (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!loading) void sendOtp();
|
||||
}}
|
||||
>
|
||||
<LabeledField label={t("phone")} htmlFor="login-phone">
|
||||
<Input
|
||||
id="login-phone"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder={t("phonePlaceholder")}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
autoComplete="tel"
|
||||
/>
|
||||
</LabeledField>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "..." : t("sendOtp")}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!loading) void verifyOtp();
|
||||
}}
|
||||
>
|
||||
<LabeledField label={t("otp")} htmlFor="login-otp">
|
||||
<Input
|
||||
id="login-otp"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder={t("otpPlaceholder")}
|
||||
maxLength={6}
|
||||
dir="ltr"
|
||||
className="text-center tracking-widest"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</LabeledField>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "..." : t("verify")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={() => setStep("phone")}
|
||||
>
|
||||
{t("resend")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-center text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "@/i18n/routing";
|
||||
|
||||
export default async function HomePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
redirect({ href: "/pos", locale });
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Meezi brand */
|
||||
--meezi-green: 162 76% 25%;
|
||||
--meezi-green-tint: 162 52% 92%;
|
||||
--meezi-amber: 38 78% 41%;
|
||||
--meezi-danger: 0 58% 41%;
|
||||
--meezi-info: 210 82% 28%;
|
||||
|
||||
--background: 210 20% 98%;
|
||||
--foreground: 222 47% 11%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--primary: var(--meezi-green);
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 162 30% 94%;
|
||||
--secondary-foreground: 162 76% 20%;
|
||||
--muted: 210 25% 96%;
|
||||
--muted-foreground: 215 16% 47%;
|
||||
--accent: var(--meezi-green-tint);
|
||||
--accent-foreground: 162 76% 20%;
|
||||
--destructive: var(--meezi-danger);
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 214 24% 88%;
|
||||
--input: 214 24% 88%;
|
||||
--ring: var(--meezi-green);
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-vazirmatn), var(--font-inter), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html[lang="en"] body {
|
||||
font-family: var(--font-inter), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html[lang="fa"] body,
|
||||
html[lang="ar"] body {
|
||||
font-family: var(--font-vazirmatn), system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sonner portal — inherit Meezi fonts (toasts render outside main tree) */
|
||||
[data-sonner-toaster],
|
||||
[data-sonner-toast],
|
||||
[data-sonner-toast] [data-title],
|
||||
[data-sonner-toast] [data-description],
|
||||
[data-sonner-toast] [data-button],
|
||||
[data-sonner-toast] [data-close-button] {
|
||||
font-family: var(--font-vazirmatn), system-ui, sans-serif !important;
|
||||
}
|
||||
|
||||
html[lang="en"] [data-sonner-toaster],
|
||||
html[lang="en"] [data-sonner-toast],
|
||||
html[lang="en"] [data-sonner-toast] [data-title],
|
||||
html[lang="en"] [data-sonner-toast] [data-description],
|
||||
html[lang="en"] [data-sonner-toast] [data-button],
|
||||
html[lang="en"] [data-sonner-toast] [data-close-button] {
|
||||
font-family: var(--font-inter), system-ui, sans-serif !important;
|
||||
}
|
||||
|
||||
/* Per-café theme — panel + menu layout variants (data-* set by applyCafeTheme) */
|
||||
html[data-panel-style="glass"] .bg-card,
|
||||
html[data-panel-style="glass"] .theme-preview-sidebar,
|
||||
html[data-panel-style="glass"] .theme-preview-menu-card {
|
||||
background: color-mix(in srgb, hsl(var(--card)) 82%, transparent) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
html[data-panel-style="bold"] .bg-card {
|
||||
border-width: 2px;
|
||||
border-color: hsl(var(--primary) / 0.35);
|
||||
}
|
||||
|
||||
html[data-panel-style="elevated"] .bg-card,
|
||||
html[data-panel-style="elevated"] .theme-preview-menu-card {
|
||||
box-shadow: 0 8px 24px hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
html[data-panel-style="outline"] .bg-card {
|
||||
background: transparent !important;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
html[data-panel-style="soft"] .bg-card {
|
||||
box-shadow: 0 2px 12px hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
html[data-menu-style="compact"] .theme-preview-menu-card {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
html[data-menu-style="grid"] [data-menu-grid="true"] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
html[data-menu-style="list"] .theme-preview-menu-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
html[data-density="compact"] {
|
||||
--spacing-scale: 0.85;
|
||||
}
|
||||
|
||||
html[data-density="spacious"] {
|
||||
--spacing-scale: 1.15;
|
||||
}
|
||||
|
||||
html[data-density="compact"] main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
html[data-density="spacious"] main {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* QR guest menu — background textures (--qr-bg set inline from café theme) */
|
||||
[data-qr-texture] {
|
||||
background-color: var(--qr-bg, #f5f5f4);
|
||||
}
|
||||
|
||||
[data-qr-texture="none"] {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
[data-qr-texture="paper"] {
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 3px,
|
||||
color-mix(in srgb, var(--qr-bg, #f5f5f4) 88%, #000 12%) 3px,
|
||||
color-mix(in srgb, var(--qr-bg, #f5f5f4) 88%, #000 12%) 4px
|
||||
),
|
||||
radial-gradient(ellipse 120% 80% at 50% 0%, color-mix(in srgb, var(--qr-bg) 70%, #fff 30%), var(--qr-bg));
|
||||
}
|
||||
|
||||
[data-qr-texture="linen"] {
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent 6px,
|
||||
color-mix(in srgb, var(--qr-bg) 92%, #000 8%) 6px,
|
||||
color-mix(in srgb, var(--qr-bg) 92%, #000 8%) 7px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 6px,
|
||||
color-mix(in srgb, var(--qr-bg) 94%, #000 6%) 6px,
|
||||
color-mix(in srgb, var(--qr-bg) 94%, #000 6%) 7px
|
||||
);
|
||||
}
|
||||
|
||||
[data-qr-texture="dots"] {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--qr-bg) 75%, #000 25%) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 14px 14px;
|
||||
}
|
||||
|
||||
[data-qr-texture="grid"] {
|
||||
background-image:
|
||||
linear-gradient(color-mix(in srgb, var(--qr-bg) 80%, #000 20%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--qr-bg) 80%, #000 20%) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
[data-qr-texture="marble"] {
|
||||
background-image:
|
||||
radial-gradient(ellipse 70% 50% at 15% 20%, color-mix(in srgb, var(--qr-bg) 55%, #fff 45%), transparent 55%),
|
||||
radial-gradient(ellipse 60% 45% at 85% 75%, color-mix(in srgb, var(--qr-bg) 60%, #ddd 40%), transparent 50%),
|
||||
radial-gradient(ellipse 50% 40% at 50% 50%, color-mix(in srgb, var(--qr-bg) 75%, #eee 25%), transparent 60%);
|
||||
}
|
||||
|
||||
[data-qr-texture="wood"] {
|
||||
background-image: repeating-linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--qr-bg) 85%, #5c4033 15%),
|
||||
color-mix(in srgb, var(--qr-bg) 92%, #5c4033 8%) 2px,
|
||||
color-mix(in srgb, var(--qr-bg) 78%, #3e2723 22%) 4px,
|
||||
color-mix(in srgb, var(--qr-bg) 90%, #5c4033 10%) 6px
|
||||
);
|
||||
background-size: 100% 8px;
|
||||
}
|
||||
|
||||
[data-qr-texture="warm"] {
|
||||
background-image:
|
||||
radial-gradient(circle at 30% 40%, color-mix(in srgb, var(--qr-bg) 70%, #d4a574 30%) 0%, transparent 45%),
|
||||
radial-gradient(circle at 70% 60%, color-mix(in srgb, var(--qr-bg) 75%, #c9a87c 25%) 0%, transparent 40%),
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 8px,
|
||||
color-mix(in srgb, var(--qr-bg) 94%, #8b6914 6%) 8px,
|
||||
color-mix(in srgb, var(--qr-bg) 94%, #8b6914 6%) 9px
|
||||
);
|
||||
}
|
||||
|
||||
/* QR guest menu — themed surfaces/text (vars from buildQrThemeCssVars) */
|
||||
[data-qr-guest-menu] {
|
||||
color: var(--qr-text, #1c1917);
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-surface {
|
||||
background-color: var(--qr-surface, #fff);
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-muted {
|
||||
color: var(--qr-text-muted, #78716c);
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-text {
|
||||
color: var(--qr-text, #1c1917);
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-fill-muted {
|
||||
background-color: color-mix(in srgb, var(--qr-secondary, #e1f5ee) 45%, var(--qr-surface, #fff));
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-icon {
|
||||
color: var(--qr-primary, #0f6e56);
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-border {
|
||||
border-color: color-mix(in srgb, var(--qr-primary, #0f6e56) 22%, transparent);
|
||||
}
|
||||
|
||||
/* Texture swatches in settings appearance picker */
|
||||
.qr-texture-swatch[data-qr-texture] {
|
||||
min-height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "میزی — پنل مدیریت کافه",
|
||||
short_name: "میزی",
|
||||
description: "سیستم مدیریت کافه و رستوران میزی",
|
||||
start_url: "/fa/pos",
|
||||
display: "standalone",
|
||||
background_color: "#ffffff",
|
||||
theme_color: "#0F6E56",
|
||||
orientation: "any",
|
||||
categories: ["business", "productivity"],
|
||||
lang: "fa",
|
||||
dir: "rtl",
|
||||
icons: [
|
||||
{
|
||||
src: "/icons/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icons/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icons/icon-maskable-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
screenshots: [
|
||||
{
|
||||
src: "/screenshots/pos.png",
|
||||
sizes: "1280x800",
|
||||
type: "image/png",
|
||||
form_factor: "wide",
|
||||
label: "سیستم فروش حضوری",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { QrGuestMenu } from "@/components/qr/qr-guest-menu";
|
||||
|
||||
export default function QrLandingPage() {
|
||||
const params = useParams();
|
||||
const code = typeof params.code === "string" ? params.code : "";
|
||||
|
||||
return <QrGuestMenu code={code} />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
export default function QrError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-svh flex-col items-center justify-center gap-4 bg-[#f5f5f4] p-6 text-center"
|
||||
dir="rtl"
|
||||
>
|
||||
<p className="text-4xl">⚠️</p>
|
||||
<h1 className="text-lg font-semibold text-foreground">خطا در بارگذاری منو</h1>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
{error.message || "صفحه منوی میز قابل نمایش نیست."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="rounded-lg bg-[#0F6E56] px-4 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
تلاش مجدد
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import localFont from "next/font/local";
|
||||
import faMessages from "../../../messages/fa.json";
|
||||
import { MeeziToaster } from "@/components/ui/meezi-toaster";
|
||||
import "../globals.css";
|
||||
|
||||
const vazirmatn = localFont({
|
||||
src: "../../fonts/Vazirmatn-Variable.woff2",
|
||||
variable: "--font-vazirmatn",
|
||||
display: "swap",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
export default function QrRootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="fa" dir="rtl">
|
||||
<body
|
||||
className={`${vazirmatn.variable} min-h-svh bg-transparent font-sans antialiased font-[family-name:var(--font-vazirmatn)]`}
|
||||
>
|
||||
<NextIntlClientProvider locale="fa" messages={faMessages}>
|
||||
{children}
|
||||
<MeeziToaster />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Building2, RotateCcw, Trash2, Eye } from "lucide-react";
|
||||
import { apiDelete, apiGet, apiPost } from "@/lib/api/client";
|
||||
import { isCafeOwner } from "@/lib/auth-permissions";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useBranchStore } from "@/lib/stores/branch.store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Branch = {
|
||||
id: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
phone?: string;
|
||||
loginPhone?: string;
|
||||
managerName?: string;
|
||||
isPendingDeletion?: boolean;
|
||||
deletedAt?: string | null;
|
||||
scheduledPermanentDeleteAt?: string | null;
|
||||
daysUntilPermanentDelete?: number | null;
|
||||
};
|
||||
|
||||
function purgeCountdownLabel(
|
||||
branch: Branch,
|
||||
t: (key: string, values?: Record<string, number | string>) => string
|
||||
): string {
|
||||
const days = branch.daysUntilPermanentDelete ?? 0;
|
||||
if (days <= 0) return t("purgeToday");
|
||||
if (days === 1) return t("purgeInOneDay");
|
||||
return t("purgeInDays", { days });
|
||||
}
|
||||
|
||||
export function BranchesScreen() {
|
||||
const t = useTranslations("branchesPage");
|
||||
const tCommon = useTranslations("common");
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const role = useAuthStore((s) => s.user?.role);
|
||||
const setBranchId = useBranchStore((s) => s.setBranchId);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const [loginPhone, setLoginPhone] = useState("");
|
||||
const [managerName, setManagerName] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Branch | null>(null);
|
||||
const [reviewBranch, setReviewBranch] = useState<Branch | null>(null);
|
||||
|
||||
const { data: branches = [], isLoading } = useQuery({
|
||||
queryKey: ["branches", cafeId, "manage"],
|
||||
queryFn: () =>
|
||||
apiGet<Branch[]>(
|
||||
`/api/cafes/${cafeId}/branches?includePendingDeletion=true`
|
||||
),
|
||||
enabled: !!cafeId && isCafeOwner(role),
|
||||
});
|
||||
|
||||
const activeBranches = useMemo(
|
||||
() => branches.filter((b) => !b.isPendingDeletion),
|
||||
[branches]
|
||||
);
|
||||
const pendingBranches = useMemo(
|
||||
() => branches.filter((b) => b.isPendingDeletion),
|
||||
[branches]
|
||||
);
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["branches", cafeId] });
|
||||
};
|
||||
|
||||
const createBranch = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost(`/api/cafes/${cafeId}/branches`, {
|
||||
name: newBranchName.trim(),
|
||||
loginPhone: loginPhone.trim(),
|
||||
managerName: managerName.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setNewBranchName("");
|
||||
setLoginPhone("");
|
||||
setManagerName("");
|
||||
setError(null);
|
||||
setMessage(t("created"));
|
||||
invalidate();
|
||||
},
|
||||
onError: () => setError(t("createError")),
|
||||
});
|
||||
|
||||
const deleteBranch = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiDelete(`/api/cafes/${cafeId}/branches/${id}`),
|
||||
onSuccess: (_, id) => {
|
||||
setDeleteTarget(null);
|
||||
setMessage(t("deleteScheduled"));
|
||||
setError(null);
|
||||
const current = useBranchStore.getState().branchId;
|
||||
if (current === id) setBranchId(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
setDeleteTarget(null);
|
||||
setError(t("deleteError"));
|
||||
},
|
||||
});
|
||||
|
||||
const restoreBranch = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiPost<Branch>(`/api/cafes/${cafeId}/branches/${id}/restore`, {}),
|
||||
onSuccess: () => {
|
||||
setMessage(t("restored"));
|
||||
setError(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: () => setError(t("restoreError")),
|
||||
});
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
if (!isCafeOwner(role)) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
<p className="text-sm text-muted-foreground">{t("ownerOnly")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
newBranchName.trim().length > 0 &&
|
||||
loginPhone.trim().length >= 10 &&
|
||||
!createBranch.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
{message ? (
|
||||
<p className="rounded-xl border border-[#0F6E56]/30 bg-[#E1F5EE] px-4 py-3 text-sm text-[#0F6E56]">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{pendingBranches.length > 0 ? (
|
||||
<Card className="rounded-xl border border-border/60 bg-muted/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base text-muted-foreground">
|
||||
{t("pendingTitle")}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{t("pendingHint")}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y divide-border rounded-lg border border-dashed border-border">
|
||||
{pendingBranches.map((b) => (
|
||||
<li
|
||||
key={b.id}
|
||||
className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Building2
|
||||
className="mt-0.5 h-8 w-8 shrink-0 text-muted-foreground/50"
|
||||
aria-hidden
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-muted-foreground">{b.name}</p>
|
||||
<p className="mt-1 text-xs font-medium text-amber-800">
|
||||
{purgeCountdownLabel(b, t)}
|
||||
</p>
|
||||
{b.scheduledPermanentDeleteAt ? (
|
||||
<p className="text-[10px] text-muted-foreground" dir="ltr">
|
||||
{new Date(b.scheduledPermanentDeleteAt).toLocaleString()}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setReviewBranch(b)}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 me-1.5" />
|
||||
{t("review")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={restoreBranch.isPending}
|
||||
onClick={() => restoreBranch.mutate(b.id)}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 me-1.5" />
|
||||
{t("restore")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="rounded-xl border border-border/80 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("listTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
||||
) : activeBranches.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{activeBranches.map((b) => (
|
||||
<li
|
||||
key={b.id}
|
||||
className="flex flex-col gap-2 px-4 py-3 text-start sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">{b.name}</span>
|
||||
{b.managerName ? (
|
||||
<p className="text-xs text-muted-foreground">{b.managerName}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{b.loginPhone ? (
|
||||
<span dir="ltr" className="font-medium text-foreground">
|
||||
{t("loginPhone")}: {b.loginPhone}
|
||||
</span>
|
||||
) : null}
|
||||
{(b.city || b.address) && (
|
||||
<p>{[b.city, b.address].filter(Boolean).join(" · ")}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setReviewBranch(b)}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 me-1.5" />
|
||||
{t("review")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-destructive/40 text-destructive hover:bg-destructive/10"
|
||||
)}
|
||||
disabled={activeBranches.length <= 1}
|
||||
onClick={() => setDeleteTarget(b)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 me-1.5" />
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form
|
||||
className="space-y-3 border-t border-border pt-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (canSubmit) createBranch.mutate();
|
||||
}}
|
||||
>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{t("addSection")}
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<LabeledField label={t("newName")} htmlFor="new-branch-name">
|
||||
<Input
|
||||
id="new-branch-name"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("loginPhone")} htmlFor="branch-login-phone">
|
||||
<Input
|
||||
id="branch-login-phone"
|
||||
value={loginPhone}
|
||||
onChange={(e) => setLoginPhone(e.target.value)}
|
||||
placeholder="09121234567"
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
autoComplete="tel"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField
|
||||
label={t("managerName")}
|
||||
htmlFor="branch-manager-name"
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<Input
|
||||
id="branch-manager-name"
|
||||
value={managerName}
|
||||
onChange={(e) => setManagerName(e.target.value)}
|
||||
placeholder={t("managerNamePlaceholder")}
|
||||
/>
|
||||
</LabeledField>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{createBranch.isPending ? "..." : t("add")}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">{t("masterPlanHint")}</p>
|
||||
</form>
|
||||
<p className="text-xs text-muted-foreground">{t("branchSelectHint")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("deleteWarning")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
onClick={() => deleteTarget && deleteBranch.mutate(deleteTarget.id)}
|
||||
>
|
||||
{t("deleteConfirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog
|
||||
open={!!reviewBranch}
|
||||
onOpenChange={(open) => !open && setReviewBranch(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("reviewTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div>
|
||||
{reviewBranch ? (
|
||||
<dl className="mt-2 space-y-2 text-start text-sm text-foreground">
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{t("newName")}</dt>
|
||||
<dd className="font-medium">{reviewBranch.name}</dd>
|
||||
</div>
|
||||
{reviewBranch.managerName ? (
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{t("managerName")}</dt>
|
||||
<dd>{reviewBranch.managerName}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{reviewBranch.loginPhone ? (
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{t("loginPhone")}</dt>
|
||||
<dd dir="ltr">{reviewBranch.loginPhone}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{(reviewBranch.city || reviewBranch.address) && (
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{t("location")}</dt>
|
||||
<dd>
|
||||
{[reviewBranch.city, reviewBranch.address]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{reviewBranch.isPendingDeletion ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
{purgeCountdownLabel(reviewBranch, t)}
|
||||
</p>
|
||||
) : null}
|
||||
</dl>
|
||||
) : null}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={() => setReviewBranch(null)}>
|
||||
{tCommon("confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus } from "lucide-react";
|
||||
import { apiGet, apiPost } from "@/lib/api/client";
|
||||
import type { Coupon, CouponType } from "@/lib/api/types";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export function CouponsScreen() {
|
||||
const t = useTranslations("coupons");
|
||||
const tCommon = useTranslations("common");
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [type, setType] = useState<CouponType>("Percentage");
|
||||
const [value, setValue] = useState("10");
|
||||
|
||||
const { data: coupons = [], isLoading } = useQuery({
|
||||
queryKey: ["coupons", cafeId],
|
||||
queryFn: () => apiGet<Coupon[]>(`/api/cafes/${cafeId}/coupons`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const createCoupon = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<Coupon>(`/api/cafes/${cafeId}/coupons`, {
|
||||
code,
|
||||
type,
|
||||
value: Number(value),
|
||||
isActive: true,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["coupons", cafeId] });
|
||||
setShowForm(false);
|
||||
setCode("");
|
||||
setValue("10");
|
||||
},
|
||||
});
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">{t("title")}</h2>
|
||||
<Button onClick={() => setShowForm(!showForm)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("addCoupon")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardContent className="grid gap-3 pt-6 sm:grid-cols-3">
|
||||
<LabeledField label={t("code")} htmlFor="coupon-code">
|
||||
<Input
|
||||
id="coupon-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("type")} htmlFor="coupon-type">
|
||||
<select
|
||||
id="coupon-type"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as CouponType)}
|
||||
>
|
||||
<option value="Percentage">{t("types.Percentage")}</option>
|
||||
<option value="FixedAmount">{t("types.FixedAmount")}</option>
|
||||
</select>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("value")} htmlFor="coupon-value">
|
||||
<Input
|
||||
id="coupon-value"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
type="number"
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<div className="flex gap-2 sm:col-span-3">
|
||||
<Button onClick={() => createCoupon.mutate()} disabled={createCoupon.isPending}>
|
||||
{tCommon("save")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowForm(false)}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">{tCommon("loading")}</p>
|
||||
) : coupons.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t("noCoupons")}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{coupons.map((c) => (
|
||||
<Card key={c.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="font-mono text-lg">{c.code}</CardTitle>
|
||||
<Badge variant={c.isActive ? "default" : "secondary"}>
|
||||
{c.isActive ? t("active") : t("inactive")}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
<p>
|
||||
{t("type")}: {t(`types.${c.type}`)}
|
||||
</p>
|
||||
<p>
|
||||
{t("value")}: {formatNumber(c.value)}
|
||||
{c.type === "Percentage" ? "%" : " ت"}
|
||||
</p>
|
||||
<p>
|
||||
{t("usage")}: {formatNumber(c.usedCount)}
|
||||
{c.usageLimit ? ` / ${formatNumber(c.usageLimit)}` : ""}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus, Pencil, Search } from "lucide-react";
|
||||
import { apiGet } from "@/lib/api/client";
|
||||
import type { Customer } from "@/lib/api/types";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CustomerWizard, type CustomerWizardMode } from "@/components/crm/customer-wizard";
|
||||
|
||||
export function CrmScreen() {
|
||||
const t = useTranslations("crm");
|
||||
const tCommon = useTranslations("common");
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [wizardMode, setWizardMode] = useState<CustomerWizardMode>("create");
|
||||
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
||||
|
||||
const { data: customers = [], isLoading } = useQuery({
|
||||
queryKey: ["customers", cafeId, debouncedSearch],
|
||||
queryFn: () =>
|
||||
apiGet<Customer[]>(
|
||||
`/api/cafes/${cafeId}/customers${debouncedSearch ? `?q=${encodeURIComponent(debouncedSearch)}` : ""}`
|
||||
),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const openWizard = (mode: CustomerWizardMode, customer?: Customer) => {
|
||||
setWizardMode(mode);
|
||||
setEditingCustomer(customer ?? null);
|
||||
setWizardOpen(true);
|
||||
};
|
||||
|
||||
const refreshCustomers = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["customers", cafeId] });
|
||||
};
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-xl font-bold">{t("title")}</h2>
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
onClick={() => openWizard("create")}
|
||||
>
|
||||
<Plus className="me-2 h-4 w-4" />
|
||||
{t("addCustomer")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<LabeledField label={tCommon("search")} htmlFor="crm-search" className="min-w-[12rem] flex-1">
|
||||
<Input
|
||||
id="crm-search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && setDebouncedSearch(search)}
|
||||
/>
|
||||
</LabeledField>
|
||||
<Button variant="outline" onClick={() => setDebouncedSearch(search)}>
|
||||
<Search className="h-4 w-4" />
|
||||
{tCommon("search")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">{tCommon("loading")}</p>
|
||||
) : customers.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t("noCustomers")}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{customers.map((c) => (
|
||||
<Card key={c.id} className="rounded-xl border border-border/80 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-base">{c.name}</CardTitle>
|
||||
<Badge variant="secondary">{t(`groups.${c.group}`)}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p dir="ltr" className="text-end font-mono">
|
||||
{c.phone}
|
||||
</p>
|
||||
{c.nationalId ? (
|
||||
<p>
|
||||
{t("nationalId")}: {c.nationalId}
|
||||
</p>
|
||||
) : null}
|
||||
<p>
|
||||
{t("loyaltyPoints")}: {formatNumber(c.loyaltyPoints)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => openWizard("edit", c)}
|
||||
>
|
||||
<Pencil className="me-1 h-3.5 w-3.5" />
|
||||
{tCommon("edit")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CustomerWizard
|
||||
open={wizardOpen}
|
||||
mode={wizardMode}
|
||||
cafeId={cafeId}
|
||||
customer={editingCustomer}
|
||||
onClose={() => setWizardOpen(false)}
|
||||
onSaved={refreshCustomers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import { apiPatch, apiPost, ApiClientError } from "@/lib/api/client";
|
||||
import type { Customer, CustomerGroup } from "@/lib/api/types";
|
||||
import { useIsRtl } from "@/lib/use-is-rtl";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const GROUPS: CustomerGroup[] = ["Regular", "Vip", "New", "Employee"];
|
||||
const STEP_COUNT = 4;
|
||||
|
||||
export type CustomerWizardMode = "create" | "edit";
|
||||
|
||||
type CustomerWizardProps = {
|
||||
open: boolean;
|
||||
mode: CustomerWizardMode;
|
||||
cafeId: string;
|
||||
customer?: Customer | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
phone: string;
|
||||
nationalId: string;
|
||||
birthDateJalali: string;
|
||||
group: CustomerGroup;
|
||||
loyaltyPoints: string;
|
||||
referredBy: string;
|
||||
};
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return {
|
||||
name: "",
|
||||
phone: "",
|
||||
nationalId: "",
|
||||
birthDateJalali: "",
|
||||
group: "Regular",
|
||||
loyaltyPoints: "0",
|
||||
referredBy: "",
|
||||
};
|
||||
}
|
||||
|
||||
function fromCustomer(c: Customer): FormState {
|
||||
return {
|
||||
name: c.name,
|
||||
phone: c.phone,
|
||||
nationalId: c.nationalId ?? "",
|
||||
birthDateJalali: c.birthDateJalali ?? "",
|
||||
group: c.group,
|
||||
loyaltyPoints: String(c.loyaltyPoints),
|
||||
referredBy: c.referredBy ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function CustomerWizard({
|
||||
open,
|
||||
mode,
|
||||
cafeId,
|
||||
customer,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: CustomerWizardProps) {
|
||||
const t = useTranslations("crm.wizard");
|
||||
const tCrm = useTranslations("crm");
|
||||
const tCommon = useTranslations("common");
|
||||
const isRtl = useIsRtl();
|
||||
const numberLocale = isRtl ? "fa-IR" : "en-US";
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStep(1);
|
||||
setError(null);
|
||||
setForm(mode === "edit" && customer ? fromCustomer(customer) : emptyForm());
|
||||
}, [open, mode, customer]);
|
||||
|
||||
const stepLabels = useMemo(
|
||||
() => [t("steps.contact"), t("steps.profile"), t("steps.loyalty"), t("steps.confirm")],
|
||||
[t]
|
||||
);
|
||||
|
||||
const canNext = () => {
|
||||
if (step === 1) return form.name.trim().length > 0 && form.phone.trim().length >= 10;
|
||||
return true;
|
||||
};
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const loyalty = parseInt(form.loyaltyPoints, 10) || 0;
|
||||
if (mode === "create") {
|
||||
return apiPost<Customer>(`/api/cafes/${cafeId}/customers`, {
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
nationalId: form.nationalId.trim() || undefined,
|
||||
birthDateJalali: form.birthDateJalali.trim() || undefined,
|
||||
group: form.group,
|
||||
referredBy: form.referredBy.trim() || undefined,
|
||||
});
|
||||
}
|
||||
if (!customer?.id) throw new Error("missing customer");
|
||||
return apiPatch<Customer>(`/api/cafes/${cafeId}/customers/${customer.id}`, {
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
nationalId: form.nationalId.trim() || undefined,
|
||||
birthDateJalali: form.birthDateJalali.trim() || undefined,
|
||||
group: form.group,
|
||||
loyaltyPoints: loyalty,
|
||||
referredBy: form.referredBy.trim() || undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSaved();
|
||||
onClose();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
const code = err instanceof ApiClientError ? err.code : "";
|
||||
if (code === "DUPLICATE_PHONE") setError(t("errors.duplicatePhone"));
|
||||
else setError(t("errors.generic"));
|
||||
},
|
||||
});
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const BackIcon = isRtl ? ChevronRight : ChevronLeft;
|
||||
const NextIcon = isRtl ? ChevronLeft : ChevronRight;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="customer-wizard-title"
|
||||
>
|
||||
<Card className="flex max-h-[min(90dvh,640px)] w-full max-w-lg flex-col overflow-hidden shadow-xl">
|
||||
<CardHeader className="shrink-0 space-y-3 border-b border-border/80 pb-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle id="customer-wizard-title" className="text-lg">
|
||||
{mode === "create" ? t("titleCreate") : t("titleEdit")}
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("stepOf", { current: step, total: STEP_COUNT })}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" size="icon" variant="ghost" onClick={onClose} aria-label={tCommon("cancel")}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{stepLabels.map((label, i) => {
|
||||
const n = i + 1;
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className={cn(
|
||||
"h-1 flex-1 rounded-full transition-colors",
|
||||
n <= step ? "bg-[#0F6E56]" : "bg-muted"
|
||||
)}
|
||||
title={label}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{stepLabels[step - 1]}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="min-h-0 flex-1 overflow-y-auto py-4">
|
||||
{error ? (
|
||||
<p className="mb-3 rounded-md border border-[#A32D2D]/30 bg-red-50 px-3 py-2 text-sm text-[#A32D2D]">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-3">
|
||||
<LabeledField label={tCrm("name")} htmlFor="wiz-name">
|
||||
<Input
|
||||
id="wiz-name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
autoFocus
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={tCrm("phone")} htmlFor="wiz-phone">
|
||||
<Input
|
||||
id="wiz-phone"
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
inputMode="tel"
|
||||
/>
|
||||
</LabeledField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-3">
|
||||
<LabeledField label={tCrm("nationalId")} htmlFor="wiz-national-id">
|
||||
<Input
|
||||
id="wiz-national-id"
|
||||
value={form.nationalId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, nationalId: e.target.value }))}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
maxLength={10}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={tCrm("birthDate")} htmlFor="wiz-birth" hint={t("birthHint")}>
|
||||
<Input
|
||||
id="wiz-birth"
|
||||
value={form.birthDateJalali}
|
||||
onChange={(e) => setForm((f) => ({ ...f, birthDateJalali: e.target.value }))}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
placeholder="1400/01/01"
|
||||
/>
|
||||
</LabeledField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-3">
|
||||
<LabeledField label={tCrm("group")} htmlFor="wiz-group">
|
||||
<select
|
||||
id="wiz-group"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={form.group}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, group: e.target.value as CustomerGroup }))
|
||||
}
|
||||
>
|
||||
{GROUPS.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{tCrm(`groups.${g}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
<LabeledField label={tCrm("loyaltyPoints")} htmlFor="wiz-points">
|
||||
<Input
|
||||
id="wiz-points"
|
||||
value={form.loyaltyPoints}
|
||||
onChange={(e) => setForm((f) => ({ ...f, loyaltyPoints: e.target.value }))}
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
disabled={mode === "create"}
|
||||
/>
|
||||
</LabeledField>
|
||||
{mode === "create" ? (
|
||||
<p className="text-xs text-muted-foreground">{t("loyaltyCreateHint")}</p>
|
||||
) : null}
|
||||
<LabeledField label={t("referredBy")} htmlFor="wiz-referred">
|
||||
<Input
|
||||
id="wiz-referred"
|
||||
value={form.referredBy}
|
||||
onChange={(e) => setForm((f) => ({ ...f, referredBy: e.target.value }))}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div className="flex justify-between gap-2 border-b border-border/60 pb-2">
|
||||
<dt className="text-muted-foreground">{tCrm("name")}</dt>
|
||||
<dd className="font-medium">{form.name}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2 border-b border-border/60 pb-2">
|
||||
<dt className="text-muted-foreground">{tCrm("phone")}</dt>
|
||||
<dd className="font-mono" dir="ltr">
|
||||
{form.phone}
|
||||
</dd>
|
||||
</div>
|
||||
{form.nationalId ? (
|
||||
<div className="flex justify-between gap-2 border-b border-border/60 pb-2">
|
||||
<dt className="text-muted-foreground">{tCrm("nationalId")}</dt>
|
||||
<dd dir="ltr">{form.nationalId}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{form.birthDateJalali ? (
|
||||
<div className="flex justify-between gap-2 border-b border-border/60 pb-2">
|
||||
<dt className="text-muted-foreground">{tCrm("birthDate")}</dt>
|
||||
<dd dir="ltr">{form.birthDateJalali}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-between gap-2 border-b border-border/60 pb-2">
|
||||
<dt className="text-muted-foreground">{tCrm("group")}</dt>
|
||||
<dd>
|
||||
<Badge variant="secondary">{tCrm(`groups.${form.group}`)}</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt className="text-muted-foreground">{tCrm("loyaltyPoints")}</dt>
|
||||
<dd>{formatNumber(parseInt(form.loyaltyPoints, 10) || 0, numberLocale)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<div className="flex shrink-0 gap-2 border-t border-border/80 p-4">
|
||||
{step > 1 ? (
|
||||
<Button type="button" variant="outline" onClick={() => setStep((s) => s - 1)}>
|
||||
<BackIcon className="me-1 h-4 w-4" />
|
||||
{t("back")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{step < STEP_COUNT ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={!canNext()}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setStep((s) => s + 1);
|
||||
}}
|
||||
>
|
||||
{t("next")}
|
||||
<NextIcon className="ms-1 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={save.isPending}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
save.mutate();
|
||||
}}
|
||||
>
|
||||
{save.isPending ? tCommon("loading") : tCommon("save")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
DISCOVER_TAXONOMY,
|
||||
type CafeDiscoverProfile,
|
||||
type DiscoverListField,
|
||||
type DiscoverSingleField,
|
||||
toggleListValue,
|
||||
} from "@/lib/cafe-discover-profile";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CafeDiscoverProfileEditorProps = {
|
||||
value: CafeDiscoverProfile;
|
||||
onChange: (next: CafeDiscoverProfile) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function CafeDiscoverProfileEditor({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: CafeDiscoverProfileEditorProps) {
|
||||
const t = useTranslations("discoverProfile");
|
||||
|
||||
const setList = (field: DiscoverListField, id: string) => {
|
||||
onChange({ ...value, [field]: toggleListValue(value[field], id) });
|
||||
};
|
||||
|
||||
const setSingle = (field: DiscoverSingleField, id: string) => {
|
||||
onChange({ ...value, [field]: value[field] === id ? null : id });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<ProfileSection label={t("sections.themes")} hint={t("hints.themes")}>
|
||||
<ChipGrid
|
||||
ids={DISCOVER_TAXONOMY.themes}
|
||||
selected={value.themes}
|
||||
label={(id) => t(`themes.${id}`)}
|
||||
onToggle={(id) => setList("themes", id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</ProfileSection>
|
||||
|
||||
<ProfileSection label={t("sections.occasions")} hint={t("hints.occasions")}>
|
||||
<ChipGrid
|
||||
ids={DISCOVER_TAXONOMY.occasions}
|
||||
selected={value.occasions}
|
||||
label={(id) => t(`occasions.${id}`)}
|
||||
onToggle={(id) => setList("occasions", id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</ProfileSection>
|
||||
|
||||
<ProfileSection label={t("sections.spaceFeatures")} hint={t("hints.spaceFeatures")}>
|
||||
<ChipGrid
|
||||
ids={DISCOVER_TAXONOMY.spaceFeatures}
|
||||
selected={value.spaceFeatures}
|
||||
label={(id) => t(`spaceFeatures.${id}`)}
|
||||
onToggle={(id) => setList("spaceFeatures", id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</ProfileSection>
|
||||
|
||||
<ProfileSection label={t("sections.vibes")} hint={t("hints.vibes")}>
|
||||
<ChipGrid
|
||||
ids={DISCOVER_TAXONOMY.vibes}
|
||||
selected={value.vibes}
|
||||
label={(id) => t(`vibes.${id}`)}
|
||||
onToggle={(id) => setList("vibes", id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</ProfileSection>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<ProfileSection label={t("sections.size")}>
|
||||
<ChipGrid
|
||||
ids={DISCOVER_TAXONOMY.sizes}
|
||||
selected={value.size ? [value.size] : []}
|
||||
label={(id) => t(`sizes.${id}`)}
|
||||
onToggle={(id) => setSingle("size", id)}
|
||||
disabled={disabled}
|
||||
single
|
||||
/>
|
||||
</ProfileSection>
|
||||
<ProfileSection label={t("sections.floors")}>
|
||||
<ChipGrid
|
||||
ids={DISCOVER_TAXONOMY.floors}
|
||||
selected={value.floors ? [value.floors] : []}
|
||||
label={(id) => t(`floors.${id}`)}
|
||||
onToggle={(id) => setSingle("floors", id)}
|
||||
disabled={disabled}
|
||||
single
|
||||
/>
|
||||
</ProfileSection>
|
||||
<ProfileSection label={t("sections.noiseLevel")}>
|
||||
<ChipGrid
|
||||
ids={DISCOVER_TAXONOMY.noiseLevels}
|
||||
selected={value.noiseLevel ? [value.noiseLevel] : []}
|
||||
label={(id) => t(`noiseLevels.${id}`)}
|
||||
onToggle={(id) => setSingle("noiseLevel", id)}
|
||||
disabled={disabled}
|
||||
single
|
||||
/>
|
||||
</ProfileSection>
|
||||
<ProfileSection label={t("sections.priceTier")}>
|
||||
<ChipGrid
|
||||
ids={DISCOVER_TAXONOMY.priceTiers}
|
||||
selected={value.priceTier ? [value.priceTier] : []}
|
||||
label={(id) => t(`priceTiers.${id}`)}
|
||||
onToggle={(id) => setSingle("priceTier", id)}
|
||||
disabled={disabled}
|
||||
single
|
||||
/>
|
||||
</ProfileSection>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSection({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipGrid({
|
||||
ids,
|
||||
selected,
|
||||
label,
|
||||
onToggle,
|
||||
disabled,
|
||||
single,
|
||||
}: {
|
||||
ids: readonly string[];
|
||||
selected: string[];
|
||||
label: (id: string) => string;
|
||||
onToggle: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
single?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ids.map((id) => {
|
||||
const active = selected.includes(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onToggle(id)}
|
||||
className={cn(
|
||||
"rounded-lg border px-2.5 py-1.5 text-xs font-medium transition active:scale-[0.98]",
|
||||
active
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
||||
: "border-border/80 bg-card text-foreground hover:border-[#0F6E56]/40",
|
||||
disabled && "pointer-events-none opacity-50"
|
||||
)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{label(id)}
|
||||
{!single && active ? (
|
||||
<span className="ms-1 opacity-70" aria-hidden>
|
||||
✓
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { apiGet, apiPut } from "@/lib/api/client";
|
||||
import { adminGet, adminPut } from "@/lib/api/admin-client";
|
||||
import {
|
||||
EMPTY_DISCOVER_PROFILE,
|
||||
type CafeDiscoverProfile,
|
||||
} from "@/lib/cafe-discover-profile";
|
||||
import { CafeDiscoverProfileEditor } from "@/components/discover/cafe-discover-profile-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { notify } from "@/lib/notify";
|
||||
|
||||
type ApiDiscoverProfile = {
|
||||
themes: string[];
|
||||
size?: string | null;
|
||||
floors?: string | null;
|
||||
vibes: string[];
|
||||
occasions: string[];
|
||||
spaceFeatures: string[];
|
||||
noiseLevel?: string | null;
|
||||
priceTier?: string | null;
|
||||
};
|
||||
|
||||
function fromApi(d: ApiDiscoverProfile): CafeDiscoverProfile {
|
||||
return {
|
||||
themes: d.themes ?? [],
|
||||
size: d.size ?? null,
|
||||
floors: d.floors ?? null,
|
||||
vibes: d.vibes ?? [],
|
||||
occasions: d.occasions ?? [],
|
||||
spaceFeatures: d.spaceFeatures ?? [],
|
||||
noiseLevel: d.noiseLevel ?? null,
|
||||
priceTier: d.priceTier ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function toApiBody(p: CafeDiscoverProfile) {
|
||||
return {
|
||||
themes: p.themes,
|
||||
size: p.size,
|
||||
floors: p.floors,
|
||||
vibes: p.vibes,
|
||||
occasions: p.occasions,
|
||||
spaceFeatures: p.spaceFeatures,
|
||||
noiseLevel: p.noiseLevel,
|
||||
priceTier: p.priceTier,
|
||||
};
|
||||
}
|
||||
|
||||
type CafeDiscoverProfilePanelProps = {
|
||||
cafeId: string;
|
||||
mode: "merchant" | "admin";
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function CafeDiscoverProfilePanel({
|
||||
cafeId,
|
||||
mode,
|
||||
compact,
|
||||
}: CafeDiscoverProfilePanelProps) {
|
||||
const t = useTranslations(
|
||||
mode === "admin" ? "admin.cafes.discoverProfile" : "settings.discoverProfile"
|
||||
);
|
||||
const qc = useQueryClient();
|
||||
const [profile, setProfile] = useState<CafeDiscoverProfile>(EMPTY_DISCOVER_PROFILE);
|
||||
|
||||
const queryKey =
|
||||
mode === "admin"
|
||||
? ["admin", "cafe-discover-profile", cafeId]
|
||||
: ["cafe-discover-profile", cafeId];
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
if (mode === "admin") {
|
||||
const res = await adminGet<ApiDiscoverProfile & { cafeId: string; cafeName: string }>(
|
||||
`/api/admin/cafes/${cafeId}/discover-profile`
|
||||
);
|
||||
return fromApi(res);
|
||||
}
|
||||
const res = await apiGet<ApiDiscoverProfile>(`/api/cafes/${cafeId}/discover-profile`);
|
||||
return fromApi(res);
|
||||
},
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setProfile(data);
|
||||
}, [data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body = toApiBody(profile);
|
||||
return mode === "admin"
|
||||
? adminPut(`/api/admin/cafes/${cafeId}/discover-profile`, body)
|
||||
: apiPut(`/api/cafes/${cafeId}/discover-profile`, body);
|
||||
},
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey });
|
||||
notify.success(t("saved"));
|
||||
},
|
||||
});
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{!compact ? (
|
||||
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
||||
) : (
|
||||
<CafeDiscoverProfileEditor
|
||||
value={profile}
|
||||
onChange={setProfile}
|
||||
disabled={save.isPending}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={save.isPending || isLoading}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return <div className="space-y-4">{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="rounded-xl border border-border/80 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">{content}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchCafePublicProfile,
|
||||
removeGalleryPhoto,
|
||||
updateCafePublicProfile,
|
||||
uploadGalleryPhoto,
|
||||
type CafeProfileEdit,
|
||||
} from "@/lib/api/cafe-public-profile";
|
||||
import type { WorkingHours } from "@/lib/api/public-discover";
|
||||
import { resolveMediaUrl } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Props = { cafeId: string };
|
||||
|
||||
type Tab = "info" | "gallery" | "hours" | "social";
|
||||
|
||||
const DAY_KEYS: (keyof WorkingHours)[] = ["sat", "sun", "mon", "tue", "wed", "thu", "fri"];
|
||||
|
||||
export function CafePublicProfilePanel({ cafeId }: Props) {
|
||||
const t = useTranslations("cafePublicProfile");
|
||||
const qc = useQueryClient();
|
||||
const [tab, setTab] = useState<Tab>("info");
|
||||
const [saved, setSaved] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── Server state ──────────────────────────────────────────────────────────
|
||||
const { data: profile, isLoading } = useQuery({
|
||||
queryKey: ["cafe-public-profile", cafeId],
|
||||
queryFn: () => fetchCafePublicProfile(cafeId),
|
||||
});
|
||||
|
||||
// ── Local edit state ──────────────────────────────────────────────────────
|
||||
const [description, setDescription] = useState<string>("");
|
||||
const [instagram, setInstagram] = useState<string>("");
|
||||
const [website, setWebsite] = useState<string>("");
|
||||
const [hours, setHours] = useState<WorkingHours>(emptyHours());
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// Populate local state once we get server data
|
||||
if (profile && !initialized) {
|
||||
setDescription(profile.description ?? "");
|
||||
setInstagram(profile.instagramHandle ?? "");
|
||||
setWebsite(profile.websiteUrl ?? "");
|
||||
setHours(profile.workingHours ?? emptyHours());
|
||||
setInitialized(true);
|
||||
}
|
||||
|
||||
// ── Save info/social/hours ────────────────────────────────────────────────
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateCafePublicProfile(cafeId, {
|
||||
description,
|
||||
instagramHandle: instagram || null,
|
||||
websiteUrl: website || null,
|
||||
workingHours: hours,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(["cafe-public-profile", cafeId], data);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Gallery upload ────────────────────────────────────────────────────────
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setUploadError(null);
|
||||
try {
|
||||
const gallery = await uploadGalleryPhoto(cafeId, file);
|
||||
qc.setQueryData<CafeProfileEdit>(["cafe-public-profile", cafeId], (old) =>
|
||||
old ? { ...old, galleryUrls: gallery } : old
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : t("uploadFailed");
|
||||
setUploadError(msg.includes("GALLERY_FULL") ? t("galleryFull") : t("uploadFailed"));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (url: string) => removeGalleryPhoto(cafeId, url),
|
||||
onSuccess: (gallery) => {
|
||||
qc.setQueryData<CafeProfileEdit>(["cafe-public-profile", cafeId], (old) =>
|
||||
old ? { ...old, galleryUrls: gallery } : old
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Hours helpers ─────────────────────────────────────────────────────────
|
||||
const setDayField = (
|
||||
day: keyof WorkingHours,
|
||||
field: "isOpen" | "open" | "close",
|
||||
value: string | boolean
|
||||
) => {
|
||||
setHours((prev) => ({
|
||||
...prev,
|
||||
[day]: {
|
||||
...((prev[day] as object) ?? { isOpen: false, open: "", close: "" }),
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-muted-foreground p-4">{t("loading")}</p>;
|
||||
}
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: "info", label: t("tabs.info") },
|
||||
{ id: "gallery", label: t("tabs.gallery") },
|
||||
{ id: "hours", label: t("tabs.hours") },
|
||||
{ id: "social", label: t("tabs.social") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">{t("title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 rounded-xl border border-border/80 bg-muted/40 p-1">
|
||||
{tabs.map((tb) => (
|
||||
<button
|
||||
key={tb.id}
|
||||
type="button"
|
||||
onClick={() => setTab(tb.id)}
|
||||
className={cn(
|
||||
"flex-1 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer",
|
||||
tab === tb.id
|
||||
? "bg-white shadow-sm text-[#0F6E56]"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{tb.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Info tab ─────────────────────────────────────────────────────── */}
|
||||
{tab === "info" && (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("description")}</Label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value)}
|
||||
placeholder={t("descriptionPlaceholder")}
|
||||
rows={5}
|
||||
className="w-full resize-none rounded-lg border border-border/80 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#0F6E56]"
|
||||
/>
|
||||
</div>
|
||||
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Gallery tab ──────────────────────────────────────────────────── */}
|
||||
{tab === "gallery" && (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("gallery")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("galleryHint")}</p>
|
||||
</div>
|
||||
|
||||
{/* Existing photos */}
|
||||
{profile?.galleryUrls && profile.galleryUrls.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{profile.galleryUrls.map((url) => {
|
||||
const src = resolveMediaUrl(url);
|
||||
return (
|
||||
<div key={url} className="group relative">
|
||||
<div
|
||||
className="aspect-square rounded-lg bg-cover bg-center"
|
||||
style={{ backgroundImage: src ? `url(${src})` : undefined }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMutation.mutate(url)}
|
||||
disabled={removeMutation.isPending}
|
||||
className="absolute end-1 top-1 rounded-md bg-black/60 px-2 py-0.5 text-[10px] text-white opacity-0 transition group-hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
{t("removePhoto")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">هنوز عکسی آپلود نشده</p>
|
||||
)}
|
||||
|
||||
{/* Upload button */}
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={uploading || (profile?.galleryUrls?.length ?? 0) >= 8}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{uploading ? t("uploading") : t("uploadPhoto")}
|
||||
</Button>
|
||||
{uploadError && (
|
||||
<p className="mt-1 text-xs text-red-500">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Working hours tab ─────────────────────────────────────────────── */}
|
||||
{tab === "hours" && (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<p className="text-sm font-medium">{t("workingHours")}</p>
|
||||
<div className="space-y-2">
|
||||
{DAY_KEYS.map((day) => {
|
||||
const d = hours[day] as { isOpen: boolean; open?: string; close?: string } | null;
|
||||
return (
|
||||
<div key={day} className="flex flex-wrap items-center gap-3 rounded-lg border border-border/60 px-3 py-2">
|
||||
<span className="w-20 text-sm font-medium">{t(`days.${day}`)}</span>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={d?.isOpen ?? false}
|
||||
onChange={(e) => setDayField(day, "isOpen", e.target.checked)}
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs">{t("isOpen")}</span>
|
||||
</label>
|
||||
{d?.isOpen && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="time"
|
||||
value={d.open ?? ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDayField(day, "open", e.target.value)}
|
||||
className="rounded border border-border/80 px-2 py-1 text-xs"
|
||||
dir="ltr"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
<input
|
||||
type="time"
|
||||
value={d.close ?? ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDayField(day, "close", e.target.value)}
|
||||
className="rounded border border-border/80 px-2 py-1 text-xs"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Social tab ───────────────────────────────────────────────────── */}
|
||||
{tab === "social" && (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("instagram")}</Label>
|
||||
<div className="flex items-center rounded-lg border border-border/80 px-3">
|
||||
<span className="text-sm text-muted-foreground">@</span>
|
||||
<Input
|
||||
value={instagram}
|
||||
onChange={(e) => setInstagram(e.target.value.replace(/^@/, ""))}
|
||||
placeholder={t("instagramPlaceholder")}
|
||||
className="border-0 ps-1 shadow-none"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("website")}</Label>
|
||||
<Input
|
||||
value={website}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
placeholder={t("websitePlaceholder")}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Save button shared sub-component ─────────────────────────────────────────
|
||||
|
||||
function SaveButton({
|
||||
saving,
|
||||
saved,
|
||||
onSave,
|
||||
t,
|
||||
}: {
|
||||
saving: boolean;
|
||||
saved: boolean;
|
||||
onSave: () => void;
|
||||
t: ReturnType<typeof useTranslations<"cafePublicProfile">>;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="bg-[#0F6E56]"
|
||||
>
|
||||
{saving ? "…" : saved ? t("saved") : t("save")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function emptyHours(): WorkingHours {
|
||||
const day = () => ({ isOpen: false, open: null, close: null });
|
||||
return { sat: day(), sun: day(), mon: day(), tue: day(), wed: day(), thu: day(), fri: day() };
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { apiPostPublic, ApiClientError } from "@/lib/api/client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type CoffeeAdvisorPick = {
|
||||
name: string;
|
||||
reason: string;
|
||||
menuItemId?: string | null;
|
||||
};
|
||||
|
||||
type CoffeeAdvisorResult = {
|
||||
summary: string;
|
||||
picks: CoffeeAdvisorPick[];
|
||||
};
|
||||
|
||||
type CoffeeAdvisorPanelProps = {
|
||||
cafeSlug: string;
|
||||
};
|
||||
|
||||
export function CoffeeAdvisorPanel({ cafeSlug }: CoffeeAdvisorPanelProps) {
|
||||
const t = useTranslations("discoverPublic.coffeeAdvisor");
|
||||
const [purpose, setPurpose] = useState("");
|
||||
const [result, setResult] = useState<CoffeeAdvisorResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
const trimmed = purpose.trim();
|
||||
if (trimmed.length < 3) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const data = await apiPostPublic<CoffeeAdvisorResult>(
|
||||
"/api/public/coffee-advisor",
|
||||
{ purpose: trimmed, cafeSlug }
|
||||
);
|
||||
setResult(data);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiClientError && e.code === "AI_NOT_CONFIGURED") {
|
||||
setError(t("notConfigured"));
|
||||
} else {
|
||||
setError(t("failed"));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="rounded-xl border border-primary/20 bg-gradient-to-b from-[#E1F5EE]/40 to-card">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Sparkles className="h-4 w-4 text-primary" aria-hidden />
|
||||
{t("title")}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{t("subtitle")}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={purpose}
|
||||
onChange={(e) => setPurpose(e.target.value)}
|
||||
placeholder={t("placeholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !loading) void submit();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
className="shrink-0 bg-primary hover:bg-primary/90"
|
||||
disabled={loading || purpose.trim().length < 3}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{loading ? t("loading") : t("submit")}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : null}
|
||||
{result ? (
|
||||
<div className="space-y-3 rounded-lg border border-primary/15 bg-card/80 p-3">
|
||||
<p className="text-sm leading-relaxed">{result.summary}</p>
|
||||
{result.picks.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{result.picks.map((pick) => (
|
||||
<li
|
||||
key={`${pick.name}-${pick.menuItemId ?? "x"}`}
|
||||
className="rounded-lg border border-border/60 bg-background px-3 py-2"
|
||||
>
|
||||
<p className="text-sm font-medium text-primary">{pick.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{pick.reason}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchPublicCafe,
|
||||
fetchPublicCafeReviews,
|
||||
type WorkingHours,
|
||||
} from "@/lib/api/public-discover";
|
||||
import { resolveMediaUrl } from "@/lib/api/client";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CoffeeAdvisorPanel } from "@/components/discover/coffee-advisor-panel";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Props = { slug: string };
|
||||
|
||||
const DAY_KEYS: (keyof WorkingHours)[] = ["sat", "sun", "mon", "tue", "wed", "thu", "fri"];
|
||||
|
||||
export function PublicCafeDetailScreen({ slug }: Props) {
|
||||
const t = useTranslations("discoverPublic");
|
||||
const tProfile = useTranslations("discoverProfile");
|
||||
const locale = useLocale();
|
||||
const [galleryIndex, setGalleryIndex] = useState(0);
|
||||
|
||||
const { data: cafe, isLoading, error } = useQuery({
|
||||
queryKey: ["public-cafe", slug],
|
||||
queryFn: () => fetchPublicCafe(slug),
|
||||
});
|
||||
|
||||
const { data: reviews = [] } = useQuery({
|
||||
queryKey: ["public-cafe-reviews", slug],
|
||||
queryFn: () => fetchPublicCafeReviews(slug),
|
||||
enabled: !!slug,
|
||||
});
|
||||
|
||||
const label = (key: string) => {
|
||||
const groups = ["themes", "vibes", "occasions", "spaceFeatures", "noiseLevels", "priceTiers"] as const;
|
||||
for (const g of groups) {
|
||||
try { return tProfile(`${g}.${key}` as "themes.modern"); } catch { /* next */ }
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const mapSrc =
|
||||
cafe?.address || cafe?.city
|
||||
? `https://map.neshan.org/search?term=${encodeURIComponent(
|
||||
[cafe.address, cafe.city].filter(Boolean).join("، ")
|
||||
)}`
|
||||
: null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center bg-[#f5f5f4]">
|
||||
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !cafe) {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-4 bg-[#f5f5f4] p-6">
|
||||
<p className="text-sm text-muted-foreground">{t("notFound")}</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${locale}/discover`}>{t("backToList")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Build image list: gallery first, then cover/logo fallback
|
||||
const allImages = cafe.galleryUrls?.length
|
||||
? cafe.galleryUrls
|
||||
: [cafe.coverImageUrl ?? cafe.logoUrl].filter(Boolean) as string[];
|
||||
const currentImage = resolveMediaUrl(allImages[galleryIndex] ?? null);
|
||||
|
||||
const profile = cafe.discoverProfile;
|
||||
const allTags = [
|
||||
...profile.occasions,
|
||||
...profile.vibes,
|
||||
...profile.spaceFeatures,
|
||||
...profile.themes,
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-svh bg-[#f5f5f4]">
|
||||
<header className="border-b bg-white px-4 py-4">
|
||||
<Link href={`/${locale}/discover`} className="text-sm text-[#0F6E56] hover:underline">
|
||||
← {t("backToList")}
|
||||
</Link>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<h1 className="text-lg font-medium">{cafe.name}</h1>
|
||||
{cafe.isOpenNow && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
{t("openNowLabel")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{cafe.city && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{cafe.city}{cafe.address ? ` — ${cafe.address}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-3xl space-y-4 p-4">
|
||||
|
||||
{/* Gallery carousel */}
|
||||
{allImages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{currentImage && (
|
||||
<div
|
||||
className="h-52 w-full rounded-xl bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${currentImage})` }}
|
||||
/>
|
||||
)}
|
||||
{allImages.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{allImages.map((img, i) => {
|
||||
const url = resolveMediaUrl(img);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setGalleryIndex(i)}
|
||||
className={cn(
|
||||
"h-14 w-20 shrink-0 rounded-lg bg-cover bg-center transition-all cursor-pointer",
|
||||
i === galleryIndex
|
||||
? "ring-2 ring-[#0F6E56]"
|
||||
: "opacity-70 hover:opacity-100"
|
||||
)}
|
||||
style={{ backgroundImage: url ? `url(${url})` : undefined }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info card */}
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{cafe.averageRating > 0 && (
|
||||
<p className="text-sm font-medium text-[#0F6E56]">
|
||||
★ {formatNumber(cafe.averageRating, locale)} —{" "}
|
||||
{t("reviewCount", { count: cafe.reviewCount })}
|
||||
</p>
|
||||
)}
|
||||
{cafe.description && (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{cafe.description}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{allTags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px]">
|
||||
{label(tag)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Working hours */}
|
||||
{cafe.workingHours && <WorkingHoursCard hours={cafe.workingHours} t={t} locale={locale} />}
|
||||
|
||||
{/* Social links */}
|
||||
{(cafe.instagramHandle || cafe.websiteUrl) && (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardContent className="flex flex-wrap gap-3 p-4">
|
||||
{cafe.instagramHandle && (
|
||||
<a
|
||||
href={`https://instagram.com/${cafe.instagramHandle}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border/80 bg-white px-3 py-2 text-sm transition hover:border-pink-400 cursor-pointer"
|
||||
>
|
||||
<svg className="h-4 w-4 text-pink-500" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 1.366.062 2.633.336 3.608 1.31.975.975 1.249 2.242 1.311 3.608.058 1.265.069 1.645.069 4.849 0 3.205-.011 3.584-.069 4.849-.062 1.366-.336 2.633-1.311 3.608-.975.975-2.242 1.249-3.608 1.311-1.266.058-1.644.069-4.85.069-3.204 0-3.584-.011-4.849-.069-1.366-.062-2.633-.336-3.608-1.311-.975-.975-1.249-2.242-1.311-3.608C2.175 15.584 2.163 15.205 2.163 12c0-3.204.012-3.584.07-4.849.062-1.366.336-2.633 1.311-3.608.975-.974 2.242-1.248 3.608-1.31C8.416 2.175 8.796 2.163 12 2.163zm0-2.163C8.741 0 8.333.014 7.053.072 5.197.157 3.355.673 1.965 2.063.573 3.453.157 5.197.072 7.053.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.085 1.856.5 3.598 1.893 4.99C3.355 23.327 5.197 23.843 7.053 23.928 8.333 23.986 8.741 24 12 24s3.667-.014 4.947-.072c1.856-.085 3.598-.501 4.99-1.893 1.393-1.392 1.808-3.134 1.893-4.99.058-1.28.072-1.689.072-4.948 0-3.259-.014-3.667-.072-4.947-.085-1.856-.5-3.598-1.893-4.99C20.645.673 18.803.157 16.947.072 15.667.014 15.259 0 12 0z"/>
|
||||
<path d="M12 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/>
|
||||
</svg>
|
||||
<span>@{cafe.instagramHandle}</span>
|
||||
</a>
|
||||
)}
|
||||
{cafe.websiteUrl && (
|
||||
<a
|
||||
href={cafe.websiteUrl.startsWith("http") ? cafe.websiteUrl : `https://${cafe.websiteUrl}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border/80 bg-white px-3 py-2 text-sm transition hover:border-blue-400 cursor-pointer"
|
||||
>
|
||||
<svg className="h-4 w-4 text-blue-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>
|
||||
</svg>
|
||||
<span>{t("websiteLabel")}</span>
|
||||
</a>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<CoffeeAdvisorPanel cafeSlug={slug} />
|
||||
|
||||
{/* Map */}
|
||||
{mapSrc && (
|
||||
<Card className="overflow-hidden rounded-xl border border-border/80">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("mapTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<iframe
|
||||
title={t("mapTitle")}
|
||||
src={mapSrc}
|
||||
className="h-64 w-full border-0"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
<div className="border-t p-3">
|
||||
<a
|
||||
href={mapSrc}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-[#0C447C] hover:underline"
|
||||
>
|
||||
{t("openInNeshan")}
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Reviews */}
|
||||
{reviews.length > 0 && (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("reviewsTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-4 pt-0">
|
||||
{reviews.map((r) => (
|
||||
<div key={r.id} className="space-y-2 border-b border-border/60 pb-3 last:border-0">
|
||||
<p className="text-sm font-medium">{r.authorName}</p>
|
||||
<p className="text-xs text-amber-600">{"★".repeat(r.rating)}</p>
|
||||
{r.comment && <p className="text-sm text-muted-foreground">{r.comment}</p>}
|
||||
{r.ownerReply && (
|
||||
<p className="rounded-lg bg-[#E1F5EE] px-3 py-2 text-sm text-[#0F6E56]">
|
||||
{t("ownerReply")}: {r.ownerReply}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Button asChild className="w-full bg-[#0F6E56]">
|
||||
<Link href={`/${locale}/discover`}>{t("exploreMore")}</Link>
|
||||
</Button>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Working hours sub-component ───────────────────────────────────────────────
|
||||
|
||||
function WorkingHoursCard({
|
||||
hours,
|
||||
t,
|
||||
locale,
|
||||
}: {
|
||||
hours: WorkingHours;
|
||||
t: ReturnType<typeof useTranslations<"discoverPublic">>;
|
||||
locale: string;
|
||||
}) {
|
||||
// Detect today's day key in Iran time (UTC+3:30)
|
||||
const iranOffset = 210; // minutes
|
||||
const iranNow = new Date(Date.now() + iranOffset * 60_000);
|
||||
const dayIndex = iranNow.getUTCDay(); // 0=Sun ... 6=Sat
|
||||
const dayKeyMap: Record<number, keyof WorkingHours> = {
|
||||
6: "sat", 0: "sun", 1: "mon", 2: "tue", 3: "wed", 4: "thu", 5: "fri",
|
||||
};
|
||||
const todayKey = dayKeyMap[dayIndex];
|
||||
|
||||
const DAY_KEYS: (keyof WorkingHours)[] = ["sat", "sun", "mon", "tue", "wed", "thu", "fri"];
|
||||
|
||||
return (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("workingHoursTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 pb-2">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{DAY_KEYS.map((day) => {
|
||||
const schedule = hours[day];
|
||||
const isToday = day === todayKey;
|
||||
return (
|
||||
<tr
|
||||
key={day}
|
||||
className={cn(
|
||||
"border-b border-border/40 last:border-0",
|
||||
isToday && "bg-[#E1F5EE]/60"
|
||||
)}
|
||||
>
|
||||
<td className={cn(
|
||||
"px-4 py-2 font-medium",
|
||||
isToday ? "text-[#0F6E56]" : "text-foreground"
|
||||
)}>
|
||||
{t(`days.${day}`)}
|
||||
{isToday && (
|
||||
<span className="ms-1.5 text-[10px] font-normal text-[#0F6E56]">
|
||||
(امروز)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-end text-muted-foreground">
|
||||
{!schedule || !schedule.isOpen ? (
|
||||
<span className="text-red-500">{t("closedLabel")}</span>
|
||||
) : schedule.open && schedule.close ? (
|
||||
<span dir="ltr">{schedule.open} – {schedule.close}</span>
|
||||
) : (
|
||||
<span className="text-emerald-600">{t("openNowLabel")}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchDiscoverTaxonomy,
|
||||
fetchNlpHints,
|
||||
fetchPublicDiscover,
|
||||
type DiscoverSearchParams,
|
||||
type NlpHints,
|
||||
type PublicCafeDiscover,
|
||||
} from "@/lib/api/public-discover";
|
||||
import { resolveMediaUrl } from "@/lib/api/client";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
const CITIES = [
|
||||
{ id: "tehran", query: "تهران" },
|
||||
{ id: "karaj", query: "کرج" },
|
||||
] as const;
|
||||
|
||||
type FilterKey = "themes" | "vibes" | "occasions" | "spaceFeatures";
|
||||
|
||||
function toggle(list: string[], value: string): string[] {
|
||||
return list.includes(value) ? list.filter((x) => x !== value) : [...list, value];
|
||||
}
|
||||
|
||||
// Count non-empty detected filter fields
|
||||
function nlpHintCount(h: NlpHints | null): number {
|
||||
if (!h) return 0;
|
||||
return (
|
||||
h.themes.length +
|
||||
h.vibes.length +
|
||||
h.occasions.length +
|
||||
h.spaceFeatures.length +
|
||||
(h.noiseLevel ? 1 : 0) +
|
||||
(h.priceTier ? 1 : 0) +
|
||||
(h.size ? 1 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function PublicDiscoverScreen() {
|
||||
const t = useTranslations("discoverPublic");
|
||||
const tProfile = useTranslations("discoverProfile");
|
||||
const locale = useLocale();
|
||||
|
||||
const [city, setCity] = useState<string>("tehran");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [sort, setSort] = useState("rating");
|
||||
const [themes, setThemes] = useState<string[]>([]);
|
||||
const [vibes, setVibes] = useState<string[]>([]);
|
||||
const [occasions, setOccasions] = useState<string[]>([]);
|
||||
const [spaceFeatures, setSpaceFeatures] = useState<string[]>([]);
|
||||
const [noise, setNoise] = useState<string | null>(null);
|
||||
const [priceTier, setPriceTier] = useState<string | null>(null);
|
||||
const [size, setSize] = useState<string | null>(null);
|
||||
const [openNow, setOpenNow] = useState(false);
|
||||
|
||||
// Debounce the search input for NLP hints
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => setDebouncedSearch(search), 600);
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
}, [search]);
|
||||
|
||||
// Fetch NLP hints whenever the debounced search changes
|
||||
const { data: nlpHints } = useQuery({
|
||||
queryKey: ["nlp-hints", debouncedSearch],
|
||||
queryFn: () => fetchNlpHints(debouncedSearch),
|
||||
enabled: debouncedSearch.trim().length > 2,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const cityQuery = CITIES.find((c) => c.id === city)?.query ?? "تهران";
|
||||
|
||||
const params: DiscoverSearchParams = useMemo(
|
||||
() => ({
|
||||
city: cityQuery,
|
||||
q: search.trim() || undefined,
|
||||
sort,
|
||||
themes: themes.length ? themes : undefined,
|
||||
vibes: vibes.length ? vibes : undefined,
|
||||
occasions: occasions.length ? occasions : undefined,
|
||||
spaceFeatures: spaceFeatures.length ? spaceFeatures : undefined,
|
||||
noise: noise ?? undefined,
|
||||
priceTier: priceTier ?? undefined,
|
||||
size: size ?? undefined,
|
||||
openNow,
|
||||
requireProfile: true,
|
||||
}),
|
||||
[cityQuery, search, sort, themes, vibes, occasions, spaceFeatures, noise, priceTier, size, openNow]
|
||||
);
|
||||
|
||||
const { data: taxonomy } = useQuery({
|
||||
queryKey: ["discover-taxonomy"],
|
||||
queryFn: fetchDiscoverTaxonomy,
|
||||
});
|
||||
|
||||
const { data: cafes = [], isLoading, isFetching } = useQuery({
|
||||
queryKey: ["public-discover", params],
|
||||
queryFn: () => fetchPublicDiscover(params),
|
||||
});
|
||||
|
||||
const label = useCallback(
|
||||
(key: string) => {
|
||||
const groups = ["themes", "vibes", "occasions", "spaceFeatures", "noiseLevels", "priceTiers", "sizes"] as const;
|
||||
for (const g of groups) {
|
||||
try { return tProfile(`${g}.${key}` as "themes.modern"); } catch { /* next */ }
|
||||
}
|
||||
return key;
|
||||
},
|
||||
[tProfile]
|
||||
);
|
||||
|
||||
const clearAll = () => {
|
||||
setThemes([]); setVibes([]); setOccasions([]); setSpaceFeatures([]);
|
||||
setNoise(null); setPriceTier(null); setSize(null); setSearch("");
|
||||
setOpenNow(false);
|
||||
};
|
||||
|
||||
const filterSection = (
|
||||
key: FilterKey,
|
||||
options: string[] | undefined,
|
||||
active: string[],
|
||||
setActive: (v: string[]) => void
|
||||
) => {
|
||||
if (!options?.length) return null;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t(`filters.${key}`)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.slice(0, 14).map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => setActive(toggle(active, opt))}
|
||||
className={cn(
|
||||
"rounded-lg border px-2.5 py-1 text-xs transition-colors active:scale-[0.98] cursor-pointer",
|
||||
active.includes(opt)
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
||||
: "border-border/80 hover:border-[#0F6E56]/40"
|
||||
)}
|
||||
>
|
||||
{label(opt)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const detectedCount = nlpHintCount(nlpHints ?? null);
|
||||
|
||||
return (
|
||||
<div className="min-h-svh bg-[#f5f5f4]">
|
||||
<header className="border-b bg-white px-4 py-5">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("brand")}
|
||||
</p>
|
||||
<h1 className="text-lg font-medium text-foreground">{t("title")}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-3xl space-y-4 p-4">
|
||||
{/* City selector */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CITIES.map((c) => (
|
||||
<Button
|
||||
key={c.id}
|
||||
size="sm"
|
||||
variant={city === c.id ? "default" : "outline"}
|
||||
className={city === c.id ? "bg-[#0F6E56]" : ""}
|
||||
onClick={() => setCity(c.id)}
|
||||
>
|
||||
{t(`cities.${c.id}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* AI smart search */}
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="text-end pe-10"
|
||||
/>
|
||||
{/* AI spark indicator */}
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-sm transition-opacity",
|
||||
debouncedSearch.trim().length > 2 ? "opacity-100" : "opacity-30"
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
✦
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t("searchHint")}</p>
|
||||
|
||||
{/* Detected filters banner */}
|
||||
{detectedCount > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-[#0F6E56]/30 bg-[#E1F5EE]/60 px-3 py-2">
|
||||
<span className="text-[11px] font-medium text-[#0F6E56]">
|
||||
{t("aiDetectedLabel")}
|
||||
</span>
|
||||
{nlpHints?.themes.map((k) => (
|
||||
<span key={k} className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
|
||||
{label(k)}
|
||||
</span>
|
||||
))}
|
||||
{nlpHints?.vibes.map((k) => (
|
||||
<span key={k} className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
|
||||
{label(k)}
|
||||
</span>
|
||||
))}
|
||||
{nlpHints?.occasions.map((k) => (
|
||||
<span key={k} className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
|
||||
{label(k)}
|
||||
</span>
|
||||
))}
|
||||
{nlpHints?.spaceFeatures.map((k) => (
|
||||
<span key={k} className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
|
||||
{label(k)}
|
||||
</span>
|
||||
))}
|
||||
{nlpHints?.noiseLevel && (
|
||||
<span className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
|
||||
{label(nlpHints.noiseLevel)}
|
||||
</span>
|
||||
)}
|
||||
{nlpHints?.priceTier && (
|
||||
<span className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
|
||||
{label(nlpHints.priceTier)}
|
||||
</span>
|
||||
)}
|
||||
{nlpHints?.size && (
|
||||
<span className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
|
||||
{label(nlpHints.size)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
className="ms-auto text-[11px] text-[#0F6E56] underline cursor-pointer"
|
||||
>
|
||||
{t("aiDetectedClear")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter panel */}
|
||||
<Card className="rounded-xl border border-border/80 shadow-sm">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
{filterSection("occasions", taxonomy?.occasions, occasions, setOccasions)}
|
||||
{filterSection("vibes", taxonomy?.vibes, vibes, setVibes)}
|
||||
{filterSection("spaceFeatures", taxonomy?.spaceFeatures, spaceFeatures, setSpaceFeatures)}
|
||||
{filterSection("themes", taxonomy?.themes, themes, setThemes)}
|
||||
|
||||
{/* Size filter — was missing before */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("filters.size")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{taxonomy?.sizes?.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSize(size === s ? null : s)}
|
||||
className={cn(
|
||||
"rounded-lg border px-2.5 py-1 text-xs transition-colors cursor-pointer",
|
||||
size === s
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
||||
: "border-border/80 hover:border-[#0F6E56]/40"
|
||||
)}
|
||||
>
|
||||
{label(s)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Noise level */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("filters.noise")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{taxonomy?.noiseLevels?.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setNoise(noise === n ? null : n)}
|
||||
className={cn(
|
||||
"rounded-lg border px-2.5 py-1 text-xs transition-colors cursor-pointer",
|
||||
noise === n
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
||||
: "border-border/80 hover:border-[#0F6E56]/40"
|
||||
)}
|
||||
>
|
||||
{label(n)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price tier */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("filters.priceTier")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{taxonomy?.priceTiers?.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setPriceTier(priceTier === p ? null : p)}
|
||||
className={cn(
|
||||
"rounded-lg border px-2.5 py-1 text-xs transition-colors cursor-pointer",
|
||||
priceTier === p
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
||||
: "border-border/80 hover:border-[#0F6E56]/40"
|
||||
)}
|
||||
>
|
||||
{label(p)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Open now toggle + actions */}
|
||||
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenNow((v) => !v)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer",
|
||||
openNow
|
||||
? "border-emerald-500 bg-emerald-50 text-emerald-700"
|
||||
: "border-border/80 hover:border-emerald-400/60"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 rounded-full",
|
||||
openNow ? "bg-emerald-500" : "bg-muted-foreground/40"
|
||||
)}
|
||||
/>
|
||||
{t("openNow")}
|
||||
</button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={clearAll}
|
||||
className="ms-auto"
|
||||
>
|
||||
{t("clearFilters")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Results header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isLoading || isFetching
|
||||
? t("loading")
|
||||
: t("resultCount", { count: cafes.length })}
|
||||
</p>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value)}
|
||||
className="rounded-lg border border-border/80 bg-white px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="rating">{t("sort.rating")}</option>
|
||||
<option value="reviews">{t("sort.reviews")}</option>
|
||||
<option value="name">{t("sort.name")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{cafes.length === 0 && !isLoading ? (
|
||||
<Card className="rounded-xl border border-dashed p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{cafes.map((cafe) => (
|
||||
<CafeDiscoverCard key={cafe.id} cafe={cafe} locale={locale} label={label} t={t} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Card component ────────────────────────────────────────────────────────────
|
||||
|
||||
function CafeDiscoverCard({
|
||||
cafe,
|
||||
locale,
|
||||
label,
|
||||
t,
|
||||
}: {
|
||||
cafe: PublicCafeDiscover;
|
||||
locale: string;
|
||||
label: (key: string) => string;
|
||||
t: ReturnType<typeof useTranslations<"discoverPublic">>;
|
||||
}) {
|
||||
// Pick the best cover: gallery first, then coverImage, then logo
|
||||
const firstGallery = cafe.galleryUrls?.[0];
|
||||
const cover = resolveMediaUrl(firstGallery ?? cafe.coverImageUrl ?? cafe.logoUrl);
|
||||
|
||||
const tags = [
|
||||
...cafe.discoverProfile.occasions.slice(0, 2),
|
||||
...cafe.discoverProfile.vibes.slice(0, 1),
|
||||
];
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={`/${locale}/discover/${cafe.slug}`}
|
||||
className="block rounded-xl border border-border/80 bg-white transition-all hover:border-[#0F6E56] hover:shadow-sm active:scale-[0.99] cursor-pointer"
|
||||
>
|
||||
{/* Cover image */}
|
||||
{cover ? (
|
||||
<div
|
||||
className="h-32 rounded-t-xl bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${cover})` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center rounded-t-xl bg-muted">
|
||||
<svg className="h-10 w-10 text-muted-foreground/30" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2 19V7a2 2 0 012-2h1V4a1 1 0 012 0v1h10V4a1 1 0 112 0v1h1a2 2 0 012 2v12a2 2 0 01-2 2H4a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-medium text-foreground">{cafe.name}</h2>
|
||||
{/* Gallery count badge */}
|
||||
{cafe.galleryUrls?.length > 1 && (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
+{cafe.galleryUrls.length - 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* Open/closed badge */}
|
||||
{cafe.isOpenNow && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
{t("openNowLabel")}
|
||||
</span>
|
||||
)}
|
||||
{cafe.averageRating > 0 && (
|
||||
<span className="text-sm font-medium text-[#0F6E56]">
|
||||
★ {formatNumber(cafe.averageRating, locale)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cafe.city && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{cafe.city}
|
||||
{cafe.address ? ` — ${cafe.address}` : ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px]">
|
||||
{label(tag)}
|
||||
</Badge>
|
||||
))}
|
||||
{cafe.discoverProfile.priceTier && (
|
||||
<Badge className="bg-amber-100 text-[10px] text-amber-900">
|
||||
{label(cafe.discoverProfile.priceTier)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gallery strip */}
|
||||
{cafe.galleryUrls?.length > 1 && (
|
||||
<div className="flex gap-1 overflow-x-auto pb-0.5">
|
||||
{cafe.galleryUrls.slice(1, 4).map((url, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-10 w-16 shrink-0 rounded bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${resolveMediaUrl(url)})` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[#0F6E56]">{t("viewCafe")} ←</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { apiDelete, apiGet, apiGetPaged, apiPost } from "@/lib/api/client";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||
import { isoTodayTehran } from "@/lib/reports/analytics";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
type Branch = { id: string; name: string };
|
||||
|
||||
type ShiftDto = {
|
||||
id: string;
|
||||
branchId: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ExpenseCategory =
|
||||
| "Supplies"
|
||||
| "Utilities"
|
||||
| "Salary"
|
||||
| "Rent"
|
||||
| "Maintenance"
|
||||
| "Other";
|
||||
|
||||
export type ExpenseRow = {
|
||||
id: string;
|
||||
cafeId: string;
|
||||
branchId: string;
|
||||
shiftId?: string | null;
|
||||
category: ExpenseCategory;
|
||||
amount: number;
|
||||
note?: string | null;
|
||||
receiptImageUrl?: string | null;
|
||||
createdByUserId: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const CATEGORIES: ExpenseCategory[] = [
|
||||
"Supplies",
|
||||
"Utilities",
|
||||
"Salary",
|
||||
"Rent",
|
||||
"Maintenance",
|
||||
"Other",
|
||||
];
|
||||
|
||||
const MANAGER_ROLES = new Set(["Owner", "Manager"]);
|
||||
|
||||
export function ExpensesScreen() {
|
||||
const t = useTranslations("expenses");
|
||||
const tCommon = useTranslations("common");
|
||||
const locale = useLocale();
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const role = useAuthStore((s) => s.user?.role ?? "");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const today = isoTodayTehran();
|
||||
const [branchId, setBranchId] = useState<string>("");
|
||||
const [from, setFrom] = useState(today);
|
||||
const [to, setTo] = useState(today);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [category, setCategory] = useState<ExpenseCategory>("Supplies");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [linkShift, setLinkShift] = useState(true);
|
||||
|
||||
const { data: branches = [] } = useQuery({
|
||||
queryKey: ["branches", cafeId],
|
||||
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!branchId && branches.length > 0) setBranchId(branches[0]!.id);
|
||||
}, [branchId, branches]);
|
||||
|
||||
const { data: currentShift } = useQuery({
|
||||
queryKey: ["shift-current", cafeId, branchId],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<ShiftDto>(
|
||||
`/api/cafes/${cafeId}/branches/${branchId}/shifts/current`
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: !!cafeId && !!branchId,
|
||||
});
|
||||
|
||||
const listKey = ["expenses", cafeId, branchId, from, to] as const;
|
||||
|
||||
const { data: listResponse, isLoading } = useQuery({
|
||||
queryKey: listKey,
|
||||
queryFn: () =>
|
||||
apiGetPaged<ExpenseRow>(
|
||||
`/api/cafes/${cafeId}/expenses?branchId=${encodeURIComponent(branchId)}&from=${from}&to=${to}&page=1&pageSize=50`
|
||||
),
|
||||
enabled: !!cafeId && !!branchId && !!from && !!to,
|
||||
});
|
||||
|
||||
const rows = useMemo(() => listResponse?.items ?? [], [listResponse?.items]);
|
||||
const totalAmount = useMemo(() => rows.reduce((s, r) => s + r.amount, 0), [rows]);
|
||||
|
||||
const createExpense = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<ExpenseRow>(`/api/cafes/${cafeId}/expenses`, {
|
||||
branchId,
|
||||
shiftId: linkShift && currentShift ? currentShift.id : null,
|
||||
category,
|
||||
amount: Number(amount),
|
||||
note: note.trim() || null,
|
||||
receiptImageUrl: null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: listKey });
|
||||
setShowModal(false);
|
||||
setAmount("");
|
||||
setNote("");
|
||||
setCategory("Supplies");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteExpense = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/expenses/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: listKey }),
|
||||
});
|
||||
|
||||
const canDelete = MANAGER_ROLES.has(role);
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 bg-[#f5f5f4] min-h-full -m-4 p-4 md:-m-6 md:p-6">
|
||||
<PageHeader
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
action={
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0d5e49]"
|
||||
onClick={() => setShowModal(true)}
|
||||
disabled={!branchId}
|
||||
>
|
||||
<Plus className="ms-2 h-4 w-4" />
|
||||
{t("addExpense")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="rounded-xl border border-border/80 bg-card">
|
||||
<CardContent className="flex flex-wrap items-end gap-4 pt-6">
|
||||
<LabeledField label={t("branch")} htmlFor="exp-branch">
|
||||
<select
|
||||
id="exp-branch"
|
||||
className="h-9 min-w-[10rem] rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={branchId}
|
||||
onChange={(e) => setBranchId(e.target.value)}
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("fromDate")} htmlFor="exp-from">
|
||||
<Input
|
||||
id="exp-from"
|
||||
type="date"
|
||||
dir="ltr"
|
||||
className="w-40 text-end"
|
||||
value={from}
|
||||
max={to}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("toDate")} htmlFor="exp-to">
|
||||
<Input
|
||||
id="exp-to"
|
||||
type="date"
|
||||
dir="ltr"
|
||||
className="w-40 text-end"
|
||||
value={to}
|
||||
min={from}
|
||||
max={today}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
/>
|
||||
</LabeledField>
|
||||
<div className="ms-auto text-end">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("periodTotal")}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-[#BA7517]">
|
||||
{formatCurrency(totalAmount, numberLocale)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-xl border border-border/80 bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("listTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="w-full min-w-[28rem] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
<th className="py-2 text-start">{t("colDate")}</th>
|
||||
<th className="py-2 text-start">{t("colCategory")}</th>
|
||||
<th className="py-2 text-start">{t("colNote")}</th>
|
||||
<th className="py-2 text-end">{t("colAmount")}</th>
|
||||
{canDelete ? <th className="py-2 w-10" /> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={canDelete ? 5 : 4} className="py-4 text-muted-foreground">
|
||||
{t("loading")}
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={canDelete ? 5 : 4} className="py-4 text-muted-foreground">
|
||||
{t("empty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-border/50">
|
||||
<td className="py-2.5 tabular-nums text-muted-foreground" dir="ltr">
|
||||
{new Date(row.createdAt).toLocaleString(
|
||||
locale === "en" ? "en-GB" : "fa-IR",
|
||||
{ dateStyle: "short", timeStyle: "short" }
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5">{t(`categories.${row.category}`)}</td>
|
||||
<td className="py-2.5 max-w-[12rem] truncate text-muted-foreground">
|
||||
{row.note ?? "—"}
|
||||
</td>
|
||||
<td className="py-2.5 text-end font-medium text-[#BA7517] tabular-nums">
|
||||
{formatCurrency(row.amount, numberLocale)}
|
||||
</td>
|
||||
{canDelete ? (
|
||||
<td className="py-2.5 text-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-[#A32D2D]"
|
||||
onClick={() => deleteExpense.mutate(row.id)}
|
||||
disabled={deleteExpense.isPending}
|
||||
aria-label={tCommon("delete")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{listResponse?.meta ? (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{t("rowCount", {
|
||||
count: formatNumber(listResponse.meta.total, numberLocale),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{showModal ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="expense-modal-title"
|
||||
>
|
||||
<Card className="w-full max-w-md rounded-xl border border-border/80 bg-card shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle id="expense-modal-title" className="text-base">
|
||||
{t("addExpense")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<LabeledField label={t("category")} htmlFor="exp-cat">
|
||||
<select
|
||||
id="exp-cat"
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as ExpenseCategory)}
|
||||
>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`categories.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("amount")} htmlFor="exp-amount">
|
||||
<Input
|
||||
id="exp-amount"
|
||||
type="number"
|
||||
min={1}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("note")} htmlFor="exp-note">
|
||||
<Input
|
||||
id="exp-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("notePlaceholder")}
|
||||
/>
|
||||
</LabeledField>
|
||||
{currentShift ? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={linkShift}
|
||||
onChange={(e) => setLinkShift(e.target.checked)}
|
||||
/>
|
||||
{t("linkOpenShift")}
|
||||
</label>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{t("noOpenShift")}</p>
|
||||
)}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0d5e49]"
|
||||
disabled={!amount || createExpense.isPending}
|
||||
onClick={() => createExpense.mutate()}
|
||||
>
|
||||
{tCommon("confirm")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowModal(false)}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface Employee {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
baseSalary: number;
|
||||
}
|
||||
|
||||
interface Attendance {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
date: string;
|
||||
clockIn?: string;
|
||||
clockOut?: string;
|
||||
}
|
||||
|
||||
interface LeaveRequest {
|
||||
id: string;
|
||||
employeeName: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
reason?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface Salary {
|
||||
id: string;
|
||||
employeeName: string;
|
||||
monthYear: string;
|
||||
netSalary: number;
|
||||
isPaid: boolean;
|
||||
}
|
||||
|
||||
type Tab = "attendance" | "leave" | "payroll";
|
||||
|
||||
export function HrScreen() {
|
||||
const t = useTranslations("hr");
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const userId = useAuthStore((s) => s.user?.userId);
|
||||
const queryClient = useQueryClient();
|
||||
const [tab, setTab] = useState<Tab>("attendance");
|
||||
const [monthYear, setMonthYear] = useState(
|
||||
new Date().toISOString().slice(0, 7)
|
||||
);
|
||||
|
||||
const { data: employees = [] } = useQuery({
|
||||
queryKey: ["employees", cafeId],
|
||||
queryFn: () => apiGet<Employee[]>(`/api/cafes/${cafeId}/employees`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const { data: attendance = [] } = useQuery({
|
||||
queryKey: ["attendance", cafeId],
|
||||
queryFn: () => apiGet<Attendance[]>(`/api/cafes/${cafeId}/attendance`),
|
||||
enabled: !!cafeId && tab === "attendance",
|
||||
});
|
||||
|
||||
const { data: leaves = [] } = useQuery({
|
||||
queryKey: ["leave-requests", cafeId],
|
||||
queryFn: () =>
|
||||
apiGet<LeaveRequest[]>(`/api/cafes/${cafeId}/leave-requests?status=Pending`),
|
||||
enabled: !!cafeId && tab === "leave",
|
||||
});
|
||||
|
||||
const { data: salaries = [] } = useQuery({
|
||||
queryKey: ["salaries", cafeId, monthYear],
|
||||
queryFn: () =>
|
||||
apiGet<Salary[]>(`/api/cafes/${cafeId}/salaries?monthYear=${monthYear}`),
|
||||
enabled: !!cafeId && tab === "payroll",
|
||||
});
|
||||
|
||||
const clockIn = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<Attendance>(`/api/cafes/${cafeId}/employees/${userId}/attendance/clock-in`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["attendance", cafeId] }),
|
||||
});
|
||||
|
||||
const clockOut = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<Attendance>(`/api/cafes/${cafeId}/employees/${userId}/attendance/clock-out`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["attendance", cafeId] }),
|
||||
});
|
||||
|
||||
const approveLeave = useMutation({
|
||||
mutationFn: (leaveId: string) =>
|
||||
apiPatch(`/api/cafes/${cafeId}/leave-requests/${leaveId}/status`, {
|
||||
status: "Approved",
|
||||
}),
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["leave-requests", cafeId] }),
|
||||
});
|
||||
|
||||
const markPaid = useMutation({
|
||||
mutationFn: (salaryId: string) =>
|
||||
apiPatch(`/api/cafes/${cafeId}/salaries/${salaryId}/paid`),
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["salaries", cafeId, monthYear] }),
|
||||
});
|
||||
|
||||
if (!cafeId || !userId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">{t("title")}</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["attendance", "leave", "payroll"] as Tab[]).map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
size="sm"
|
||||
variant={tab === key ? "default" : "outline"}
|
||||
onClick={() => setTab(key)}
|
||||
>
|
||||
{t(`tabs.${key}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "attendance" && (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("myAttendance")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => clockIn.mutate()} disabled={clockIn.isPending}>
|
||||
{t("clockIn")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => clockOut.mutate()} disabled={clockOut.isPending}>
|
||||
{t("clockOut")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{attendance.map((a) => (
|
||||
<Card key={a.id}>
|
||||
<CardContent className="space-y-1 pt-4 text-sm">
|
||||
<p className="font-medium">{a.employeeName}</p>
|
||||
<p className="text-muted-foreground">{a.date}</p>
|
||||
<p dir="ltr" className="text-end font-mono text-xs">
|
||||
{a.clockIn ? new Date(a.clockIn).toLocaleTimeString("fa-IR") : "—"}
|
||||
{" → "}
|
||||
{a.clockOut ? new Date(a.clockOut).toLocaleTimeString("fa-IR") : "—"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "leave" && (
|
||||
<div className="space-y-3">
|
||||
{leaves.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t("noLeave")}</p>
|
||||
) : (
|
||||
leaves.map((l) => (
|
||||
<Card key={l.id}>
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-3 pt-4">
|
||||
<div>
|
||||
<p className="font-medium">{l.employeeName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{l.startDate} — {l.endDate}
|
||||
</p>
|
||||
{l.reason && <p className="text-sm">{l.reason}</p>}
|
||||
</div>
|
||||
<Button size="sm" onClick={() => approveLeave.mutate(l.id)}>
|
||||
{t("approve")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "payroll" && (
|
||||
<div className="space-y-4">
|
||||
<LabeledField label={t("monthYear")} htmlFor="hr-month" hint="YYYY-MM" className="max-w-xs">
|
||||
<Input
|
||||
id="hr-month"
|
||||
value={monthYear}
|
||||
onChange={(e) => setMonthYear(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("employeeCount")}: {formatNumber(employees.length)}
|
||||
</p>
|
||||
{salaries.map((s) => (
|
||||
<Card key={s.id}>
|
||||
<CardContent className="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<p className="font-medium">{s.employeeName}</p>
|
||||
<p>{formatCurrency(s.netSalary)}</p>
|
||||
</div>
|
||||
{s.isPaid ? (
|
||||
<Badge>{t("paid")}</Badge>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => markPaid.mutate(s.id)}>
|
||||
{t("markPaid")}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { apiGet, apiPatch, apiPost, apiPut } from "@/lib/api/client";
|
||||
import { InventoryUnitField } from "@/components/inventory/inventory-unit-field";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useBranchStore } from "@/lib/stores/branch.store";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { notify } from "@/lib/notify";
|
||||
|
||||
type Ingredient = {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
quantityOnHand: number;
|
||||
reorderLevel: number;
|
||||
unitCost: number;
|
||||
parLevel: number;
|
||||
lowStockWarningPercent: number;
|
||||
warningThreshold: number;
|
||||
stockValueToman: number;
|
||||
isLowStock: boolean;
|
||||
};
|
||||
|
||||
type MenuItem = { id: string; name: string };
|
||||
|
||||
type RecipeLine = {
|
||||
id: string;
|
||||
ingredientId: string;
|
||||
ingredientName: string;
|
||||
unit: string;
|
||||
quantityPerUnit: number;
|
||||
};
|
||||
|
||||
type MenuItemRecipe = {
|
||||
menuItemId: string;
|
||||
menuItemName: string;
|
||||
lines: RecipeLine[];
|
||||
materialCostPerUnitToman: number;
|
||||
};
|
||||
|
||||
type PurchasesSummary = {
|
||||
totalPaidToman: number;
|
||||
purchaseCount: number;
|
||||
recent: {
|
||||
id: string;
|
||||
ingredientName: string;
|
||||
delta: number;
|
||||
unit: string;
|
||||
totalPaidToman: number;
|
||||
createdAt: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function InventoryScreen() {
|
||||
const t = useTranslations("inventory");
|
||||
const tCommon = useTranslations("common");
|
||||
const locale = useLocale();
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const branchId = useBranchStore((s) => s.branchId);
|
||||
const setBranchId = useBranchStore((s) => s.setBranchId);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: branches = [] } = useQuery({
|
||||
queryKey: ["branches", cafeId],
|
||||
queryFn: () => apiGet<{ id: string }[]>(`/api/cafes/${cafeId}/branches`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!branchId && branches.length > 0) setBranchId(branches[0]!.id);
|
||||
}, [branchId, branches, setBranchId]);
|
||||
const [tab, setTab] = useState<"materials" | "recipes">("materials");
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [unit, setUnit] = useState("گرم");
|
||||
const [qty, setQty] = useState("500");
|
||||
const [reorder, setReorder] = useState("50");
|
||||
const [totalPaid, setTotalPaid] = useState("");
|
||||
const [parLevel, setParLevel] = useState("500");
|
||||
const [warningPct, setWarningPct] = useState("20");
|
||||
const [adjustQty, setAdjustQty] = useState<Record<string, string>>({});
|
||||
const [adjustPaid, setAdjustPaid] = useState<Record<string, string>>({});
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editUnit, setEditUnit] = useState("گرم");
|
||||
const [editReorder, setEditReorder] = useState("0");
|
||||
const [editUnitCost, setEditUnitCost] = useState("0");
|
||||
const [editParLevel, setEditParLevel] = useState("0");
|
||||
const [editWarningPct, setEditWarningPct] = useState("20");
|
||||
|
||||
const [selectedMenuItemId, setSelectedMenuItemId] = useState("");
|
||||
const [recipeDraft, setRecipeDraft] = useState<RecipeLine[]>([]);
|
||||
const [newRecipeIngredientId, setNewRecipeIngredientId] = useState("");
|
||||
const [newRecipeQty, setNewRecipeQty] = useState("10");
|
||||
|
||||
const { data: ingredients = [], isLoading } = useQuery({
|
||||
queryKey: ["inventory", cafeId],
|
||||
queryFn: () => apiGet<Ingredient[]>(`/api/cafes/${cafeId}/inventory/ingredients`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const monthRange = useMemo(() => {
|
||||
const now = new Date();
|
||||
const from = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const fmt = (d: Date) =>
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
return { from: fmt(from), to: fmt(now) };
|
||||
}, []);
|
||||
|
||||
const { data: purchasesSummary } = useQuery({
|
||||
queryKey: ["inventory", cafeId, "purchases", branchId, monthRange.from, monthRange.to],
|
||||
queryFn: () =>
|
||||
apiGet<PurchasesSummary>(
|
||||
`/api/cafes/${cafeId}/inventory/purchases?branchId=${encodeURIComponent(branchId!)}&from=${monthRange.from}&to=${monthRange.to}`
|
||||
),
|
||||
enabled: !!cafeId && !!branchId && tab === "materials",
|
||||
});
|
||||
|
||||
const { data: lowStock = [] } = useQuery({
|
||||
queryKey: ["inventory", cafeId, "low"],
|
||||
queryFn: () => apiGet<Ingredient[]>(`/api/cafes/${cafeId}/inventory/low-stock`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const { data: menuItems = [] } = useQuery({
|
||||
queryKey: ["menu", "items", cafeId],
|
||||
queryFn: () => apiGet<MenuItem[]>(`/api/cafes/${cafeId}/menu/items`),
|
||||
enabled: !!cafeId && tab === "recipes",
|
||||
});
|
||||
|
||||
const { data: recipe, isLoading: recipeLoading } = useQuery({
|
||||
queryKey: ["inventory", cafeId, "recipe", selectedMenuItemId],
|
||||
queryFn: () =>
|
||||
apiGet<MenuItemRecipe>(
|
||||
`/api/cafes/${cafeId}/inventory/menu-items/${selectedMenuItemId}/recipe`
|
||||
),
|
||||
enabled: !!cafeId && !!selectedMenuItemId,
|
||||
});
|
||||
|
||||
const impliedUnitCost = useMemo(() => {
|
||||
const q = parseFloat(qty) || 0;
|
||||
const paid = parseFloat(totalPaid) || 0;
|
||||
if (q > 0 && paid > 0) return paid / q;
|
||||
return 0;
|
||||
}, [qty, totalPaid]);
|
||||
|
||||
const createIngredient = useMutation({
|
||||
mutationFn: () => {
|
||||
const quantity = parseFloat(qty) || 0;
|
||||
const paid = parseFloat(totalPaid) || 0;
|
||||
return apiPost(`/api/cafes/${cafeId}/inventory/ingredients`, {
|
||||
name,
|
||||
unit: unit.trim() || t("defaultUnit"),
|
||||
quantityOnHand: quantity,
|
||||
reorderLevel: parseFloat(reorder) || 0,
|
||||
unitCost: impliedUnitCost,
|
||||
parLevel: parseFloat(parLevel) || quantity || 0,
|
||||
lowStockWarningPercent: parseFloat(warningPct) || 20,
|
||||
totalPaidToman: paid > 0 ? paid : null,
|
||||
branchId: paid > 0 ? branchId : null,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["inventory", cafeId] });
|
||||
notify.success(t("created"));
|
||||
},
|
||||
});
|
||||
|
||||
const updateIngredient = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiPatch(`/api/cafes/${cafeId}/inventory/ingredients/${id}`, {
|
||||
name: editName.trim(),
|
||||
unit: editUnit.trim() || t("defaultUnit"),
|
||||
reorderLevel: parseFloat(editReorder) || 0,
|
||||
unitCost: parseFloat(editUnitCost) || 0,
|
||||
parLevel: parseFloat(editParLevel) || 0,
|
||||
lowStockWarningPercent: parseFloat(editWarningPct) || 20,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["inventory", cafeId] });
|
||||
setEditingId(null);
|
||||
notify.success(t("updated"));
|
||||
},
|
||||
});
|
||||
|
||||
const adjustStock = useMutation({
|
||||
mutationFn: ({ id, delta, paid }: { id: string; delta: number; paid?: number }) =>
|
||||
apiPost(`/api/cafes/${cafeId}/inventory/ingredients/${id}/adjust`, {
|
||||
delta,
|
||||
note: delta > 0 ? t("purchaseNote") : t("adjustNote"),
|
||||
totalPaidToman: delta > 0 ? paid : null,
|
||||
branchId: delta > 0 ? branchId : null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["inventory", cafeId] });
|
||||
void qc.invalidateQueries({ queryKey: ["inventory", cafeId, "purchases"] });
|
||||
void qc.invalidateQueries({ queryKey: ["expenses"] });
|
||||
notify.success(t("adjusted"));
|
||||
},
|
||||
onError: () => notify.error(t("purchaseRequired")),
|
||||
});
|
||||
|
||||
const saveRecipe = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPut(`/api/cafes/${cafeId}/inventory/menu-items/${selectedMenuItemId}/recipe`, {
|
||||
lines: recipeDraft.map((l) => ({
|
||||
ingredientId: l.ingredientId,
|
||||
quantityPerUnit: l.quantityPerUnit,
|
||||
})),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["inventory", cafeId, "recipe", selectedMenuItemId] });
|
||||
notify.success(t("recipeSaved"));
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (recipe?.lines) setRecipeDraft(recipe.lines.map((l) => ({ ...l })));
|
||||
}, [recipe]);
|
||||
|
||||
const startEdit = (ing: Ingredient) => {
|
||||
setEditingId(ing.id);
|
||||
setEditName(ing.name);
|
||||
setEditUnit(ing.unit);
|
||||
setEditReorder(String(ing.reorderLevel));
|
||||
setEditUnitCost(String(ing.unitCost));
|
||||
setEditParLevel(String(ing.parLevel));
|
||||
setEditWarningPct(String(ing.lowStockWarningPercent));
|
||||
};
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
{lowStock.length > 0 ? (
|
||||
<Card className="rounded-xl border border-amber-300 bg-amber-50/80 p-4">
|
||||
<p className="text-sm font-medium text-amber-900">{t("lowStockAlert")}</p>
|
||||
<p className="mt-1 text-sm text-amber-800">
|
||||
{lowStock.map((i) => i.name).join("، ")}
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{tab === "materials" && branchId && purchasesSummary ? (
|
||||
<Card className="rounded-xl border border-[#0F6E56]/25 bg-[#E1F5EE]/40 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("purchasesThisMonth")}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-medium text-[#0F6E56]">
|
||||
{formatNumber(purchasesSummary.totalPaidToman, numberLocale)} ت
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("purchaseCount", { count: purchasesSummary.purchaseCount })}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/expenses"
|
||||
className="text-sm font-medium text-[#0C447C] hover:underline"
|
||||
>
|
||||
{t("viewInExpenses")}
|
||||
</Link>
|
||||
</div>
|
||||
{purchasesSummary.recent.length > 0 ? (
|
||||
<ul className="mt-3 space-y-1 border-t border-[#0F6E56]/15 pt-3 text-sm">
|
||||
{purchasesSummary.recent.slice(0, 5).map((p) => (
|
||||
<li key={p.id} className="flex justify-between gap-2">
|
||||
<span>
|
||||
{p.ingredientName} (+{formatNumber(p.delta, numberLocale)} {p.unit})
|
||||
</span>
|
||||
<span className="shrink-0 font-medium">
|
||||
{formatNumber(p.totalPaidToman, numberLocale)} ت
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{tab === "materials" && !branchId ? (
|
||||
<p className="text-sm text-amber-800">{t("selectBranchForPurchases")}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={tab === "materials" ? "default" : "outline"}
|
||||
className={tab === "materials" ? "bg-[#0F6E56]" : ""}
|
||||
onClick={() => setTab("materials")}
|
||||
>
|
||||
{t("tabMaterials")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === "recipes" ? "default" : "outline"}
|
||||
className={tab === "recipes" ? "bg-[#0F6E56]" : ""}
|
||||
onClick={() => setTab("recipes")}
|
||||
>
|
||||
{t("tabRecipes")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tab === "materials" ? (
|
||||
<>
|
||||
<Card className="rounded-xl border border-border/80 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("addIngredient")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<LabeledField label={t("name")}>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</LabeledField>
|
||||
<InventoryUnitField value={unit} onChange={setUnit} id="ingredient-unit-new" />
|
||||
<LabeledField label={t("quantity")}>
|
||||
<Input value={qty} onChange={(e) => setQty(e.target.value)} dir="ltr" className="text-end" />
|
||||
</LabeledField>
|
||||
<LabeledField label={t("parLevel")}>
|
||||
<Input value={parLevel} onChange={(e) => setParLevel(e.target.value)} dir="ltr" className="text-end" />
|
||||
</LabeledField>
|
||||
<LabeledField label={t("totalPaid")}>
|
||||
<Input
|
||||
value={totalPaid}
|
||||
onChange={(e) => setTotalPaid(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
placeholder="0"
|
||||
/>
|
||||
</LabeledField>
|
||||
{impliedUnitCost > 0 ? (
|
||||
<p className="text-xs text-muted-foreground sm:col-span-2">
|
||||
{t("impliedUnitCost")}: {formatNumber(impliedUnitCost, numberLocale)} ت / {unit}
|
||||
</p>
|
||||
) : null}
|
||||
<LabeledField label={t("warningPercent")}>
|
||||
<Input value={warningPct} onChange={(e) => setWarningPct(e.target.value)} dir="ltr" className="text-end" />
|
||||
</LabeledField>
|
||||
<LabeledField label={t("reorderLevel")}>
|
||||
<Input value={reorder} onChange={(e) => setReorder(e.target.value)} dir="ltr" className="text-end" />
|
||||
</LabeledField>
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46] self-end"
|
||||
disabled={!name.trim()}
|
||||
onClick={() => createIngredient.mutate()}
|
||||
>
|
||||
{tCommon("save")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
||||
) : ingredients.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{ingredients.map((ing) => {
|
||||
const isEditing = editingId === ing.id;
|
||||
return (
|
||||
<Card
|
||||
key={ing.id}
|
||||
className={cn(
|
||||
"rounded-xl border shadow-sm",
|
||||
ing.isLowStock ? "border-amber-300 bg-amber-50/50" : "border-border/80"
|
||||
)}
|
||||
>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("editIngredient")}
|
||||
</p>
|
||||
<LabeledField label={t("name")}>
|
||||
<Input value={editName} onChange={(e) => setEditName(e.target.value)} />
|
||||
</LabeledField>
|
||||
<InventoryUnitField
|
||||
value={editUnit}
|
||||
onChange={setEditUnit}
|
||||
id={`ingredient-unit-${ing.id}`}
|
||||
/>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<LabeledField label={t("parLevel")}>
|
||||
<Input
|
||||
value={editParLevel}
|
||||
onChange={(e) => setEditParLevel(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("unitCost")}>
|
||||
<Input
|
||||
value={editUnitCost}
|
||||
onChange={(e) => setEditUnitCost(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("warningPercent")}>
|
||||
<Input
|
||||
value={editWarningPct}
|
||||
onChange={(e) => setEditWarningPct(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("reorderLevel")}>
|
||||
<Input
|
||||
value={editReorder}
|
||||
onChange={(e) => setEditReorder(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("quantity")}: {formatNumber(ing.quantityOnHand)} {ing.unit} —{" "}
|
||||
{t("quantityEditHint")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={!editName.trim() || updateIngredient.isPending}
|
||||
onClick={() => updateIngredient.mutate(ing.id)}
|
||||
>
|
||||
{tCommon("save")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditingId(null)}
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">{ing.name}</h3>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{ing.isLowStock ? (
|
||||
<Badge variant="outline">{t("lowStock")}</Badge>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8"
|
||||
aria-label={t("editIngredient")}
|
||||
onClick={() => startEdit(ing)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[#0F6E56]">
|
||||
{formatNumber(ing.quantityOnHand)} {ing.unit}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("warningAt")}: {formatNumber(ing.warningThreshold)} {ing.unit}
|
||||
<span className="mx-1">·</span>
|
||||
{t("stockValue")}: {formatNumber(ing.stockValueToman)} ت
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<LabeledField label={t("adjustDelta")} className="min-w-0 flex-1">
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={adjustQty[ing.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setAdjustQty((s) => ({ ...s, [ing.id]: e.target.value }))
|
||||
}
|
||||
dir="ltr"
|
||||
className="h-9 text-end"
|
||||
placeholder="+100"
|
||||
/>
|
||||
</LabeledField>
|
||||
{parseFloat(adjustQty[ing.id] ?? "0") > 0 ? (
|
||||
<LabeledField label={t("totalPaid")} className="min-w-0 flex-1">
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={adjustPaid[ing.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setAdjustPaid((s) => ({ ...s, [ing.id]: e.target.value }))
|
||||
}
|
||||
dir="ltr"
|
||||
className="h-9 text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!branchId && parseFloat(adjustQty[ing.id] ?? "0") > 0}
|
||||
onClick={() => {
|
||||
const delta = parseFloat(adjustQty[ing.id] ?? "0");
|
||||
if (!delta) return;
|
||||
const paid = parseFloat(adjustPaid[ing.id] ?? "0");
|
||||
if (delta > 0 && paid <= 0) {
|
||||
notify.error(t("purchaseRequired"));
|
||||
return;
|
||||
}
|
||||
adjustStock.mutate({
|
||||
id: ing.id,
|
||||
delta,
|
||||
paid: delta > 0 ? paid : undefined,
|
||||
});
|
||||
setAdjustQty((s) => ({ ...s, [ing.id]: "" }));
|
||||
setAdjustPaid((s) => ({ ...s, [ing.id]: "" }));
|
||||
}}
|
||||
>
|
||||
{t("adjust")}
|
||||
</Button>
|
||||
</div>
|
||||
{parseFloat(adjustQty[ing.id] ?? "0") > 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">{t("purchaseHint")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card className="rounded-xl border border-border/80 p-4 space-y-4">
|
||||
<LabeledField label={t("selectMenuItem")}>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={selectedMenuItemId}
|
||||
onChange={(e) => {
|
||||
setSelectedMenuItemId(e.target.value);
|
||||
setRecipeDraft([]);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("selectMenuItemPlaceholder")}</option>
|
||||
{menuItems.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
|
||||
{selectedMenuItemId && recipe ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("materialCostPerUnit")}: {formatNumber(recipe.materialCostPerUnitToman)} ت
|
||||
</p>
|
||||
<p className="text-[11px] uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("recipeLines")}
|
||||
</p>
|
||||
{recipeDraft.map((line, idx) => (
|
||||
<div key={line.ingredientId} className="flex flex-wrap items-end gap-2">
|
||||
<span className="text-sm flex-1 min-w-[120px]">
|
||||
{line.ingredientName} ({line.unit})
|
||||
</span>
|
||||
<Input
|
||||
className="w-28 text-end"
|
||||
dir="ltr"
|
||||
value={String(line.quantityPerUnit)}
|
||||
onChange={(e) => {
|
||||
const next = [...recipeDraft];
|
||||
next[idx] = { ...line, quantityPerUnit: parseFloat(e.target.value) || 0 };
|
||||
setRecipeDraft(next);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => setRecipeDraft(recipeDraft.filter((_, i) => i !== idx))}
|
||||
>
|
||||
{tCommon("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap items-end gap-2 border-t pt-3">
|
||||
<select
|
||||
className="h-10 flex-1 min-w-[140px] rounded-md border border-input px-2 text-sm"
|
||||
value={newRecipeIngredientId}
|
||||
onChange={(e) => setNewRecipeIngredientId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("pickIngredient")}</option>
|
||||
{ingredients.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.name} ({i.unit})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
className="w-24 text-end"
|
||||
dir="ltr"
|
||||
value={newRecipeQty}
|
||||
onChange={(e) => setNewRecipeQty(e.target.value)}
|
||||
placeholder={t("perUnit")}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const ing = ingredients.find((i) => i.id === newRecipeIngredientId);
|
||||
if (!ing) return;
|
||||
if (recipeDraft.some((l) => l.ingredientId === ing.id)) return;
|
||||
setRecipeDraft([
|
||||
...recipeDraft,
|
||||
{
|
||||
id: `draft_${ing.id}`,
|
||||
ingredientId: ing.id,
|
||||
ingredientName: ing.name,
|
||||
unit: ing.unit,
|
||||
quantityPerUnit: parseFloat(newRecipeQty) || 0,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
{t("addLine")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-[#0F6E56]"
|
||||
disabled={saveRecipe.isPending}
|
||||
onClick={() => saveRecipe.mutate()}
|
||||
>
|
||||
{t("saveRecipe")}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">{t("recipeHint")}</p>
|
||||
</>
|
||||
) : selectedMenuItemId ? (
|
||||
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
||||
) : null}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { INVENTORY_UNITS, isKnownInventoryUnit } from "@/lib/inventory-units";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
|
||||
const CUSTOM_VALUE = "__custom__";
|
||||
|
||||
type InventoryUnitFieldProps = {
|
||||
value: string;
|
||||
onChange: (unit: string) => void;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export function InventoryUnitField({ value, onChange, id }: InventoryUnitFieldProps) {
|
||||
const t = useTranslations("inventory");
|
||||
const selectValue = isKnownInventoryUnit(value) ? value : CUSTOM_VALUE;
|
||||
const showCustom = selectValue === CUSTOM_VALUE;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<LabeledField label={t("unit")} htmlFor={id}>
|
||||
<select
|
||||
id={id}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={selectValue}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
if (next === CUSTOM_VALUE) {
|
||||
onChange(value && !isKnownInventoryUnit(value) ? value : "");
|
||||
} else {
|
||||
onChange(next);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{INVENTORY_UNITS.map((u) => (
|
||||
<option key={u.value} value={u.value}>
|
||||
{t(`units.${u.key}`)}
|
||||
</option>
|
||||
))}
|
||||
<option value={CUSTOM_VALUE}>{t("unitCustom")}</option>
|
||||
</select>
|
||||
</LabeledField>
|
||||
{showCustom ? (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={t("unitCustomPlaceholder")}
|
||||
/>
|
||||
) : null}
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">{t("unitsHelp")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import * as signalR from "@microsoft/signalr";
|
||||
import { apiGet, apiPatch } from "@/lib/api/client";
|
||||
import type { LiveOrder } from "@/lib/api/types";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||
import { formatOrderNumber } from "@/lib/order-number";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_FLOW: Record<string, string> = {
|
||||
Pending: "Confirmed",
|
||||
Confirmed: "Preparing",
|
||||
Preparing: "Ready",
|
||||
Ready: "Delivered",
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
Pending: "border-yellow-400 bg-yellow-50",
|
||||
Confirmed: "border-yellow-400 bg-yellow-50",
|
||||
Preparing: "border-blue-400 bg-blue-50",
|
||||
Ready: "border-green-400 bg-green-50",
|
||||
};
|
||||
|
||||
type KdsT = ReturnType<typeof useTranslations<"kds">>;
|
||||
|
||||
function statusLabel(t: KdsT, status: string): string {
|
||||
switch (status) {
|
||||
case "Pending":
|
||||
return t("status.Pending");
|
||||
case "Confirmed":
|
||||
return t("status.Confirmed");
|
||||
case "Preparing":
|
||||
return t("status.Preparing");
|
||||
case "Ready":
|
||||
return t("status.Ready");
|
||||
case "Delivered":
|
||||
return t("status.Delivered");
|
||||
case "Cancelled":
|
||||
return t("status.Cancelled");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function advanceLabel(t: KdsT, nextStatus: string): string {
|
||||
switch (nextStatus) {
|
||||
case "Confirmed":
|
||||
return t("advanceTo.Confirmed");
|
||||
case "Preparing":
|
||||
return t("advanceTo.Preparing");
|
||||
case "Ready":
|
||||
return t("advanceTo.Ready");
|
||||
case "Delivered":
|
||||
return t("advanceTo.Delivered");
|
||||
default:
|
||||
return t("advance");
|
||||
}
|
||||
}
|
||||
|
||||
export function KdsScreen() {
|
||||
const t = useTranslations("kds");
|
||||
const locale = useLocale();
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const queryClient = useQueryClient();
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
const { data: orders = [], isLoading } = useQuery({
|
||||
queryKey: ["orders-live", cafeId],
|
||||
queryFn: () => apiGet<LiveOrder[]>(`/api/cafes/${cafeId}/orders/live`),
|
||||
enabled: !!cafeId,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] });
|
||||
}, [queryClient, cafeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cafeId) return;
|
||||
|
||||
const token =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("meezi_access_token")
|
||||
: null;
|
||||
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
|
||||
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(`${baseUrl}/hubs/kds`, {
|
||||
accessTokenFactory: () => token ?? "",
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
connection
|
||||
.start()
|
||||
.then(() => connection.invoke("JoinCafe", cafeId))
|
||||
.then(() => setConnected(true))
|
||||
.catch(() => setConnected(false));
|
||||
|
||||
connection.on("OrderCreated", () => refresh());
|
||||
connection.on("OrderStatusChanged", () => refresh());
|
||||
|
||||
return () => {
|
||||
void connection.stop();
|
||||
};
|
||||
}, [cafeId, refresh]);
|
||||
|
||||
const advanceStatus = useMutation({
|
||||
mutationFn: async ({ orderId, status }: { orderId: string; status: string }) =>
|
||||
apiPatch(`/api/cafes/${cafeId}/orders/${orderId}/status`, { status }),
|
||||
onSuccess: () => refresh(),
|
||||
});
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
const columns = [
|
||||
{ key: "Pending", label: t("pending"), statuses: ["Pending", "Confirmed"] },
|
||||
{ key: "Preparing", label: t("preparing"), statuses: ["Preparing"] },
|
||||
{ key: "Ready", label: t("ready"), statuses: ["Ready"] },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">{t("title")}</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{connected ? `● ${t("live")}` : `○ ${t("polling")}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">{t("loading")}</p>
|
||||
) : orders.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t("noOrders")}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{columns.map((col) => (
|
||||
<div key={col.key} className="space-y-3">
|
||||
<h3 className="font-semibold">{col.label}</h3>
|
||||
{orders
|
||||
.filter((o) => col.statuses.includes(o.status))
|
||||
.map((order) => {
|
||||
const nextStatus = STATUS_FLOW[order.status];
|
||||
return (
|
||||
<Card
|
||||
key={order.id}
|
||||
className={cn("border-2", statusColors[order.status] ?? "")}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex justify-between gap-2 text-base">
|
||||
<span className="min-w-0">
|
||||
#{formatOrderNumber(order)}
|
||||
{" · "}
|
||||
{order.tableNumber
|
||||
? `${t("table")} ${formatNumber(order.tableNumber, numberLocale)}`
|
||||
: "—"}
|
||||
</span>
|
||||
<span className="shrink-0 text-sm font-normal">
|
||||
{formatCurrency(order.total, numberLocale)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<span className="mt-1 inline-flex w-fit rounded-md border border-border/80 bg-card/80 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
{statusLabel(t, order.status)}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<ul className="text-sm">
|
||||
{order.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
{formatNumber(item.quantity, numberLocale)}×{" "}
|
||||
{item.menuItemName}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{nextStatus ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={advanceStatus.isPending}
|
||||
onClick={() =>
|
||||
advanceStatus.mutate({
|
||||
orderId: order.id,
|
||||
status: nextStatus,
|
||||
})
|
||||
}
|
||||
>
|
||||
{advanceLabel(t, nextStatus)}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { apiGet } from "@/lib/api/client";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
|
||||
type Branch = { id: string; name: string };
|
||||
|
||||
type BranchFilterSelectProps = {
|
||||
value: string | null;
|
||||
onChange: (branchId: string | null) => void;
|
||||
includeAll?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function BranchFilterSelect({
|
||||
value,
|
||||
onChange,
|
||||
includeAll = true,
|
||||
className,
|
||||
}: BranchFilterSelectProps) {
|
||||
const t = useTranslations("tables");
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
|
||||
const { data: branches = [] } = useQuery({
|
||||
queryKey: ["branches", cafeId],
|
||||
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
if (!cafeId || branches.length === 0) return null;
|
||||
if (!includeAll && branches.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<select
|
||||
className={className ?? "rounded-md border border-input bg-background px-3 py-2 text-sm"}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
aria-label={t("branchFilter")}
|
||||
>
|
||||
{includeAll ? (
|
||||
<option value="">{t("allBranches")}</option>
|
||||
) : null}
|
||||
{branches.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { apiGet } from "@/lib/api/client";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useBranchStore } from "@/lib/stores/branch.store";
|
||||
|
||||
type Branch = { id: string; name: string };
|
||||
|
||||
export function BranchSelect({ className }: { className?: string }) {
|
||||
const t = useTranslations("branches");
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const branchId = useBranchStore((s) => s.branchId);
|
||||
const setBranchId = useBranchStore((s) => s.setBranchId);
|
||||
|
||||
const { data: branches = [] } = useQuery({
|
||||
queryKey: ["branches", cafeId],
|
||||
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (branches.length === 0) return;
|
||||
const valid = branchId && branches.some((b) => b.id === branchId);
|
||||
if (!valid) setBranchId(branches[0]!.id);
|
||||
}, [branches, branchId, setBranchId]);
|
||||
|
||||
if (!cafeId || branches.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<select
|
||||
className={className ?? "rounded-md border border-input bg-background px-3 py-2 text-sm"}
|
||||
value={branchId ?? ""}
|
||||
onChange={(e) => setBranchId(e.target.value || null)}
|
||||
aria-label={t("label")}
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { format } from "date-fns-jalali";
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useLiveClock } from "@/lib/hooks/use-live-clock";
|
||||
import { useOnlineStatus } from "@/lib/hooks/use-online-status";
|
||||
import {
|
||||
formatHeaderJalaliDate,
|
||||
formatHeaderTime,
|
||||
isPlanTierKey,
|
||||
} from "@/lib/format-datetime";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function HeaderDivider() {
|
||||
return <div className="mx-3 h-9 w-px shrink-0 bg-border/80" aria-hidden />;
|
||||
}
|
||||
|
||||
/** WiFi + Jalali date/time + plan — grouped at header center; clock is the middle focus. */
|
||||
export function HeaderCenterCluster() {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("dashboard");
|
||||
const tPlanNames = useTranslations("settings.plans.names");
|
||||
const online = useOnlineStatus();
|
||||
const now = useLiveClock();
|
||||
const planTier = useAuthStore((s) => s.user?.planTier);
|
||||
|
||||
const time = useMemo(() => formatHeaderTime(now, locale), [now, locale]);
|
||||
const jalaliDate = useMemo(
|
||||
() => formatHeaderJalaliDate(now, locale),
|
||||
[now, locale]
|
||||
);
|
||||
|
||||
const planLabel = planTier
|
||||
? isPlanTierKey(planTier)
|
||||
? tPlanNames(planTier)
|
||||
: planTier
|
||||
: "—";
|
||||
|
||||
const planBadgeVariant =
|
||||
planTier === "Business" || planTier === "Enterprise"
|
||||
? "default"
|
||||
: planTier === "Pro"
|
||||
? "secondary"
|
||||
: "outline";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 px-1"
|
||||
title={online ? t("online") : t("offline")}
|
||||
aria-label={online ? t("online") : t("offline")}
|
||||
>
|
||||
{online ? (
|
||||
<Wifi className="h-4 w-4 text-emerald-600" aria-hidden />
|
||||
) : (
|
||||
<WifiOff className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"hidden text-xs font-medium whitespace-nowrap lg:inline",
|
||||
online ? "text-emerald-700 dark:text-emerald-500" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{online ? t("online") : t("offline")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<HeaderDivider />
|
||||
|
||||
<div className="flex min-w-[5.5rem] flex-col items-center gap-0.5 px-1 text-center tabular-nums">
|
||||
<time
|
||||
className="max-w-[12rem] truncate text-[11px] leading-none text-muted-foreground"
|
||||
dateTime={format(now, "yyyy-MM-dd")}
|
||||
dir={locale === "en" ? "ltr" : "rtl"}
|
||||
>
|
||||
{jalaliDate}
|
||||
</time>
|
||||
<time
|
||||
className="text-base font-semibold leading-none tracking-tight sm:text-lg"
|
||||
dateTime={format(now, "HH:mm:ss")}
|
||||
dir="ltr"
|
||||
>
|
||||
{time}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<HeaderDivider />
|
||||
|
||||
<Link
|
||||
href="/subscription"
|
||||
className="pointer-events-auto flex flex-col items-center gap-0.5 rounded-md px-1 py-0.5 transition-colors hover:bg-accent/60"
|
||||
title={t("viewSubscription")}
|
||||
>
|
||||
<span className="text-[10px] font-medium uppercase leading-none tracking-wide text-muted-foreground">
|
||||
{t("activePlan")}
|
||||
</span>
|
||||
<Badge variant={planBadgeVariant} className="text-xs font-semibold">
|
||||
{planLabel}
|
||||
</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PageHeaderProps = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PageHeader({ title, subtitle, action, className }: PageHeaderProps) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="text-start">
|
||||
<h1 className="text-lg font-medium tracking-tight text-foreground">{title}</h1>
|
||||
{subtitle ? (
|
||||
<p className="mt-1 text-start text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link, usePathname } from "@/i18n/routing";
|
||||
import { canSeeNavGroup, canSeeNavItem } from "@/lib/auth-permissions";
|
||||
import {
|
||||
NAV_GROUPS,
|
||||
NAV_GROUPS_STORAGE_KEY,
|
||||
findNavGroupForPath,
|
||||
type NavGroupDef,
|
||||
type NavGroupId,
|
||||
type NavItemDef,
|
||||
} from "@/lib/sidebar-nav";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type OpenGroupsState = Partial<Record<NavGroupId, boolean>>;
|
||||
|
||||
function readStoredOpenGroups(): OpenGroupsState {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(NAV_GROUPS_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as OpenGroupsState;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function buildDefaultOpenGroups(): OpenGroupsState {
|
||||
const stored = readStoredOpenGroups();
|
||||
const defaults: OpenGroupsState = {};
|
||||
for (const g of NAV_GROUPS) {
|
||||
defaults[g.id] = stored[g.id] ?? g.defaultOpen;
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
function persistOpenGroups(next: OpenGroupsState): void {
|
||||
try {
|
||||
localStorage.setItem(NAV_GROUPS_STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignore quota */
|
||||
}
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
item,
|
||||
label,
|
||||
active,
|
||||
}: {
|
||||
item: NavItemDef;
|
||||
label: string;
|
||||
active: boolean;
|
||||
}) {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"group flex items-center rounded-lg px-3 py-2 text-sm transition-colors cursor-pointer",
|
||||
active
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 me-2.5",
|
||||
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function NavGroupSection({
|
||||
group,
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
pathname,
|
||||
role,
|
||||
branchId,
|
||||
tItem,
|
||||
}: {
|
||||
group: NavGroupDef;
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
pathname: string;
|
||||
role: string | undefined;
|
||||
branchId: string | null | undefined;
|
||||
tItem: (key: string) => string;
|
||||
}) {
|
||||
const visibleItems = group.items.filter((item) =>
|
||||
canSeeNavItem(item.key, role, branchId)
|
||||
);
|
||||
if (visibleItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-start transition-colors hover:bg-accent/50 cursor-pointer"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3 w-3 shrink-0 text-muted-foreground/60 transition-transform duration-200",
|
||||
open && "rotate-180"
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/70">
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-200",
|
||||
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="space-y-0.5 pb-1 pt-0.5">
|
||||
{visibleItems.map((item) => {
|
||||
const active =
|
||||
pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.key}
|
||||
item={item}
|
||||
label={tItem(item.key)}
|
||||
active={active}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ side }: { side: "left" | "right" }) {
|
||||
const t = useTranslations("nav");
|
||||
const tGroups = useTranslations("nav.groups");
|
||||
const tBrand = useTranslations("brand");
|
||||
const pathname = usePathname();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const hasHydrated = useAuthStore((s) => s._hasHydrated);
|
||||
const role = user?.role;
|
||||
const branchId = user?.branchId ?? null;
|
||||
|
||||
const [openGroups, setOpenGroups] = useState<OpenGroupsState>(buildDefaultOpenGroups);
|
||||
|
||||
const visibleGroups = useMemo(
|
||||
() =>
|
||||
NAV_GROUPS.filter((g) => {
|
||||
if (!canSeeNavGroup(g.id, role, branchId)) return false;
|
||||
return g.items.some((item) => canSeeNavItem(item.key, role, branchId));
|
||||
}),
|
||||
[role, branchId]
|
||||
);
|
||||
|
||||
const setGroupOpen = useCallback((groupId: NavGroupId, open: boolean) => {
|
||||
setOpenGroups((prev) => {
|
||||
const next = { ...prev, [groupId]: open };
|
||||
persistOpenGroups(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const activeGroup = findNavGroupForPath(pathname);
|
||||
if (!activeGroup) return;
|
||||
setOpenGroups((prev) => {
|
||||
if (prev[activeGroup]) return prev;
|
||||
const next = { ...prev, [activeGroup]: true };
|
||||
persistOpenGroups(next);
|
||||
return next;
|
||||
});
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex w-56 shrink-0 flex-col bg-background",
|
||||
"border-border",
|
||||
side === "right" ? "border-s" : "border-e"
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex h-14 items-center gap-2.5 px-4 border-b border-border">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-primary" aria-hidden>
|
||||
<path d="M3 6h18v2H3V6zm2 4h14v2H5v-2zm-2 4h18v2H3v-2zm4 4h10v2H7v-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-bold tracking-tight text-foreground">
|
||||
{tBrand("name")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav
|
||||
className="flex-1 overflow-y-auto p-3 space-y-1
|
||||
[&::-webkit-scrollbar]:w-1
|
||||
[&::-webkit-scrollbar-track]:bg-transparent
|
||||
[&::-webkit-scrollbar-thumb]:rounded-full
|
||||
[&::-webkit-scrollbar-thumb]:bg-border"
|
||||
aria-label={t("aria")}
|
||||
>
|
||||
{!hasHydrated ? (
|
||||
/* Skeleton — shown for ~50ms until Zustand rehydrates from localStorage.
|
||||
Prevents the flash where all groups are briefly visible before
|
||||
permission-based filtering kicks in for branch-scoped accounts. */
|
||||
<div className="space-y-3 px-1 pt-1">
|
||||
{[40, 32, 40, 32, 40].map((w, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="h-2 w-20 animate-pulse rounded bg-muted" />
|
||||
{Array.from({ length: i % 2 === 0 ? 3 : 2 }).map((_, j) => (
|
||||
<div
|
||||
key={j}
|
||||
className={`h-8 animate-pulse rounded-lg bg-muted`}
|
||||
style={{ width: `${w + j * 4}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
visibleGroups.map((group) => {
|
||||
const isOpen = openGroups[group.id] ?? group.defaultOpen;
|
||||
return (
|
||||
<NavGroupSection
|
||||
key={group.id}
|
||||
group={group}
|
||||
title={tGroups(group.id)}
|
||||
open={isOpen}
|
||||
onToggle={() => setGroupOpen(group.id, !isOpen)}
|
||||
pathname={pathname}
|
||||
role={role}
|
||||
branchId={branchId}
|
||||
tItem={(key) => t(key)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Footer — user role badge */}
|
||||
{user && (
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<span className="text-[11px] font-semibold text-primary">
|
||||
{(user.actor ?? user.role).charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{user.actor ?? user.userId}
|
||||
</p>
|
||||
<p className="truncate text-[10px] text-muted-foreground">{user.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { WifiOff, CloudUpload, RefreshCw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
||||
import { useLocale } from "next-intl";
|
||||
import {
|
||||
getAllQueueItems,
|
||||
getQueueCount,
|
||||
removeQueueItem,
|
||||
markQueueItemFailed,
|
||||
} from "@/lib/offline/offline-db";
|
||||
import { apiPost } from "@/lib/api/client";
|
||||
|
||||
/** Manual retry — fires one sync pass immediately (used as onClick). */
|
||||
async function runManualSync(
|
||||
setSyncing: (v: boolean) => void,
|
||||
setQueueCount: (n: number) => void
|
||||
) {
|
||||
if (!navigator.onLine) return;
|
||||
setSyncing(true);
|
||||
try {
|
||||
const items = await getAllQueueItems();
|
||||
for (const item of items) {
|
||||
try {
|
||||
if (item.type === "create_order") {
|
||||
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
|
||||
await apiPost(`/api/cafes/${cafeId}/orders`, body as Record<string, unknown>);
|
||||
} else if (item.type === "add_items") {
|
||||
const { cafeId, orderId, body } = item.payload as {
|
||||
cafeId: string;
|
||||
orderId: string;
|
||||
body: unknown;
|
||||
};
|
||||
await apiPost(
|
||||
`/api/cafes/${cafeId}/orders/${orderId}/items`,
|
||||
body as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
await removeQueueItem(item.id);
|
||||
} catch {
|
||||
await markQueueItemFailed(item.id);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
setQueueCount(await getQueueCount());
|
||||
}
|
||||
}
|
||||
|
||||
export function SyncStatusIndicator() {
|
||||
const { queueCount, isSyncing, isOnline, setSyncing, setQueueCount } =
|
||||
useSyncQueueStore();
|
||||
const locale = useLocale();
|
||||
const isFa = locale !== "en";
|
||||
|
||||
const show = !isOnline || queueCount > 0 || isSyncing;
|
||||
if (!show) return null;
|
||||
|
||||
const label = isFa
|
||||
? !isOnline
|
||||
? "آفلاین"
|
||||
: isSyncing
|
||||
? "همگامسازی..."
|
||||
: `${queueCount} مورد در صف`
|
||||
: !isOnline
|
||||
? "Offline"
|
||||
: isSyncing
|
||||
? "Syncing..."
|
||||
: `${queueCount} pending`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runManualSync(setSyncing, setQueueCount)}
|
||||
disabled={isSyncing || !isOnline}
|
||||
title={
|
||||
isFa
|
||||
? "برای همگامسازی دستی کلیک کنید"
|
||||
: "Click to retry sync"
|
||||
}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors",
|
||||
"disabled:cursor-not-allowed",
|
||||
!isOnline
|
||||
? "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"
|
||||
: isSyncing
|
||||
? "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
: "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300"
|
||||
)}
|
||||
>
|
||||
{!isOnline ? (
|
||||
<WifiOff className="h-3 w-3 shrink-0" aria-hidden />
|
||||
) : isSyncing ? (
|
||||
<RefreshCw className="h-3 w-3 shrink-0 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<CloudUpload className="h-3 w-3 shrink-0" aria-hidden />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Link, useRouter, usePathname } from "@/i18n/routing";
|
||||
import { Pencil, LogOut } from "lucide-react";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useCafeSettings } from "@/lib/hooks/use-cafe-settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { HeaderCenterCluster } from "@/components/layout/header-center-cluster";
|
||||
import { NotificationCenter } from "@/components/notifications/notification-center";
|
||||
import { SyncStatusIndicator } from "@/components/layout/sync-status-indicator";
|
||||
|
||||
const locales = ["fa", "ar", "en"] as const;
|
||||
|
||||
export function Topbar() {
|
||||
const t = useTranslations();
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const clearAuth = useAuthStore((s) => s.clearAuth);
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const { data: cafeSettings, isLoading, isPending } = useCafeSettings(cafeId);
|
||||
const cafeDisplayName = cafeSettings?.name ?? t("dashboard.cafeName");
|
||||
const showNameSkeleton = (isLoading || isPending) && !cafeSettings;
|
||||
|
||||
const switchLocale = (next: string) => {
|
||||
router.replace(pathname, { locale: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="relative flex h-14 items-center gap-3 border-b border-border bg-background px-4 sm:px-6">
|
||||
{/* Cafe name */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{showNameSkeleton ? (
|
||||
<Skeleton className="h-5 w-32 max-w-full" />
|
||||
) : (
|
||||
<Link
|
||||
href="/settings"
|
||||
className="group inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-lg px-2 py-1 transition-colors hover:bg-accent cursor-pointer"
|
||||
title={t("dashboard.editCafeSettings")}
|
||||
>
|
||||
<h1 className="truncate text-sm font-semibold text-foreground sm:text-base">
|
||||
{cafeDisplayName}
|
||||
</h1>
|
||||
<Pencil
|
||||
className="h-3 w-3 shrink-0 text-muted-foreground/50 transition-colors group-hover:text-primary"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="sr-only">{t("dashboard.editCafeSettings")}</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<HeaderCenterCluster />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-1 items-center justify-end gap-1.5">
|
||||
<SyncStatusIndicator />
|
||||
<NotificationCenter />
|
||||
|
||||
{/* Language switcher */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2.5 text-xs cursor-pointer">
|
||||
{t(`languages.${locale}`)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[120px]">
|
||||
{locales.map((code) => (
|
||||
<DropdownMenuItem
|
||||
key={code}
|
||||
onClick={() => switchLocale(code)}
|
||||
className={locale === code ? "font-semibold text-primary cursor-pointer" : "cursor-pointer"}
|
||||
>
|
||||
{t(`languages.${code}`)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Logout */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
clearAuth();
|
||||
router.push("/login");
|
||||
}}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
title={t("common.logout")}
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" aria-hidden />
|
||||
<span className="sr-only">{t("common.logout")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { Clock, X, Zap } from "lucide-react";
|
||||
|
||||
// 14 Khordad 1405 = June 4, 2026 (Tehran UTC+3:30)
|
||||
const DEADLINE = new Date("2026-06-04T00:00:00+03:30");
|
||||
const STORAGE_KEY = "meezi_trial_banner_v1";
|
||||
|
||||
interface TimeLeft {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
function calcTimeLeft(): TimeLeft {
|
||||
const diff = Math.max(0, DEADLINE.getTime() - Date.now());
|
||||
return {
|
||||
days: Math.floor(diff / 86_400_000),
|
||||
hours: Math.floor((diff % 86_400_000) / 3_600_000),
|
||||
minutes: Math.floor((diff % 3_600_000) / 60_000),
|
||||
seconds: Math.floor((diff % 60_000) / 1_000),
|
||||
};
|
||||
}
|
||||
|
||||
function pad(n: number) {
|
||||
return n.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
export function TrialCountdownBanner() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const isRtl = locale !== "en";
|
||||
|
||||
// Start hidden — reveal after mount so we can read localStorage without SSR mismatch
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState<TimeLeft>(calcTimeLeft);
|
||||
const [expired, setExpired] = useState(false);
|
||||
|
||||
// Hydrate visibility from localStorage
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(STORAGE_KEY) !== "1") {
|
||||
setVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Tick every second
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const id = setInterval(() => {
|
||||
const tl = calcTimeLeft();
|
||||
setTimeLeft(tl);
|
||||
if (tl.days === 0 && tl.hours === 0 && tl.minutes === 0 && tl.seconds === 0) {
|
||||
setExpired(true);
|
||||
}
|
||||
}, 1_000);
|
||||
return () => clearInterval(id);
|
||||
}, [visible]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
setVisible(false);
|
||||
localStorage.setItem(STORAGE_KEY, "1");
|
||||
};
|
||||
|
||||
const urgency = timeLeft.days <= 3; // red when ≤ 3 days left
|
||||
const soon = timeLeft.days <= 7; // amber when ≤ 7 days left
|
||||
|
||||
const bgClass = urgency
|
||||
? "bg-red-600"
|
||||
: soon
|
||||
? "bg-amber-500"
|
||||
: "bg-[#0F6E56]";
|
||||
|
||||
const textFa = expired
|
||||
? "دوره آزمایشی میزی به پایان رسید. برای ادامه پلن انتخاب کنید."
|
||||
: "دوره آزمایشی رایگان تا ۱۴ خرداد ۱۴۰۵";
|
||||
|
||||
const textEn = expired
|
||||
? "Your Meezi trial has ended. Choose a plan to continue."
|
||||
: "Free trial ends 14 Khordad 1405 (Jun 4)";
|
||||
|
||||
const Digit = ({ value, label }: { value: number; label: string }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="min-w-[2.25rem] rounded-md bg-white/20 px-2 py-0.5 text-center text-base font-extrabold tabular-nums leading-tight text-white sm:text-lg">
|
||||
{pad(value)}
|
||||
</span>
|
||||
<span className="mt-0.5 text-[10px] font-medium text-white/70">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const labelsFa = ["روز", "ساعت", "دقیقه", "ثانیه"];
|
||||
const labelsEn = ["d", "h", "m", "s"];
|
||||
const labels = isRtl ? labelsFa : labelsEn;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-2 sm:px-6 ${bgClass} transition-colors duration-700`}
|
||||
role="banner"
|
||||
aria-live="polite"
|
||||
>
|
||||
{/* Icon + message */}
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<Clock className="h-4 w-4 shrink-0 opacity-80" />
|
||||
<span className="text-xs font-semibold sm:text-sm">
|
||||
{isRtl ? textFa : textEn}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Countdown digits */}
|
||||
{!expired && (
|
||||
<div className="flex items-end gap-2">
|
||||
<Digit value={timeLeft.days} label={labels[0]} />
|
||||
<span className="mb-3 text-white/60 font-bold">:</span>
|
||||
<Digit value={timeLeft.hours} label={labels[1]} />
|
||||
<span className="mb-3 text-white/60 font-bold">:</span>
|
||||
<Digit value={timeLeft.minutes} label={labels[2]} />
|
||||
<span className="mb-3 text-white/60 font-bold">:</span>
|
||||
<Digit value={timeLeft.seconds} label={labels[3]} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<button
|
||||
onClick={() => router.push("/subscription")}
|
||||
className="ms-auto flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-bold text-gray-900 shadow-sm transition hover:bg-gray-100 active:scale-95"
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5 text-amber-500" />
|
||||
{isRtl ? "ارتقا به پرو" : "Upgrade to Pro"}
|
||||
</button>
|
||||
|
||||
{/* Dismiss */}
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="shrink-0 rounded p-0.5 text-white/70 transition hover:text-white"
|
||||
aria-label={isRtl ? "بستن" : "Dismiss"}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { ImagePlus, Video } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { apiUpload, resolveMediaUrl } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MediaKind = "menu" | "table";
|
||||
|
||||
type MediaPairUploadProps = {
|
||||
cafeId: string;
|
||||
kind: MediaKind;
|
||||
imageUrl?: string | null;
|
||||
videoUrl?: string | null;
|
||||
onImageChange: (url: string | null) => void;
|
||||
onVideoChange: (url: string | null) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function MediaPairUpload({
|
||||
cafeId,
|
||||
kind,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
onImageChange,
|
||||
onVideoChange,
|
||||
className,
|
||||
}: MediaPairUploadProps) {
|
||||
const t = useTranslations("media");
|
||||
const imageRef = useRef<HTMLInputElement>(null);
|
||||
const videoRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const imageEndpoint =
|
||||
kind === "menu"
|
||||
? `/api/cafes/${cafeId}/media/menu-image`
|
||||
: `/api/cafes/${cafeId}/media/table-image`;
|
||||
const videoEndpoint =
|
||||
kind === "menu"
|
||||
? `/api/cafes/${cafeId}/media/menu-video`
|
||||
: `/api/cafes/${cafeId}/media/table-video`;
|
||||
|
||||
const imgSrc = resolveMediaUrl(imageUrl);
|
||||
const vidSrc = resolveMediaUrl(videoUrl);
|
||||
|
||||
const upload = async (file: File, endpoint: string, onDone: (url: string) => void) => {
|
||||
const data = await apiUpload<{ url: string }>(endpoint, file);
|
||||
onDone(data.url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
ref={imageRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void upload(f, imageEndpoint, onImageChange);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={videoRef}
|
||||
type="file"
|
||||
accept="video/mp4,video/webm,video/quicktime"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void upload(f, videoEndpoint, onVideoChange);
|
||||
}}
|
||||
/>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => imageRef.current?.click()}>
|
||||
<ImagePlus className="me-1 h-3.5 w-3.5" />
|
||||
{t("uploadImage")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => videoRef.current?.click()}>
|
||||
<Video className="me-1 h-3.5 w-3.5" />
|
||||
{t("uploadVideo")}
|
||||
</Button>
|
||||
{imageUrl ? (
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => onImageChange(null)}>
|
||||
{t("removeImage")}
|
||||
</Button>
|
||||
) : null}
|
||||
{videoUrl ? (
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => onVideoChange(null)}>
|
||||
{t("removeVideo")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{imgSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt=""
|
||||
className="h-20 w-20 rounded-lg border object-cover"
|
||||
/>
|
||||
) : null}
|
||||
{vidSrc ? (
|
||||
<video
|
||||
src={vidSrc}
|
||||
className="h-20 max-w-[140px] rounded-lg border object-cover"
|
||||
muted
|
||||
playsInline
|
||||
controls
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiGet, apiUpload, ApiClientError } from "@/lib/api/client";
|
||||
import { MENU_3D_GLB_MAX_MB, MENU_360_PHOTO_COUNT } from "@/lib/menu-3d";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Menu3dUploadProps = {
|
||||
cafeId: string;
|
||||
model3dUrl?: string | null;
|
||||
onChange: (url: string | null) => void;
|
||||
};
|
||||
|
||||
export function Menu3dUpload({ cafeId, model3dUrl, onChange }: Menu3dUploadProps) {
|
||||
const t = useTranslations("media");
|
||||
const tSub = useTranslations("subscription");
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const { data: billing } = useQuery({
|
||||
queryKey: ["billing-status", cafeId],
|
||||
queryFn: () => apiGet<{ menu3dEnabled: boolean }>("/api/billing/status"),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
const enabled = billing?.menu3dEnabled ?? false;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border border-dashed border-border/80 bg-muted/20 p-3">
|
||||
<p className="text-xs font-medium text-foreground">{t("upload3dTitle")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("upload3dHint", { maxMb: MENU_3D_GLB_MAX_MB })}</p>
|
||||
{!enabled ? (
|
||||
<p className="text-xs text-amber-700">{tSub("featureMenu3dUpgrade")}</p>
|
||||
) : null}
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("upload3dPhotoCount", {
|
||||
min: MENU_360_PHOTO_COUNT.min,
|
||||
ideal: MENU_360_PHOTO_COUNT.ideal,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
ref={ref}
|
||||
type="file"
|
||||
accept=".glb,model/gltf-binary"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
void apiUpload<{ url: string }>(`/api/cafes/${cafeId}/media/menu-model3d`, f)
|
||||
.then((d) => onChange(d.url))
|
||||
.catch((err) => {
|
||||
if (err instanceof ApiClientError && err.code === "PLAN_FEATURE_DISABLED") {
|
||||
alert(tSub("featureMenu3dUpgrade"));
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!enabled}
|
||||
onClick={() => ref.current?.click()}
|
||||
>
|
||||
<Box className="me-1 h-3.5 w-3.5" />
|
||||
{t("upload3d")}
|
||||
</Button>
|
||||
{model3dUrl ? (
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => onChange(null)}>
|
||||
{t("remove3d")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{model3dUrl ? (
|
||||
<p className="text-[11px] text-[#0F6E56]">{t("upload3dReady")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiGet, apiPost, ApiClientError } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { notify } from "@/lib/notify";
|
||||
|
||||
type MenuAi3dGenerateProps = {
|
||||
cafeId: string;
|
||||
itemId: string;
|
||||
imageUrl?: string | null;
|
||||
onGenerated: (model3dUrl: string) => void;
|
||||
};
|
||||
|
||||
type BillingStatus = {
|
||||
menu3dEnabled: boolean;
|
||||
menuAi3dEnabled: boolean;
|
||||
menuAi3dUsedThisMonth: number;
|
||||
menuAi3dMonthlyLimit: number;
|
||||
};
|
||||
|
||||
type Ai3dUsage = {
|
||||
used: number;
|
||||
limit: number;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export function MenuAi3dGenerate({
|
||||
cafeId,
|
||||
itemId,
|
||||
imageUrl,
|
||||
onGenerated,
|
||||
}: MenuAi3dGenerateProps) {
|
||||
const t = useTranslations("media");
|
||||
const tSub = useTranslations("subscription");
|
||||
const queryClient = useQueryClient();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const { data: billing } = useQuery({
|
||||
queryKey: ["billing-status", cafeId],
|
||||
queryFn: () => apiGet<BillingStatus>("/api/billing/status"),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const { data: usage } = useQuery({
|
||||
queryKey: ["menu-ai-3d-usage", cafeId],
|
||||
queryFn: () => apiGet<Ai3dUsage>(`/api/cafes/${cafeId}/menu/ai-3d/usage`),
|
||||
enabled: !!cafeId && (billing?.menuAi3dEnabled ?? false),
|
||||
});
|
||||
|
||||
const aiEnabled = billing?.menuAi3dEnabled ?? false;
|
||||
const used = usage?.used ?? billing?.menuAi3dUsedThisMonth ?? 0;
|
||||
const limit = usage?.limit ?? billing?.menuAi3dMonthlyLimit ?? 100;
|
||||
const atLimit = limit > 0 && used >= limit;
|
||||
|
||||
const generate = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<{ model3dUrl: string; used: number; limit: number }>(
|
||||
`/api/cafes/${cafeId}/menu/items/${itemId}/ai-3d`,
|
||||
{}
|
||||
),
|
||||
onSuccess: (data) => {
|
||||
onGenerated(data.model3dUrl);
|
||||
void queryClient.invalidateQueries({ queryKey: ["billing-status", cafeId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["menu-ai-3d-usage", cafeId] });
|
||||
notify.success(t("ai3dSuccess"));
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ApiClientError) {
|
||||
if (err.code === "PLAN_FEATURE_DISABLED") {
|
||||
notify.error(tSub("featureMenuAi3dUpgrade"));
|
||||
return;
|
||||
}
|
||||
if (err.code === "PLAN_LIMIT_REACHED") {
|
||||
notify.error(t("ai3dLimitReached"));
|
||||
return;
|
||||
}
|
||||
if (err.code === "NO_IMAGE") {
|
||||
notify.error(t("ai3dNoImage"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
notify.error(t("ai3dFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
const handleClick = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await generate.mutateAsync();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!billing?.menu3dEnabled) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border border-border/80 bg-card p-3">
|
||||
<p className="text-xs font-medium text-foreground">{t("ai3dTitle")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("ai3dHint")}</p>
|
||||
{!aiEnabled ? (
|
||||
<p className="text-xs text-amber-700">{tSub("featureMenuAi3dUpgrade")}</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("ai3dUsage", { used: used.toLocaleString("fa-IR"), limit: limit.toLocaleString("fa-IR") })}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-primary text-primary-foreground hover:opacity-90"
|
||||
disabled={!aiEnabled || !imageUrl || atLimit || busy || generate.isPending}
|
||||
onClick={() => void handleClick()}
|
||||
>
|
||||
<Sparkles className="me-1 h-3.5 w-3.5" />
|
||||
{busy || generate.isPending ? t("ai3dGenerating") : t("ai3dGenerate")}
|
||||
</Button>
|
||||
{!imageUrl ? (
|
||||
<p className="text-[11px] text-amber-700">{t("ai3dNoImage")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Lock } from "lucide-react";
|
||||
import { ApiClientError } from "@/lib/api/client";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import {
|
||||
deleteBranchMenuOverride,
|
||||
getBranchMenu,
|
||||
upsertBranchMenuOverride,
|
||||
type BranchMenuItem,
|
||||
} from "@/lib/api/branch-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { useConfirm } from "@/components/providers/confirm-provider";
|
||||
|
||||
type BranchMenuOverridesProps = {
|
||||
cafeId: string;
|
||||
branchId: string;
|
||||
numberLocale: string;
|
||||
};
|
||||
|
||||
export function BranchMenuOverrides({
|
||||
cafeId,
|
||||
branchId,
|
||||
numberLocale,
|
||||
}: BranchMenuOverridesProps) {
|
||||
const t = useTranslations("branchMenu");
|
||||
const tErrors = useTranslations("errors");
|
||||
const planTier = useAuthStore((s) => s.user?.planTier ?? "Free");
|
||||
const canOverridePrice = planTier !== "Free";
|
||||
const queryClient = useQueryClient();
|
||||
const confirmDialog = useConfirm();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [priceDraft, setPriceDraft] = useState<Record<string, string>>({});
|
||||
|
||||
const { data: rows = [], isLoading } = useQuery({
|
||||
queryKey: ["branch-menu", cafeId, branchId, "manage"],
|
||||
queryFn: () => getBranchMenu(cafeId, branchId, { includeUnavailable: true }),
|
||||
enabled: !!cafeId && !!branchId,
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["branch-menu", cafeId, branchId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["menu-items", cafeId] });
|
||||
};
|
||||
|
||||
const upsert = useMutation({
|
||||
mutationFn: ({
|
||||
menuItemId,
|
||||
isAvailable,
|
||||
priceOverride,
|
||||
}: {
|
||||
menuItemId: string;
|
||||
isAvailable: boolean;
|
||||
priceOverride: number | null;
|
||||
}) =>
|
||||
upsertBranchMenuOverride(cafeId, branchId, menuItemId, {
|
||||
isAvailable,
|
||||
priceOverride,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setMessage(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
if (err instanceof ApiClientError && err.code === "PLAN_LIMIT_REACHED") {
|
||||
setMessage(t("priceOverridePro"));
|
||||
return;
|
||||
}
|
||||
setMessage(tErrors("planLimit"));
|
||||
},
|
||||
});
|
||||
|
||||
const resetOverride = useMutation({
|
||||
mutationFn: (menuItemId: string) =>
|
||||
deleteBranchMenuOverride(cafeId, branchId, menuItemId),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...rows].sort((a, b) => a.name.localeCompare(b.name, "fa")),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const handleToggle = (row: BranchMenuItem) => {
|
||||
upsert.mutate({
|
||||
menuItemId: row.id,
|
||||
isAvailable: !row.isAvailable,
|
||||
priceOverride: row.hasPriceOverride ? row.effectivePrice : null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSavePrice = (row: BranchMenuItem) => {
|
||||
const raw = priceDraft[row.id] ?? String(row.effectivePrice);
|
||||
const parsed = Number(raw.replace(/,/g, ""));
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return;
|
||||
upsert.mutate({
|
||||
menuItemId: row.id,
|
||||
isAvailable: row.isAvailable,
|
||||
priceOverride: parsed,
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = async (row: BranchMenuItem) => {
|
||||
if (!row.isOverridden) return;
|
||||
const ok = await confirmDialog({ description: t("confirmReset") });
|
||||
if (!ok) return;
|
||||
resetOverride.mutate(row.id);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{message ? (
|
||||
<Alert variant="warning" onDismiss={() => setMessage(null)}>
|
||||
{message}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-border/80 bg-card">
|
||||
<table className="w-full min-w-[32rem] text-start text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/80 bg-muted/30 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-3 py-2 font-medium">{t("name")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("masterPrice")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("branchPrice")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("availability")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={cn(
|
||||
"border-b border-border/60 last:border-0",
|
||||
row.isOverridden && "bg-[#E1F5EE]/40"
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<MenuItemLabels item={row} lines={1} primaryClassName="text-sm" />
|
||||
{row.isOverridden ? (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t("overrideActive")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{formatCurrency(row.masterPrice, numberLocale)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{canOverridePrice ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
className="h-8 w-28 text-xs"
|
||||
value={priceDraft[row.id] ?? String(row.effectivePrice)}
|
||||
onChange={(e) =>
|
||||
setPriceDraft((d) => ({ ...d, [row.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs"
|
||||
disabled={upsert.isPending}
|
||||
onClick={() => handleSavePrice(row)}
|
||||
>
|
||||
{t("savePrice")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Lock className="h-3 w-3" aria-hidden />
|
||||
{t("priceOverridePro")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={row.isAvailable}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 shrink-0 rounded-full border transition-colors",
|
||||
row.isAvailable
|
||||
? "border-primary bg-primary"
|
||||
: "border-border bg-muted"
|
||||
)}
|
||||
onClick={() => handleToggle(row)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none inline-block h-5 w-5 translate-y-0.5 rounded-full bg-white shadow transition-transform",
|
||||
row.isAvailable ? "translate-x-5" : "translate-x-0.5"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<span className="ms-2 text-xs text-muted-foreground">
|
||||
{row.isAvailable ? t("available") : t("unavailable")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 text-xs"
|
||||
disabled={!row.isOverridden || resetOverride.isPending}
|
||||
onClick={() => handleReset(row)}
|
||||
>
|
||||
{t("resetOverride")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CATEGORY_EMOJI_GROUPS } from "@/lib/category-emoji-presets";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CategoryEmojiPickerProps = {
|
||||
value: string;
|
||||
onChange: (emoji: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CategoryEmojiPicker({ value, onChange, className }: CategoryEmojiPickerProps) {
|
||||
const t = useTranslations("menuAdmin");
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3 max-h-[min(420px,50vh)] overflow-y-auto pe-1", className)}>
|
||||
{CATEGORY_EMOJI_GROUPS.map((group) => (
|
||||
<div key={group.id} className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t(`iconEmojiGroups.${group.id}`)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{group.emojis.map((emoji, index) => {
|
||||
const selected = value.trim() === emoji;
|
||||
return (
|
||||
<button
|
||||
key={`${group.id}-${index}-${emoji}`}
|
||||
type="button"
|
||||
title={emoji}
|
||||
onClick={() => onChange(emoji)}
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-lg border text-lg transition-all active:scale-[0.96]",
|
||||
selected
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] ring-1 ring-[#0F6E56]/30"
|
||||
: "border-border/80 bg-card hover:border-[#0F6E56]/40 hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { ImagePlus } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
CategoryPresetPicker,
|
||||
type CategoryIconSelection,
|
||||
} from "@/components/menu/category-preset-picker";
|
||||
import { CategoryEmojiPicker } from "@/components/menu/category-emoji-picker";
|
||||
import { CategoryPresetIcon } from "@/components/menu/category-preset-icon";
|
||||
import { CategoryVisual } from "@/components/menu/category-visual";
|
||||
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
|
||||
import { apiUpload, resolveMediaUrl } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CategoryMediaFieldsProps = {
|
||||
cafeId: string;
|
||||
icon: string;
|
||||
iconPresetId: string | null;
|
||||
iconStyle: string | null;
|
||||
imageUrl: string;
|
||||
onIconChange: (value: string) => void;
|
||||
onPresetChange: (value: CategoryIconSelection) => void;
|
||||
onImageChange: (url: string | null) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type MediaTab = "preset" | "emoji" | "image";
|
||||
|
||||
export function CategoryMediaFields({
|
||||
cafeId,
|
||||
icon,
|
||||
iconPresetId,
|
||||
iconStyle,
|
||||
imageUrl,
|
||||
onIconChange,
|
||||
onPresetChange,
|
||||
onImageChange,
|
||||
className,
|
||||
}: CategoryMediaFieldsProps) {
|
||||
const t = useTranslations("menuAdmin");
|
||||
const tMedia = useTranslations("media");
|
||||
const imageRef = useRef<HTMLInputElement>(null);
|
||||
const imgSrc = resolveMediaUrl(imageUrl);
|
||||
const [tab, setTab] = useState<MediaTab>(
|
||||
imageUrl ? "image" : iconPresetId ? "preset" : "preset"
|
||||
);
|
||||
|
||||
const uploadImage = async (file: File) => {
|
||||
const data = await apiUpload<{ url: string }>(
|
||||
`/api/cafes/${cafeId}/media/menu-image`,
|
||||
file
|
||||
);
|
||||
onImageChange(data.url);
|
||||
onPresetChange({ iconPresetId: null, iconStyle: null });
|
||||
onIconChange("");
|
||||
setTab("image");
|
||||
};
|
||||
|
||||
const tabs: { id: MediaTab; label: string }[] = [
|
||||
{ id: "preset", label: t("iconTabPreset") },
|
||||
{ id: "emoji", label: t("iconTabEmoji") },
|
||||
{ id: "image", label: t("iconTabImage") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tabs.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTab(id)}
|
||||
className={cn(
|
||||
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
tab === id
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
||||
: "border-border/80 text-muted-foreground hover:border-[#0F6E56]/40"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "preset" ? (
|
||||
<CategoryPresetPicker
|
||||
value={{
|
||||
iconPresetId,
|
||||
iconStyle: (iconStyle as CategoryIconSelection["iconStyle"]) ?? DEFAULT_CATEGORY_ICON_STYLE,
|
||||
}}
|
||||
onChange={(next) => {
|
||||
onPresetChange(next);
|
||||
if (next.iconPresetId) onIconChange("");
|
||||
onImageChange(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{tab === "emoji" ? (
|
||||
<div className="space-y-3 rounded-lg border border-border/80 bg-muted/20 p-3">
|
||||
{icon.trim() ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{t("iconPreview")}</span>
|
||||
<CategoryVisual icon={icon} size="md" />
|
||||
<button
|
||||
type="button"
|
||||
className="text-[#0F6E56] underline-offset-2 hover:underline"
|
||||
onClick={() => onIconChange("")}
|
||||
>
|
||||
{t("clearIconEmoji")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<CategoryEmojiPicker
|
||||
value={icon}
|
||||
onChange={(emoji) => {
|
||||
onIconChange(emoji);
|
||||
onPresetChange({ iconPresetId: null, iconStyle: null });
|
||||
onImageChange(null);
|
||||
}}
|
||||
/>
|
||||
<LabeledField label={t("categoryIconCustom")} htmlFor="cat-icon" className="max-w-[10rem]">
|
||||
<Input
|
||||
id="cat-icon"
|
||||
value={icon}
|
||||
onChange={(e) => {
|
||||
onIconChange(e.target.value);
|
||||
if (e.target.value.trim()) {
|
||||
onPresetChange({ iconPresetId: null, iconStyle: null });
|
||||
onImageChange(null);
|
||||
}
|
||||
}}
|
||||
placeholder="☕"
|
||||
className="text-center text-lg"
|
||||
maxLength={16}
|
||||
/>
|
||||
</LabeledField>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "image" ? (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
ref={imageRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void uploadImage(f);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => imageRef.current?.click()}>
|
||||
<ImagePlus className="me-1 h-3.5 w-3.5" />
|
||||
{t("categoryImage")}
|
||||
</Button>
|
||||
{imageUrl ? (
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => onImageChange(null)}>
|
||||
{tMedia("removeImage")}
|
||||
</Button>
|
||||
) : null}
|
||||
{iconPresetId ? (
|
||||
<CategoryPresetIcon presetId={iconPresetId} style={iconStyle} size="sm" />
|
||||
) : null}
|
||||
</div>
|
||||
{imgSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={imgSrc} alt="" className="h-16 w-16 rounded-lg border object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import {
|
||||
DEFAULT_CATEGORY_ICON_STYLE,
|
||||
getCategoryIconPreset,
|
||||
getCategoryIconStroke,
|
||||
isCategoryIconStyle,
|
||||
type CategoryIconStyleId,
|
||||
} from "@/lib/category-icon-presets";
|
||||
import type { CafeThemePalette } from "@/lib/cafe-theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CategoryPresetIconProps = {
|
||||
presetId: string;
|
||||
style?: string | null;
|
||||
size?: "xs" | "sm" | "md";
|
||||
className?: string;
|
||||
/** When set (QR guest menu), icon shell uses café theme instead of default Meezi green. */
|
||||
brandColors?: Pick<
|
||||
CafeThemePalette,
|
||||
"primary" | "secondary" | "accent" | "surface" | "textMuted"
|
||||
>;
|
||||
};
|
||||
|
||||
function themedIconShellStyle(
|
||||
styleId: CategoryIconStyleId,
|
||||
c: NonNullable<CategoryPresetIconProps["brandColors"]>
|
||||
): CSSProperties {
|
||||
const primaryRing = `0 0 0 2px color-mix(in srgb, ${c.primary} 20%, transparent)`;
|
||||
switch (styleId) {
|
||||
case "flat":
|
||||
return {
|
||||
backgroundColor: c.secondary,
|
||||
color: c.primary,
|
||||
border: `1px solid color-mix(in srgb, ${c.primary} 15%, transparent)`,
|
||||
};
|
||||
case "modern":
|
||||
return {
|
||||
background: `linear-gradient(135deg, ${c.secondary}, ${c.surface}, color-mix(in srgb, ${c.secondary} 60%, transparent))`,
|
||||
color: c.primary,
|
||||
border: `1px solid color-mix(in srgb, ${c.primary} 20%, transparent)`,
|
||||
};
|
||||
case "minimal":
|
||||
return { backgroundColor: "transparent", color: c.textMuted, border: "1px solid transparent" };
|
||||
case "outline":
|
||||
return {
|
||||
backgroundColor: c.surface,
|
||||
color: c.primary,
|
||||
border: `2px solid color-mix(in srgb, ${c.primary} 35%, transparent)`,
|
||||
};
|
||||
case "soft":
|
||||
return {
|
||||
backgroundColor: `color-mix(in srgb, ${c.secondary} 70%, transparent)`,
|
||||
color: c.primary,
|
||||
border: "none",
|
||||
borderRadius: "0.75rem",
|
||||
};
|
||||
case "bold":
|
||||
return { backgroundColor: c.primary, color: "#fff", border: "none" };
|
||||
case "gradient":
|
||||
return {
|
||||
background: `linear-gradient(to top right, ${c.primary}, color-mix(in srgb, ${c.primary} 75%, ${c.accent}), color-mix(in srgb, ${c.primary} 40%, #fff))`,
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
};
|
||||
case "pastel":
|
||||
return {
|
||||
backgroundColor: `color-mix(in srgb, ${c.secondary} 40%, ${c.surface})`,
|
||||
color: c.accent,
|
||||
border: `1px solid color-mix(in srgb, ${c.accent} 20%, transparent)`,
|
||||
};
|
||||
case "duotone":
|
||||
return {
|
||||
backgroundColor: c.secondary,
|
||||
color: c.accent,
|
||||
border: `1px solid color-mix(in srgb, ${c.accent} 15%, transparent)`,
|
||||
boxShadow: primaryRing,
|
||||
};
|
||||
default:
|
||||
return { backgroundColor: c.secondary, color: c.primary };
|
||||
}
|
||||
}
|
||||
|
||||
const boxSize = {
|
||||
xs: "h-5 w-5",
|
||||
sm: "h-7 w-7",
|
||||
md: "h-10 w-10",
|
||||
} as const;
|
||||
|
||||
const iconSize = {
|
||||
xs: "h-3 w-3",
|
||||
sm: "h-4 w-4",
|
||||
md: "h-5 w-5",
|
||||
} as const;
|
||||
|
||||
function resolveStyle(style: string | null | undefined): CategoryIconStyleId {
|
||||
return isCategoryIconStyle(style) ? style : DEFAULT_CATEGORY_ICON_STYLE;
|
||||
}
|
||||
|
||||
const styleShell: Record<CategoryIconStyleId, string> = {
|
||||
flat: "bg-[#E1F5EE] text-[#0F6E56] border border-[#0F6E56]/15",
|
||||
modern:
|
||||
"bg-gradient-to-br from-[#E1F5EE] via-white to-[#E1F5EE]/60 text-[#0F6E56] border border-[#0F6E56]/20 shadow-sm",
|
||||
minimal: "bg-transparent text-muted-foreground border border-transparent",
|
||||
outline: "bg-white text-[#0F6E56] border-2 border-[#0F6E56]/35",
|
||||
real: "bg-muted/30 border border-border/80 overflow-hidden p-0",
|
||||
soft: "bg-[#E1F5EE]/70 text-[#0F6E56] border-0 shadow-md rounded-xl",
|
||||
bold: "bg-[#0F6E56] text-white border-0 shadow-sm",
|
||||
gradient:
|
||||
"bg-gradient-to-tr from-[#0F6E56] via-[#1a8f6e] to-[#5ec4a8] text-white border-0 shadow-md",
|
||||
pastel: "bg-[#FDF8F3] text-[#BA7517] border border-[#BA7517]/20",
|
||||
duotone: "bg-[#E1F5EE] text-[#0C447C] border border-[#0C447C]/15 ring-2 ring-[#0F6E56]/20",
|
||||
};
|
||||
|
||||
export function CategoryPresetIcon({
|
||||
presetId,
|
||||
style,
|
||||
size = "sm",
|
||||
className,
|
||||
brandColors,
|
||||
}: CategoryPresetIconProps) {
|
||||
const preset = getCategoryIconPreset(presetId);
|
||||
if (!preset) return null;
|
||||
|
||||
const styleId = resolveStyle(style);
|
||||
const Icon = preset.icon;
|
||||
const stroke = getCategoryIconStroke(styleId);
|
||||
|
||||
if (styleId === "real") {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={preset.realImageUrl}
|
||||
alt=""
|
||||
className={cn(
|
||||
"shrink-0 rounded-md object-cover",
|
||||
boxSize[size],
|
||||
styleShell.real,
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const shellStyle = brandColors ? themedIconShellStyle(styleId, brandColors) : undefined;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-md",
|
||||
boxSize[size],
|
||||
!brandColors && styleShell[styleId],
|
||||
className
|
||||
)}
|
||||
style={shellStyle}
|
||||
aria-hidden
|
||||
>
|
||||
<Icon
|
||||
className={cn(iconSize[size], stroke.className)}
|
||||
strokeWidth={stroke.strokeWidth}
|
||||
fill={styleId === "bold" || styleId === "gradient" ? "currentColor" : "none"}
|
||||
fillOpacity={styleId === "bold" || styleId === "gradient" ? 0.15 : 0}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
CATEGORY_ICON_PRESETS,
|
||||
CATEGORY_ICON_STYLES,
|
||||
DEFAULT_CATEGORY_ICON_STYLE,
|
||||
type CategoryIconPresetKind,
|
||||
type CategoryIconStyleId,
|
||||
} from "@/lib/category-icon-presets";
|
||||
import { CategoryPresetIcon } from "@/components/menu/category-preset-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type CategoryIconSelection = {
|
||||
iconPresetId: string | null;
|
||||
iconStyle: CategoryIconStyleId | null;
|
||||
};
|
||||
|
||||
type CategoryPresetPickerProps = {
|
||||
value: CategoryIconSelection;
|
||||
onChange: (value: CategoryIconSelection) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CategoryPresetPicker({ value, onChange, className }: CategoryPresetPickerProps) {
|
||||
const t = useTranslations("menuAdmin");
|
||||
const activeStyle = value.iconStyle ?? DEFAULT_CATEGORY_ICON_STYLE;
|
||||
|
||||
const setStyle = (style: CategoryIconStyleId) => {
|
||||
onChange({
|
||||
iconPresetId: value.iconPresetId,
|
||||
iconStyle: style,
|
||||
});
|
||||
};
|
||||
|
||||
const setPreset = (presetId: string) => {
|
||||
onChange({
|
||||
iconPresetId: presetId,
|
||||
iconStyle: activeStyle,
|
||||
});
|
||||
};
|
||||
|
||||
const clearPreset = () => {
|
||||
onChange({ iconPresetId: null, iconStyle: null });
|
||||
};
|
||||
|
||||
const renderGroup = (kind: CategoryIconPresetKind, label: string) => {
|
||||
const presets = CATEGORY_ICON_PRESETS.filter((p) => p.kind === kind);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{presets.map((preset) => {
|
||||
const selected = value.iconPresetId === preset.id;
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
title={t(`iconPresets.${preset.id}`)}
|
||||
onClick={() => setPreset(preset.id)}
|
||||
className={cn(
|
||||
"rounded-lg border p-1.5 transition-all active:scale-[0.98]",
|
||||
selected
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] ring-1 ring-[#0F6E56]/30"
|
||||
: "border-border/80 bg-card hover:border-[#0F6E56]/40"
|
||||
)}
|
||||
>
|
||||
<CategoryPresetIcon presetId={preset.id} style={activeStyle} size="md" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3 rounded-lg border border-border/80 bg-muted/20 p-3", className)}>
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("iconStyleLabel")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CATEGORY_ICON_STYLES.map((style) => (
|
||||
<button
|
||||
key={style}
|
||||
type="button"
|
||||
onClick={() => setStyle(style)}
|
||||
className={cn(
|
||||
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
activeStyle === style
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
||||
: "border-border/80 bg-card text-muted-foreground hover:border-[#0F6E56]/40"
|
||||
)}
|
||||
>
|
||||
{t(`iconStyles.${style}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{value.iconPresetId ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{t("iconPreview")}</span>
|
||||
<CategoryPresetIcon
|
||||
presetId={value.iconPresetId}
|
||||
style={activeStyle}
|
||||
size="md"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[#0F6E56] underline-offset-2 hover:underline"
|
||||
onClick={clearPreset}
|
||||
>
|
||||
{t("clearIconPreset")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{renderGroup("drink", t("iconPresetGroupDrinks"))}
|
||||
{renderGroup("food", t("iconPresetGroupFood"))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { CategoryPresetIcon } from "@/components/menu/category-preset-icon";
|
||||
import { resolveMediaUrl } from "@/lib/api/client";
|
||||
import type { CafeThemePalette } from "@/lib/cafe-theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CategoryVisualProps = {
|
||||
icon?: string | null;
|
||||
iconPresetId?: string | null;
|
||||
iconStyle?: string | null;
|
||||
imageUrl?: string | null;
|
||||
size?: "xs" | "sm" | "md";
|
||||
className?: string;
|
||||
brandColors?: Pick<
|
||||
CafeThemePalette,
|
||||
"primary" | "secondary" | "accent" | "surface" | "textMuted"
|
||||
>;
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
xs: "h-5 w-5 text-sm",
|
||||
sm: "h-7 w-7 text-base",
|
||||
md: "h-10 w-10 text-xl",
|
||||
} as const;
|
||||
|
||||
export function CategoryVisual({
|
||||
icon,
|
||||
iconPresetId,
|
||||
iconStyle,
|
||||
imageUrl,
|
||||
size = "sm",
|
||||
className,
|
||||
brandColors,
|
||||
}: CategoryVisualProps) {
|
||||
const imgSrc = resolveMediaUrl(imageUrl);
|
||||
const emoji = icon?.trim();
|
||||
|
||||
if (imgSrc) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt=""
|
||||
className={cn(
|
||||
"shrink-0 rounded-md border border-border/80 object-cover",
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (iconPresetId) {
|
||||
return (
|
||||
<CategoryPresetIcon
|
||||
presetId={iconPresetId}
|
||||
style={iconStyle}
|
||||
size={size}
|
||||
className={className}
|
||||
brandColors={brandColors}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (emoji) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-md border",
|
||||
brandColors ? "qr-border" : "border-border/60 bg-muted/40",
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
style={
|
||||
brandColors
|
||||
? {
|
||||
backgroundColor: `color-mix(in srgb, ${brandColors.secondary} 55%, ${brandColors.surface})`,
|
||||
borderColor: `color-mix(in srgb, ${brandColors.primary} 18%, transparent)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useIsRtl } from "@/lib/use-is-rtl";
|
||||
import { Box, Pencil, Video } from "lucide-react";
|
||||
import { Menu3dUpload } from "@/components/media/menu-3d-upload";
|
||||
import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate";
|
||||
import { CategoryVisual } from "@/components/menu/category-visual";
|
||||
import { CategoryMediaFields } from "@/components/menu/category-media-fields";
|
||||
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
|
||||
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
|
||||
import { apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { MediaPairUpload } from "@/components/media/media-pair-upload";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
|
||||
import { MenuItemMedia } from "@/components/menu/menu-item-media";
|
||||
import { buildCategoryNameMap, inferMenuItemKind } from "@/lib/menu-item-image";
|
||||
|
||||
interface MenuCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
nameEn?: string;
|
||||
nameAr?: string;
|
||||
sortOrder: number;
|
||||
discountPercent: number;
|
||||
icon?: string;
|
||||
iconPresetId?: string;
|
||||
iconStyle?: string;
|
||||
imageUrl?: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
name: string;
|
||||
nameEn?: string;
|
||||
nameAr?: string;
|
||||
price: number;
|
||||
discountPercent: number;
|
||||
imageUrl?: string;
|
||||
videoUrl?: string;
|
||||
model3dUrl?: string;
|
||||
isAvailable: boolean;
|
||||
}
|
||||
|
||||
function discountedPrice(price: number, percent: number) {
|
||||
if (percent <= 0) return price;
|
||||
return Math.round(price * (1 - percent / 100));
|
||||
}
|
||||
|
||||
function mediaField(url: string) {
|
||||
return url.trim() === "" ? "" : url;
|
||||
}
|
||||
|
||||
export function MenuAdminScreen() {
|
||||
const t = useTranslations("menuAdmin");
|
||||
const tCommon = useTranslations("common");
|
||||
const isRtl = useIsRtl();
|
||||
const locale = useLocale();
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const queryClient = useQueryClient();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
|
||||
|
||||
const [catName, setCatName] = useState("");
|
||||
const [catIcon, setCatIcon] = useState("");
|
||||
const [catIconPreset, setCatIconPreset] = useState<CategoryIconSelection>({
|
||||
iconPresetId: null,
|
||||
iconStyle: DEFAULT_CATEGORY_ICON_STYLE,
|
||||
});
|
||||
const [catImageUrl, setCatImageUrl] = useState("");
|
||||
const [editCatName, setEditCatName] = useState("");
|
||||
const [editCatIcon, setEditCatIcon] = useState("");
|
||||
const [editCatIconPreset, setEditCatIconPreset] = useState<CategoryIconSelection>({
|
||||
iconPresetId: null,
|
||||
iconStyle: DEFAULT_CATEGORY_ICON_STYLE,
|
||||
});
|
||||
const [editCatImageUrl, setEditCatImageUrl] = useState("");
|
||||
const [itemName, setItemName] = useState("");
|
||||
const [itemNameEn, setItemNameEn] = useState("");
|
||||
const [itemPrice, setItemPrice] = useState("");
|
||||
const [itemDiscount, setItemDiscount] = useState("0");
|
||||
const [itemCategoryId, setItemCategoryId] = useState("");
|
||||
const [itemImageUrl, setItemImageUrl] = useState("");
|
||||
const [itemVideoUrl, setItemVideoUrl] = useState("");
|
||||
const [itemModel3dUrl, setItemModel3dUrl] = useState("");
|
||||
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editNameEn, setEditNameEn] = useState("");
|
||||
const [editPrice, setEditPrice] = useState("");
|
||||
const [editDiscount, setEditDiscount] = useState("0");
|
||||
const [editImageUrl, setEditImageUrl] = useState("");
|
||||
const [editVideoUrl, setEditVideoUrl] = useState("");
|
||||
const [editModel3dUrl, setEditModel3dUrl] = useState("");
|
||||
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ["menu-categories", cafeId],
|
||||
queryFn: () => apiGet<MenuCategory[]>(`/api/cafes/${cafeId}/menu/categories`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const { data: items = [], isLoading } = useQuery({
|
||||
queryKey: ["menu-items-all", cafeId],
|
||||
queryFn: () => apiGet<MenuItem[]>(`/api/cafes/${cafeId}/menu/items`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const categoryNameById = useMemo(
|
||||
() => buildCategoryNameMap(categories),
|
||||
[categories]
|
||||
);
|
||||
|
||||
const invalidateMenu = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["menu-items-all", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["menu-categories", cafeId] });
|
||||
};
|
||||
|
||||
const addCategory = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost(`/api/cafes/${cafeId}/menu/categories`, {
|
||||
name: catName,
|
||||
sortOrder: categories.length + 1,
|
||||
discountPercent: 0,
|
||||
icon: catIcon.trim() || null,
|
||||
iconPresetId: catIconPreset.iconPresetId,
|
||||
iconStyle: catIconPreset.iconPresetId ? catIconPreset.iconStyle : null,
|
||||
imageUrl: catImageUrl.trim() || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setCatName("");
|
||||
setCatIcon("");
|
||||
setCatIconPreset({ iconPresetId: null, iconStyle: DEFAULT_CATEGORY_ICON_STYLE });
|
||||
setCatImageUrl("");
|
||||
invalidateMenu();
|
||||
},
|
||||
});
|
||||
|
||||
const updateCategory = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiPatch(`/api/cafes/${cafeId}/menu/categories/${id}`, {
|
||||
name: editCatName,
|
||||
icon: mediaField(editCatIcon),
|
||||
iconPresetId: editCatIconPreset.iconPresetId ?? "",
|
||||
iconStyle: editCatIconPreset.iconPresetId ? editCatIconPreset.iconStyle : "",
|
||||
imageUrl: mediaField(editCatImageUrl),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setEditingCategoryId(null);
|
||||
invalidateMenu();
|
||||
},
|
||||
});
|
||||
|
||||
const addItem = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost(`/api/cafes/${cafeId}/menu/items`, {
|
||||
categoryId: itemCategoryId,
|
||||
name: itemName,
|
||||
nameEn: itemNameEn.trim(),
|
||||
price: parseFloat(itemPrice),
|
||||
discountPercent: parseFloat(itemDiscount) || 0,
|
||||
imageUrl: itemImageUrl || null,
|
||||
videoUrl: itemVideoUrl || null,
|
||||
model3dUrl: itemModel3dUrl || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setItemName("");
|
||||
setItemNameEn("");
|
||||
setItemPrice("");
|
||||
setItemDiscount("0");
|
||||
setItemImageUrl("");
|
||||
setItemVideoUrl("");
|
||||
setItemModel3dUrl("");
|
||||
invalidateMenu();
|
||||
},
|
||||
});
|
||||
|
||||
const updateItem = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiPatch(`/api/cafes/${cafeId}/menu/items/${id}`, {
|
||||
name: editName,
|
||||
nameEn: editNameEn.trim(),
|
||||
price: parseFloat(editPrice),
|
||||
discountPercent: parseFloat(editDiscount) || 0,
|
||||
imageUrl: mediaField(editImageUrl),
|
||||
videoUrl: mediaField(editVideoUrl),
|
||||
model3dUrl: mediaField(editModel3dUrl),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setEditingId(null);
|
||||
invalidateMenu();
|
||||
},
|
||||
});
|
||||
|
||||
const toggleItem = useMutation({
|
||||
mutationFn: ({ id, isAvailable }: { id: string; isAvailable: boolean }) =>
|
||||
apiPatch(`/api/cafes/${cafeId}/menu/items/${id}/availability`, { isAvailable }),
|
||||
onSuccess: invalidateMenu,
|
||||
});
|
||||
|
||||
const startCategoryEdit = (cat: MenuCategory) => {
|
||||
setEditingCategoryId(cat.id);
|
||||
setEditCatName(cat.name);
|
||||
setEditCatIcon(cat.icon ?? "");
|
||||
setEditCatIconPreset({
|
||||
iconPresetId: cat.iconPresetId ?? null,
|
||||
iconStyle: (cat.iconStyle as CategoryIconSelection["iconStyle"]) ?? DEFAULT_CATEGORY_ICON_STYLE,
|
||||
});
|
||||
setEditCatImageUrl(cat.imageUrl ?? "");
|
||||
};
|
||||
|
||||
const startEdit = (item: MenuItem) => {
|
||||
setEditingId(item.id);
|
||||
setEditName(item.name);
|
||||
setEditNameEn(item.nameEn ?? "");
|
||||
setEditPrice(String(item.price));
|
||||
setEditDiscount(String(item.discountPercent));
|
||||
setEditImageUrl(item.imageUrl ?? "");
|
||||
setEditVideoUrl(item.videoUrl ?? "");
|
||||
setEditModel3dUrl(item.model3dUrl ?? "");
|
||||
};
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6" dir={isRtl ? "rtl" : "ltr"}>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
<Card className="rounded-xl border border-border/80 bg-card shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("categories")}
|
||||
</p>
|
||||
<CardTitle className="text-base">{t("addCategory")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<LabeledField label={t("name")} htmlFor="cat-name" className="min-w-[12rem] flex-1">
|
||||
<Input id="cat-name" value={catName} onChange={(e) => setCatName(e.target.value)} />
|
||||
</LabeledField>
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={!catName.trim()}
|
||||
onClick={() => addCategory.mutate()}
|
||||
>
|
||||
{t("addCategory")}
|
||||
</Button>
|
||||
</div>
|
||||
<CategoryMediaFields
|
||||
cafeId={cafeId}
|
||||
icon={catIcon}
|
||||
iconPresetId={catIconPreset.iconPresetId}
|
||||
iconStyle={catIconPreset.iconStyle}
|
||||
imageUrl={catImageUrl}
|
||||
onIconChange={setCatIcon}
|
||||
onPresetChange={setCatIconPreset}
|
||||
onImageChange={(url) => setCatImageUrl(url ?? "")}
|
||||
/>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{categories.map((c) => {
|
||||
const isEditingCat = editingCategoryId === c.id;
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg border border-border/80 bg-card px-3 py-2 transition-colors hover:border-[#0F6E56]/40",
|
||||
isEditingCat && "ring-1 ring-[#0F6E56]/30"
|
||||
)}
|
||||
>
|
||||
<CategoryVisual
|
||||
icon={c.icon}
|
||||
iconPresetId={c.iconPresetId}
|
||||
iconStyle={c.iconStyle}
|
||||
imageUrl={c.imageUrl}
|
||||
size="sm"
|
||||
/>
|
||||
{isEditingCat ? (
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Input value={editCatName} onChange={(e) => setEditCatName(e.target.value)} />
|
||||
<CategoryMediaFields
|
||||
cafeId={cafeId}
|
||||
icon={editCatIcon}
|
||||
iconPresetId={editCatIconPreset.iconPresetId}
|
||||
iconStyle={editCatIconPreset.iconStyle}
|
||||
imageUrl={editCatImageUrl}
|
||||
onIconChange={setEditCatIcon}
|
||||
onPresetChange={setEditCatIconPreset}
|
||||
onImageChange={(url) => setEditCatImageUrl(url ?? "")}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={!editCatName.trim()}
|
||||
onClick={() => updateCategory.mutate(c.id)}
|
||||
>
|
||||
{tCommon("save")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditingCategoryId(null)}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{c.name}</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => startCategoryEdit(c)}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section>
|
||||
<p className="mb-3 text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("items")}
|
||||
</p>
|
||||
|
||||
<Card className="mb-4 rounded-xl border border-border/80 bg-card shadow-sm">
|
||||
<CardContent className="space-y-3 pt-6">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<LabeledField label={t("category")} htmlFor="item-category">
|
||||
<select
|
||||
id="item-category"
|
||||
className="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||
value={itemCategoryId}
|
||||
onChange={(e) => setItemCategoryId(e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("name")} htmlFor="item-name">
|
||||
<Input id="item-name" value={itemName} onChange={(e) => setItemName(e.target.value)} />
|
||||
</LabeledField>
|
||||
<LabeledField label={t("nameEn")} htmlFor="item-name-en">
|
||||
<Input
|
||||
id="item-name-en"
|
||||
value={itemNameEn}
|
||||
onChange={(e) => setItemNameEn(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-start"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("price")} htmlFor="item-price">
|
||||
<Input
|
||||
id="item-price"
|
||||
value={itemPrice}
|
||||
onChange={(e) => setItemPrice(e.target.value)}
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("discountPercent")} htmlFor="item-discount">
|
||||
<Input
|
||||
id="item-discount"
|
||||
value={itemDiscount}
|
||||
onChange={(e) => setItemDiscount(e.target.value)}
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46] self-end"
|
||||
disabled={!itemName.trim() || !itemNameEn.trim() || !itemCategoryId || !itemPrice}
|
||||
onClick={() => addItem.mutate()}
|
||||
>
|
||||
{t("addItem")}
|
||||
</Button>
|
||||
</div>
|
||||
<LabeledField label={t("media")}>
|
||||
<MediaPairUpload
|
||||
cafeId={cafeId}
|
||||
kind="menu"
|
||||
imageUrl={itemImageUrl}
|
||||
videoUrl={itemVideoUrl}
|
||||
onImageChange={(url) => setItemImageUrl(url ?? "")}
|
||||
onVideoChange={(url) => setItemVideoUrl(url ?? "")}
|
||||
/>
|
||||
<Menu3dUpload
|
||||
cafeId={cafeId}
|
||||
model3dUrl={itemModel3dUrl || null}
|
||||
onChange={(url) => setItemModel3dUrl(url ?? "")}
|
||||
/>
|
||||
</LabeledField>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((item) => {
|
||||
const kind = inferMenuItemKind(
|
||||
item.categoryId,
|
||||
categoryNameById.get(item.categoryId)
|
||||
);
|
||||
const hasDiscount = item.discountPercent > 0;
|
||||
const salePrice = discountedPrice(item.price, item.discountPercent);
|
||||
const isEditing = editingId === item.id;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border border-border/80 bg-card shadow-sm transition-colors hover:border-[#0F6E56]/40",
|
||||
!item.isAvailable && "opacity-60"
|
||||
)}
|
||||
>
|
||||
{hasDiscount ? (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-2 z-10 rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-[#BA7517]",
|
||||
isRtl ? "start-2" : "end-2"
|
||||
)}
|
||||
>
|
||||
{formatNumber(item.discountPercent)}٪ {t("discountBadge")}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-muted/50">
|
||||
<MenuItemMedia
|
||||
imageUrl={item.imageUrl}
|
||||
kind={kind}
|
||||
size="md"
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
{item.videoUrl ? (
|
||||
<span className="absolute bottom-2 start-2 flex items-center gap-1 rounded-md bg-black/60 px-2 py-0.5 text-[10px] text-white">
|
||||
<Video className="h-3 w-3" />
|
||||
Video
|
||||
</span>
|
||||
) : null}
|
||||
{item.model3dUrl ? (
|
||||
<span className="absolute bottom-2 end-2 flex items-center gap-1 rounded-md bg-[#0F6E56]/90 px-2 py-0.5 text-[10px] text-white">
|
||||
<Box className="h-3 w-3" />
|
||||
3D
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<CardContent className="space-y-2 p-4">
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<LabeledField label={t("name")} htmlFor={`edit-name-${item.id}`}>
|
||||
<Input
|
||||
id={`edit-name-${item.id}`}
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("nameEn")} htmlFor={`edit-name-en-${item.id}`}>
|
||||
<Input
|
||||
id={`edit-name-en-${item.id}`}
|
||||
value={editNameEn}
|
||||
onChange={(e) => setEditNameEn(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-start"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("price")} htmlFor={`edit-price-${item.id}`}>
|
||||
<Input
|
||||
id={`edit-price-${item.id}`}
|
||||
value={editPrice}
|
||||
onChange={(e) => setEditPrice(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("discountPercent")} htmlFor={`edit-discount-${item.id}`}>
|
||||
<Input
|
||||
id={`edit-discount-${item.id}`}
|
||||
value={editDiscount}
|
||||
onChange={(e) => setEditDiscount(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<MediaPairUpload
|
||||
cafeId={cafeId}
|
||||
kind="menu"
|
||||
imageUrl={editImageUrl}
|
||||
videoUrl={editVideoUrl}
|
||||
onImageChange={(url) => setEditImageUrl(url ?? "")}
|
||||
onVideoChange={(url) => setEditVideoUrl(url ?? "")}
|
||||
/>
|
||||
<Menu3dUpload
|
||||
cafeId={cafeId}
|
||||
model3dUrl={editModel3dUrl || null}
|
||||
onChange={(url) => setEditModel3dUrl(url ?? "")}
|
||||
/>
|
||||
<MenuAi3dGenerate
|
||||
cafeId={cafeId}
|
||||
itemId={item.id}
|
||||
imageUrl={editImageUrl || item.imageUrl}
|
||||
onGenerated={(url) => setEditModel3dUrl(url)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => updateItem.mutate(item.id)}>
|
||||
{tCommon("save")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditingId(null)}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<MenuItemLabels item={item} primaryClassName="text-sm" />
|
||||
<div className="flex items-baseline gap-2">
|
||||
{hasDiscount ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground line-through">
|
||||
{formatCurrency(item.price)}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-[#0F6E56]">
|
||||
{formatCurrency(salePrice)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-[#0F6E56]">
|
||||
{formatCurrency(item.price)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button size="sm" variant="outline" onClick={() => startEdit(item)}>
|
||||
<Pencil className="me-1 h-3 w-3" />
|
||||
{t("editItem")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => toggleItem.mutate({ id: item.id, isAvailable: !item.isAvailable })}
|
||||
>
|
||||
{item.isAvailable ? t("available") : t("unavailable")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getMenuEnglishSubtitle,
|
||||
getMenuPrimaryName,
|
||||
type MenuNameFields,
|
||||
} from "@/lib/menu-display";
|
||||
|
||||
type MenuItemLabelsProps = {
|
||||
item: MenuNameFields;
|
||||
className?: string;
|
||||
primaryClassName?: string;
|
||||
secondaryClassName?: string;
|
||||
lines?: 1 | 2;
|
||||
};
|
||||
|
||||
export function MenuItemLabels({
|
||||
item,
|
||||
className,
|
||||
primaryClassName,
|
||||
secondaryClassName,
|
||||
lines = 2,
|
||||
}: MenuItemLabelsProps) {
|
||||
const locale = useLocale();
|
||||
const primary = getMenuPrimaryName(item, locale);
|
||||
const english = getMenuEnglishSubtitle(item, locale);
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0", className)}>
|
||||
<p
|
||||
className={cn(
|
||||
"font-medium leading-snug",
|
||||
lines === 2 ? "line-clamp-2" : "truncate",
|
||||
primaryClassName
|
||||
)}
|
||||
>
|
||||
{primary}
|
||||
</p>
|
||||
{english ? (
|
||||
<p
|
||||
className={cn(
|
||||
"mt-0.5 truncate text-[11px] text-muted-foreground",
|
||||
secondaryClassName
|
||||
)}
|
||||
dir="ltr"
|
||||
>
|
||||
{english}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { MenuItemVisualKind } from "@/lib/menu-item-image";
|
||||
import {
|
||||
getMenuItemImageSrc,
|
||||
menuItemPlaceholderHeroIcon,
|
||||
menuItemPlaceholderIcon,
|
||||
} from "@/lib/menu-item-image";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MenuItemMediaProps = {
|
||||
imageUrl?: string | null;
|
||||
kind: MenuItemVisualKind;
|
||||
size?: "xs" | "sm" | "md" | "hero";
|
||||
className?: string;
|
||||
imgClassName?: string;
|
||||
};
|
||||
|
||||
const iconSize: Record<NonNullable<MenuItemMediaProps["size"]>, string> = {
|
||||
xs: "h-3 w-3",
|
||||
sm: "h-4 w-4",
|
||||
md: "h-10 w-10",
|
||||
hero: "h-8 w-8",
|
||||
};
|
||||
|
||||
const placeholderBg: Record<MenuItemVisualKind, string> = {
|
||||
drink: "bg-[#E8F4F8]",
|
||||
food: "bg-[#F5F0EB]",
|
||||
};
|
||||
|
||||
const placeholderIconColor: Record<MenuItemVisualKind, string> = {
|
||||
drink: "text-[#0F6E56]/45",
|
||||
food: "text-[#8B6914]/40",
|
||||
};
|
||||
|
||||
export function MenuItemMedia({
|
||||
imageUrl,
|
||||
kind,
|
||||
size = "sm",
|
||||
className,
|
||||
imgClassName,
|
||||
}: MenuItemMediaProps) {
|
||||
const src = getMenuItemImageSrc(imageUrl);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const PlaceholderIcon =
|
||||
size === "hero" ? menuItemPlaceholderHeroIcon(kind) : menuItemPlaceholderIcon(kind);
|
||||
|
||||
const showPlaceholder = !src || loadFailed;
|
||||
|
||||
if (showPlaceholder) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center",
|
||||
placeholderBg[kind],
|
||||
className
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<PlaceholderIcon className={cn(iconSize[size], placeholderIconColor[kind])} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className={cn("h-full w-full object-cover", imgClassName, className)}
|
||||
loading="lazy"
|
||||
onError={() => setLoadFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import "@google/model-viewer";
|
||||
import { resolveMediaUrl } from "@/lib/api/client";
|
||||
|
||||
type MenuItemModelViewerProps = {
|
||||
modelUrl: string;
|
||||
posterUrl?: string | null;
|
||||
alt: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function MenuItemModelViewer({
|
||||
modelUrl,
|
||||
posterUrl,
|
||||
alt,
|
||||
className,
|
||||
}: MenuItemModelViewerProps) {
|
||||
const src = resolveMediaUrl(modelUrl);
|
||||
const poster = posterUrl ? resolveMediaUrl(posterUrl) : undefined;
|
||||
if (!src) return null;
|
||||
|
||||
return (
|
||||
// @ts-expect-error model-viewer is a custom element from @google/model-viewer
|
||||
<model-viewer
|
||||
src={src}
|
||||
poster={poster}
|
||||
alt={alt}
|
||||
camera-controls
|
||||
touch-action="pan-y"
|
||||
auto-rotate
|
||||
rotation-per-second="28deg"
|
||||
interaction-prompt="none"
|
||||
shadow-intensity="1"
|
||||
exposure="1"
|
||||
environment-image="neutral"
|
||||
className={className}
|
||||
style={{ width: "100%", height: "100%", minHeight: "min(72vh, 420px)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bell } from "lucide-react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useNotificationsFeed } from "@/lib/hooks/use-notifications-feed";
|
||||
import type { CafeNotification } from "@/lib/api/notifications";
|
||||
import { numberLocaleForUi } from "@/lib/format-datetime";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { NotificationDetailPanel, NotificationRow } from "@/components/notifications/notification-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function NotificationCenter() {
|
||||
const t = useTranslations("notifications");
|
||||
const locale = useLocale();
|
||||
const numberLocale = numberLocaleForUi(locale);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<CafeNotification | null>(null);
|
||||
|
||||
const { items, unreadCount, openNotification, markAllRead } = useNotificationsFeed({
|
||||
enableToasts: true,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const handleSelect = async (n: CafeNotification) => {
|
||||
await openNotification(n);
|
||||
setSelected({ ...n, isRead: true });
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) setSelected(null);
|
||||
};
|
||||
|
||||
const isRtl = locale !== "en";
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="relative shrink-0"
|
||||
aria-label={t("title")}
|
||||
>
|
||||
<Bell className="size-4" />
|
||||
{unreadCount > 0 ? (
|
||||
<span className="absolute -top-1 -end-1 flex size-5 items-center justify-center rounded-full bg-[#0F6E56] text-[10px] font-bold text-white">
|
||||
{unreadCount > 9
|
||||
? locale === "fa"
|
||||
? "۹+"
|
||||
: "9+"
|
||||
: unreadCount.toLocaleString(numberLocale)}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={isRtl ? "start" : "end"}
|
||||
sideOffset={4}
|
||||
collisionPadding={12}
|
||||
className={cn(
|
||||
"w-80 max-h-[min(24rem,70vh)] overflow-y-auto overflow-x-hidden p-0",
|
||||
"rounded-xl border border-border/80 shadow-lg"
|
||||
)}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-border/80 bg-card px-3 py-2">
|
||||
<span className="text-sm font-semibold">{t("title")}</span>
|
||||
{unreadCount > 0 && !selected ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-medium text-[#0F6E56] hover:underline"
|
||||
onClick={() => void markAllRead()}
|
||||
>
|
||||
{t("markAllRead")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<NotificationDetailPanel
|
||||
item={selected}
|
||||
locale={locale}
|
||||
onBack={() => setSelected(null)}
|
||||
/>
|
||||
) : items.length === 0 ? (
|
||||
<p className="px-3 py-8 text-center text-sm text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2 p-2">
|
||||
{items.map((n) => (
|
||||
<li key={n.id}>
|
||||
<NotificationRow
|
||||
item={n}
|
||||
locale={locale}
|
||||
compact
|
||||
onSelect={(item) => void handleSelect(item)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { format } from "date-fns-jalali";
|
||||
import { enUS } from "date-fns-jalali/locale/en-US";
|
||||
import { faIR } from "date-fns-jalali/locale/fa-IR";
|
||||
import { Bell, ChefHat, UtensilsCrossed, type LucideIcon } from "lucide-react";
|
||||
import type { CafeNotification } from "@/lib/api/notifications";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function notificationIcon(type: string): LucideIcon {
|
||||
if (type === "table_call_waiter") return Bell;
|
||||
if (type.startsWith("guest_order")) return ChefHat;
|
||||
if (type.includes("table")) return UtensilsCrossed;
|
||||
return Bell;
|
||||
}
|
||||
|
||||
export function formatNotificationTime(iso: string, locale: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const jalaliLocale = locale === "en" ? enUS : faIR;
|
||||
return format(date, "d MMM yyyy — HH:mm", { locale: jalaliLocale });
|
||||
}
|
||||
|
||||
type NotificationRowProps = {
|
||||
item: CafeNotification;
|
||||
locale: string;
|
||||
onSelect: (n: CafeNotification) => void;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function NotificationRow({ item, locale, onSelect, compact }: NotificationRowProps) {
|
||||
const Icon = notificationIcon(item.type);
|
||||
const timeLabel = formatNotificationTime(item.createdAt, locale);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(item)}
|
||||
className={cn(
|
||||
"flex w-full gap-3 rounded-xl border border-border bg-card text-start shadow-sm transition active:scale-[0.98]",
|
||||
"hover:border-primary/40",
|
||||
!item.isRead && "border-primary/25 bg-[#E1F5EE]/40",
|
||||
compact ? "p-3" : "p-4"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-lg",
|
||||
compact ? "size-9" : "size-10",
|
||||
item.isRead ? "bg-muted text-muted-foreground" : "bg-[#E1F5EE] text-[#0F6E56]"
|
||||
)}
|
||||
>
|
||||
<Icon className={compact ? "size-4" : "size-5"} aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-start justify-between gap-2">
|
||||
<span className={cn("font-medium text-foreground", compact ? "text-xs" : "text-sm")}>
|
||||
{item.title}
|
||||
</span>
|
||||
{!item.isRead ? (
|
||||
<span className="mt-1 size-2 shrink-0 rounded-full bg-[#0F6E56]" aria-hidden />
|
||||
) : null}
|
||||
</span>
|
||||
{item.body && !compact ? (
|
||||
<span className="mt-1 line-clamp-2 block text-sm leading-relaxed text-muted-foreground">
|
||||
{item.body}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="mt-1.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||
{timeLabel ? <span>{timeLabel}</span> : null}
|
||||
{item.tableNumber ? (
|
||||
<span className="rounded-full border border-border/80 px-2 py-0.5">
|
||||
{item.tableNumber}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type NotificationDetailPanelProps = {
|
||||
item: CafeNotification;
|
||||
locale: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export function NotificationDetailPanel({ item, locale, onBack }: NotificationDetailPanelProps) {
|
||||
const t = useTranslations("notifications");
|
||||
const Icon = notificationIcon(item.type);
|
||||
const timeLabel = formatNotificationTime(item.createdAt, locale);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="self-start text-xs font-medium text-[#0F6E56] hover:underline"
|
||||
>
|
||||
{t("backToList")}
|
||||
</button>
|
||||
<div className="flex gap-3 rounded-xl border border-primary/20 bg-[#E1F5EE]/50 p-4">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-[#E1F5EE] text-[#0F6E56]">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
||||
{item.body ? (
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{item.body}</p>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-[11px] text-muted-foreground">
|
||||
{timeLabel ? <span>{timeLabel}</span> : null}
|
||||
{item.tableNumber ? (
|
||||
<span className="rounded-full border border-border/80 bg-card px-2 py-0.5">
|
||||
{item.tableNumber}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Bell } from "lucide-react";
|
||||
import { useNotificationsFeed } from "@/lib/hooks/use-notifications-feed";
|
||||
import { NotificationRow } from "@/components/notifications/notification-ui";
|
||||
import { numberLocaleForUi } from "@/lib/format-datetime";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FilterMode = "all" | "unread";
|
||||
|
||||
export function NotificationsScreen() {
|
||||
const t = useTranslations("notifications");
|
||||
const locale = useLocale();
|
||||
const [filter, setFilter] = useState<FilterMode>("all");
|
||||
const unreadOnly = filter === "unread";
|
||||
|
||||
const { cafeId, items, unreadCount, isLoading, isFetching, openNotification, markAllRead } =
|
||||
useNotificationsFeed({ unreadOnly });
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
const numberLocale = numberLocaleForUi(locale);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-medium text-foreground">{t("pageTitle")}</h1>
|
||||
{unreadCount > 0 ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("unreadCount", { count: unreadCount.toLocaleString(numberLocale) })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{unreadCount > 0 ? (
|
||||
<Button variant="outline" size="sm" onClick={() => void markAllRead()}>
|
||||
{t("markAllRead")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="inline-flex rounded-lg border border-border bg-card p-1"
|
||||
role="tablist"
|
||||
aria-label={t("filterLabel")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={filter === "all"}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-sm transition",
|
||||
filter === "all"
|
||||
? "bg-[#E1F5EE] font-medium text-[#0F6E56]"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setFilter("all")}
|
||||
>
|
||||
{t("filterAll")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={filter === "unread"}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-sm transition",
|
||||
filter === "unread"
|
||||
? "bg-[#E1F5EE] font-medium text-[#0F6E56]"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setFilter("unread")}
|
||||
>
|
||||
{t("filterUnread")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">{t("loading")}</p>
|
||||
) : items.length === 0 ? (
|
||||
<div className="rounded-xl border border-border bg-card px-6 py-16 text-center">
|
||||
<Bell className="mx-auto mb-3 size-10 text-muted-foreground/50" aria-hidden />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filter === "unread" ? t("emptyUnread") : t("empty")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((n) => (
|
||||
<li key={n.id}>
|
||||
<NotificationRow
|
||||
item={n}
|
||||
locale={locale}
|
||||
onSelect={(item) => void openNotification(item, { navigate: true })}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{isFetching && !isLoading ? (
|
||||
<p className="text-center text-[11px] text-muted-foreground">{t("refreshing")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
|
||||
interface ChartPoint {
|
||||
label: string;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
export function OverviewMiniChart({
|
||||
data,
|
||||
numberLocale,
|
||||
}: {
|
||||
data: ChartPoint[];
|
||||
numberLocale: string;
|
||||
}) {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 8, right: 4, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="ovRevFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.28} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: "#475569", fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: "#475569", fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={52}
|
||||
tickFormatter={(v: number) => formatNumber(v, numberLocale)}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "rgba(2,6,23,0.96)",
|
||||
border: "1px solid rgba(51,65,85,0.7)",
|
||||
borderRadius: "10px",
|
||||
color: "#e2e8f0",
|
||||
fontSize: 12,
|
||||
}}
|
||||
itemStyle={{ color: "#10b981" }}
|
||||
formatter={(v: number) => [formatNumber(v, numberLocale), ""]}
|
||||
labelStyle={{ color: "#94a3b8", marginBottom: 2 }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
fill="url(#ovRevFill)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: "#10b981", stroke: "#020617", strokeWidth: 2 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
"use client";
|
||||
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
ShoppingBag,
|
||||
CreditCard,
|
||||
Utensils,
|
||||
BarChart3,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
ChefHat,
|
||||
TableProperties,
|
||||
} from "lucide-react";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { apiGet } from "@/lib/api/client";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useLiveClock } from "@/lib/hooks/use-live-clock";
|
||||
import {
|
||||
addDaysIso,
|
||||
isoTodayTehran,
|
||||
percentChange,
|
||||
revenueChartPoints,
|
||||
sumSnapshots,
|
||||
topProductsFromRange,
|
||||
type DailyReportSnapshot,
|
||||
} from "@/lib/reports/analytics";
|
||||
import { fetchCafeTableBoard } from "@/lib/api/branch-tables";
|
||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
const LazyMiniChart = lazy(() =>
|
||||
import("@/components/overview/overview-mini-chart").then((m) => ({
|
||||
default: m.OverviewMiniChart,
|
||||
}))
|
||||
);
|
||||
|
||||
function KpiValue({
|
||||
value,
|
||||
loading,
|
||||
currency = false,
|
||||
locale = "fa-IR",
|
||||
}: {
|
||||
value: number | null;
|
||||
loading: boolean;
|
||||
currency?: boolean;
|
||||
locale?: string;
|
||||
}) {
|
||||
if (loading) return <Skeleton className="h-7 w-28 mt-1" />;
|
||||
if (value === null) return <span className="text-2xl font-bold text-muted-foreground">—</span>;
|
||||
return (
|
||||
<span className="text-2xl font-bold text-foreground tabular-nums">
|
||||
{currency ? formatCurrency(value, locale) : formatNumber(value, locale)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendBadge({ delta, label }: { delta: number | null; label: string }) {
|
||||
if (delta === null) return null;
|
||||
const up = delta > 0;
|
||||
const flat = delta === 0;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||
up
|
||||
? "bg-green-100 text-green-700"
|
||||
: flat
|
||||
? "bg-muted text-muted-foreground"
|
||||
: "bg-red-100 text-red-600"
|
||||
)}
|
||||
>
|
||||
{up ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : flat ? (
|
||||
<Minus className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
{Math.abs(delta).toFixed(1)}%
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewScreen() {
|
||||
const t = useTranslations("overview");
|
||||
const tNav = useTranslations("nav");
|
||||
const locale = useLocale();
|
||||
const rtl = locale !== "en";
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
const ArrowIcon = rtl ? ArrowLeft : ArrowRight;
|
||||
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const branchId = useAuthStore((s) => s.user?.branchId ?? null);
|
||||
const role = useAuthStore((s) => s.user?.role);
|
||||
|
||||
const clock = useLiveClock(10_000);
|
||||
|
||||
const today = useMemo(() => isoTodayTehran(), []);
|
||||
const sevenDaysAgo = useMemo(() => addDaysIso(today, -6), [today]);
|
||||
const yesterday = useMemo(() => addDaysIso(today, -1), [today]);
|
||||
|
||||
const timeStr = clock.toLocaleTimeString(
|
||||
locale === "fa" ? "fa-IR" : locale === "ar" ? "ar-SA" : "en-US",
|
||||
{ hour: "2-digit", minute: "2-digit", timeZone: "Asia/Tehran" }
|
||||
);
|
||||
const dateStr = clock.toLocaleDateString(
|
||||
locale === "fa" ? "fa-IR-u-ca-persian" : locale === "ar" ? "ar-SA" : "en-GB",
|
||||
{ weekday: "long", year: "numeric", month: "long", day: "numeric", timeZone: "Asia/Tehran" }
|
||||
);
|
||||
|
||||
const { data: weekSnapshots = [], isLoading } = useQuery({
|
||||
queryKey: ["overview-week", cafeId, today],
|
||||
queryFn: () =>
|
||||
apiGet<DailyReportSnapshot[]>(
|
||||
`/api/cafes/${cafeId}/reports/daily/range?from=${sevenDaysAgo}&to=${today}`
|
||||
),
|
||||
enabled: !!cafeId,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: prevSnapshots = [] } = useQuery({
|
||||
queryKey: ["overview-yesterday", cafeId, yesterday],
|
||||
queryFn: () =>
|
||||
apiGet<DailyReportSnapshot[]>(
|
||||
`/api/cafes/${cafeId}/reports/daily/range?from=${yesterday}&to=${yesterday}`
|
||||
),
|
||||
enabled: !!cafeId,
|
||||
staleTime: 300_000,
|
||||
});
|
||||
|
||||
const { data: tables = [] } = useQuery({
|
||||
queryKey: ["overview-tables", cafeId, branchId],
|
||||
queryFn: () => fetchCafeTableBoard(cafeId!, branchId),
|
||||
enabled: !!cafeId,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const todaySnapshot = useMemo(
|
||||
() => weekSnapshots.find((s) => s.date === today) ?? null,
|
||||
[weekSnapshots, today]
|
||||
);
|
||||
const todayTotals = useMemo(
|
||||
() => (todaySnapshot ? sumSnapshots([todaySnapshot]) : null),
|
||||
[todaySnapshot]
|
||||
);
|
||||
const yesterdayTotals = useMemo(
|
||||
() => (prevSnapshots.length > 0 ? sumSnapshots(prevSnapshots) : null),
|
||||
[prevSnapshots]
|
||||
);
|
||||
|
||||
const revenueDelta = useMemo(
|
||||
() => todayTotals && yesterdayTotals
|
||||
? percentChange(todayTotals.totalRevenue, yesterdayTotals.totalRevenue)
|
||||
: null,
|
||||
[todayTotals, yesterdayTotals]
|
||||
);
|
||||
const ordersDelta = useMemo(
|
||||
() => todayTotals && yesterdayTotals
|
||||
? percentChange(todayTotals.totalOrders, yesterdayTotals.totalOrders)
|
||||
: null,
|
||||
[todayTotals, yesterdayTotals]
|
||||
);
|
||||
const netIncomeDelta = useMemo(
|
||||
() => todayTotals && yesterdayTotals
|
||||
? percentChange(todayTotals.netIncome, yesterdayTotals.netIncome)
|
||||
: null,
|
||||
[todayTotals, yesterdayTotals]
|
||||
);
|
||||
|
||||
const chartPoints = useMemo(
|
||||
() => revenueChartPoints(weekSnapshots, locale, rtl),
|
||||
[weekSnapshots, locale, rtl]
|
||||
);
|
||||
|
||||
const topProducts = useMemo(() => topProductsFromRange(weekSnapshots, 5), [weekSnapshots]);
|
||||
|
||||
const tableStats = useMemo(() => {
|
||||
const active = tables.filter((tb) => tb.isActive);
|
||||
return {
|
||||
free: active.filter((tb) => tb.status === "Free").length,
|
||||
busy: active.filter((tb) => tb.status === "Busy").length,
|
||||
cleaning: active.filter((tb) => tb.status === "Cleaning").length,
|
||||
total: active.length,
|
||||
};
|
||||
}, [tables]);
|
||||
|
||||
const quickLinks = [
|
||||
{ key: "pos", href: "/pos", icon: CreditCard, labelKey: "pos" },
|
||||
{ key: "tables", href: "/tables", icon: TableProperties, labelKey: "tables" },
|
||||
{ key: "reports", href: "/reports", icon: BarChart3, labelKey: "reports" },
|
||||
{ key: "kds", href: "/kds", icon: ChefHat, labelKey: "kds" },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 auto-rows-auto">
|
||||
|
||||
{/* Welcome + Clock */}
|
||||
<Card className="col-span-2 p-5 flex flex-col bg-primary/5 border-primary/20">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-primary/70 mb-1">
|
||||
{t("greeting")}
|
||||
{role && <span className="ms-1.5 text-muted-foreground normal-case tracking-normal font-normal">· {role}</span>}
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-foreground tabular-nums leading-none">{timeStr}</p>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground truncate">{dateStr}</p>
|
||||
<div className="mt-auto pt-4">
|
||||
<span className="text-[11px] text-muted-foreground/50">Meezi</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Revenue KPI */}
|
||||
<Card className="col-span-1 p-5 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("todayRevenue")}
|
||||
</span>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10">
|
||||
<CreditCard className="h-3.5 w-3.5 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<KpiValue value={todayTotals?.totalRevenue ?? null} loading={isLoading} currency locale={numberLocale} />
|
||||
<div className="mt-2">
|
||||
<TrendBadge delta={revenueDelta} label={t("vsYesterday")} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Orders KPI */}
|
||||
<Card className="col-span-1 p-5 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("todayOrders")}
|
||||
</span>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-100">
|
||||
<ShoppingBag className="h-3.5 w-3.5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<KpiValue value={todayTotals?.totalOrders ?? null} loading={isLoading} locale={numberLocale} />
|
||||
<div className="mt-2">
|
||||
<TrendBadge delta={ordersDelta} label={t("vsYesterday")} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 7-day Chart (3×2) */}
|
||||
<Card className="col-span-2 lg:col-span-3 lg:row-span-2 p-5 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">{t("revenueChart")}</h3>
|
||||
<Link
|
||||
href="/reports"
|
||||
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{tNav("reports")}
|
||||
<ArrowIcon className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1 min-h-[180px] lg:min-h-[220px]">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-full w-full" />
|
||||
) : chartPoints.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">{t("noData")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Suspense fallback={<Skeleton className="h-full w-full" />}>
|
||||
<LazyMiniChart data={chartPoints} numberLocale={numberLocale} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Table Status (1×2) */}
|
||||
<Card className="col-span-2 lg:col-span-1 lg:row-span-2 p-5 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">{t("tableStatus")}</h3>
|
||||
<Link href="/tables" className="text-muted-foreground hover:text-primary transition-colors">
|
||||
<TableProperties className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-4">
|
||||
{/* Occupancy ring */}
|
||||
<div className="relative flex h-24 w-24 items-center justify-center">
|
||||
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 36 36">
|
||||
<circle cx="18" cy="18" r="15.5" fill="none" stroke="hsl(var(--muted))" strokeWidth="3" />
|
||||
{tableStats.total > 0 && (
|
||||
<circle
|
||||
cx="18" cy="18" r="15.5" fill="none"
|
||||
stroke="hsl(var(--primary))" strokeWidth="3" strokeLinecap="round"
|
||||
strokeDasharray={`${(tableStats.busy / tableStats.total) * 97.4} 97.4`}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-foreground tabular-nums leading-none">{tableStats.busy}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">/ {tableStats.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2.5">
|
||||
{[
|
||||
{ label: t("tableFree"), count: tableStats.free, cls: "bg-green-500", txt: "text-green-600" },
|
||||
{ label: t("tableBusy"), count: tableStats.busy, cls: "bg-amber-500", txt: "text-amber-600" },
|
||||
{ label: t("tableCleaning"), count: tableStats.cleaning, cls: "bg-blue-500", txt: "text-blue-600" },
|
||||
].map(({ label, count, cls, txt }) => (
|
||||
<div key={label} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("h-2 w-2 rounded-full", cls)} />
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<span className={cn("text-sm font-semibold tabular-nums", txt)}>
|
||||
{formatNumber(count, numberLocale)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Top Products (2 cols) */}
|
||||
<Card className="col-span-2 p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">{t("topProducts")}</h3>
|
||||
<Utensils className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2.5">{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-5 w-full" />)}</div>
|
||||
) : topProducts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("noData")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topProducts.map((p, idx) => {
|
||||
const maxRevenue = topProducts[0]?.revenue ?? 1;
|
||||
const pct = Math.round((p.revenue / maxRevenue) * 100);
|
||||
return (
|
||||
<div key={p.productId} className="flex items-center gap-3">
|
||||
<span className="w-4 shrink-0 text-[11px] font-medium text-muted-foreground tabular-nums text-end">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-foreground truncate">{p.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums shrink-0 ms-2">
|
||||
{formatNumber(p.quantity, numberLocale)} {t("unit")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary/60 transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
||||
{formatCurrency(p.revenue, numberLocale)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Net Income KPI */}
|
||||
<Card className="col-span-1 p-5 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("netIncome")}
|
||||
</span>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-purple-100">
|
||||
<BarChart3 className="h-3.5 w-3.5 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
<KpiValue value={todayTotals?.netIncome ?? null} loading={isLoading} currency locale={numberLocale} />
|
||||
<div className="mt-2">
|
||||
<TrendBadge delta={netIncomeDelta} label={t("vsYesterday")} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick Links */}
|
||||
<Card className="col-span-1 p-5 flex flex-col">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
{t("quickLinks")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 flex-1">
|
||||
{quickLinks.map(({ key, href, icon: Icon, labelKey }) => (
|
||||
<Link
|
||||
key={key}
|
||||
href={href}
|
||||
className="flex flex-col items-center justify-center gap-1.5 rounded-xl bg-muted/50 border border-border p-2.5 text-center transition-colors hover:bg-accent hover:text-accent-foreground group"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
<span className="text-[10px] text-muted-foreground group-hover:text-primary transition-colors leading-tight">
|
||||
{tNav(labelKey)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function PlaceholderPage({ titleKey }: { titleKey: string }) {
|
||||
const tNav = useTranslations("nav");
|
||||
const tCommon = useTranslations("common");
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
|
||||
<p className="text-muted-foreground">
|
||||
{tNav(titleKey as "crm")} — {tCommon("comingSoon")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronDown, Search, UserPlus, X } from "lucide-react";
|
||||
import { apiGet, apiPost, ApiClientError } from "@/lib/api/client";
|
||||
import type { Customer } from "@/lib/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CustomerMode = "existing" | "new";
|
||||
|
||||
type PosCustomerPickerProps = {
|
||||
cafeId: string;
|
||||
guestName: string;
|
||||
guestPhone: string;
|
||||
customerId: string | null;
|
||||
onGuestNameChange: (value: string) => void;
|
||||
onGuestPhoneChange: (value: string) => void;
|
||||
onCustomerChange: (customer: Customer | null) => void;
|
||||
onClearCustomer: () => void;
|
||||
/** Collapsed header in POS sidebar to leave room for cart lines */
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function PosCustomerPicker({
|
||||
cafeId,
|
||||
guestName,
|
||||
guestPhone,
|
||||
customerId,
|
||||
onGuestNameChange,
|
||||
onGuestPhoneChange,
|
||||
onCustomerChange,
|
||||
onClearCustomer,
|
||||
compact = false,
|
||||
}: PosCustomerPickerProps) {
|
||||
const t = useTranslations("pos");
|
||||
const tCrm = useTranslations("crm");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
const [expanded, setExpanded] = useState(!compact);
|
||||
const [mode, setMode] = useState<CustomerMode>(customerId ? "existing" : "new");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [search]);
|
||||
|
||||
const pickCustomer = (c: Customer) => {
|
||||
onCustomerChange(c);
|
||||
setSearch("");
|
||||
setMessage(null);
|
||||
setHighlightIndex(-1);
|
||||
};
|
||||
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (results.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((i) => (i < results.length - 1 ? i + 1 : 0));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((i) => (i > 0 ? i - 1 : results.length - 1));
|
||||
} else if (e.key === "Enter" && highlightIndex >= 0) {
|
||||
e.preventDefault();
|
||||
pickCustomer(results[highlightIndex]!);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (customerId) setMode("existing");
|
||||
}, [customerId]);
|
||||
|
||||
const { data: results = [], isFetching } = useQuery({
|
||||
queryKey: ["customers", cafeId, debouncedSearch],
|
||||
queryFn: () =>
|
||||
apiGet<Customer[]>(
|
||||
`/api/cafes/${cafeId}/customers?q=${encodeURIComponent(debouncedSearch)}`
|
||||
),
|
||||
enabled: !!cafeId && mode === "existing" && debouncedSearch.length >= 2,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightIndex(-1);
|
||||
}, [debouncedSearch, results.length]);
|
||||
|
||||
const createCustomer = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<Customer>(`/api/cafes/${cafeId}/customers`, {
|
||||
name: guestName.trim(),
|
||||
phone: guestPhone.trim(),
|
||||
group: "New",
|
||||
}),
|
||||
onSuccess: (customer) => {
|
||||
onCustomerChange(customer);
|
||||
setMode("existing");
|
||||
setMessage(t("customerSaved"));
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
if (err instanceof ApiClientError && err.code === "DUPLICATE_PHONE") {
|
||||
setMessage(t("customerPhoneExists"));
|
||||
return;
|
||||
}
|
||||
setMessage(t("customerSaveError"));
|
||||
},
|
||||
});
|
||||
|
||||
const switchMode = (next: CustomerMode) => {
|
||||
setMode(next);
|
||||
setMessage(null);
|
||||
if (next === "new") {
|
||||
onClearCustomer();
|
||||
}
|
||||
};
|
||||
|
||||
const canSaveNew =
|
||||
guestName.trim().length > 0 &&
|
||||
/^09\d{9}$/.test(guestPhone.trim()) &&
|
||||
!customerId;
|
||||
|
||||
const summaryLabel =
|
||||
guestName.trim() || guestPhone.trim()
|
||||
? `${guestName.trim() || "—"}${guestPhone.trim() ? ` · ${guestPhone.trim()}` : ""}`
|
||||
: t("customerSection");
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", compact && !expanded && "space-y-1")}>
|
||||
{compact ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-2 rounded-md border border-border/80 bg-muted/30 px-2 py-1.5 text-start"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<span className="min-w-0 truncate text-xs font-medium">{summaryLabel}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
expanded && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("customerSection")}</p>
|
||||
)}
|
||||
|
||||
{compact && !expanded ? null : (
|
||||
<>
|
||||
<div className="flex gap-1 rounded-lg border border-border p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition",
|
||||
mode === "existing"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted"
|
||||
)}
|
||||
onClick={() => switchMode("existing")}
|
||||
>
|
||||
{t("existingCustomer")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition",
|
||||
mode === "new"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted"
|
||||
)}
|
||||
onClick={() => switchMode("new")}
|
||||
>
|
||||
{t("newCustomer")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{customerId ? (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-[#0F6E56]/30 bg-[#E1F5EE] px-2 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-[#0F6E56]">{guestName}</p>
|
||||
<p className="truncate text-xs text-muted-foreground" dir="ltr">
|
||||
{guestPhone}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => {
|
||||
onClearCustomer();
|
||||
onGuestNameChange("");
|
||||
onGuestPhoneChange("");
|
||||
setMode("new");
|
||||
}}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mode === "existing" && !customerId ? (
|
||||
<div className="space-y-2">
|
||||
<LabeledField label={tCommon("search")} htmlFor="pos-customer-search">
|
||||
<div className="relative">
|
||||
<Search className="absolute start-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="pos-customer-search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder={t("customerSearchPlaceholder")}
|
||||
className={cn("ps-8", compact && "h-8 text-sm")}
|
||||
role="combobox"
|
||||
aria-expanded={results.length > 0 && debouncedSearch.length >= 2}
|
||||
aria-activedescendant={
|
||||
highlightIndex >= 0 ? `pos-customer-opt-${highlightIndex}` : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</LabeledField>
|
||||
{debouncedSearch.length < 2 ? (
|
||||
<p className="text-[11px] text-muted-foreground">{t("customerSearchHint")}</p>
|
||||
) : isFetching ? (
|
||||
<p className="text-[11px] text-muted-foreground">{tCommon("loading")}</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">{t("customerNotFound")}</p>
|
||||
) : (
|
||||
<ul
|
||||
className={cn(
|
||||
"space-y-1 overflow-y-auto overscroll-contain",
|
||||
compact ? "max-h-24" : "max-h-32"
|
||||
)}
|
||||
>
|
||||
{results.map((c, idx) => (
|
||||
<li key={c.id} id={`pos-customer-opt-${idx}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-md border px-2 py-1.5 text-start text-sm transition",
|
||||
idx === highlightIndex
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border/80 hover:border-primary hover:bg-muted/40"
|
||||
)}
|
||||
onClick={() => pickCustomer(c)}
|
||||
>
|
||||
<span className="min-w-0 truncate font-medium">{c.name}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground" dir="ltr">
|
||||
{c.phone}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mode === "new" && !customerId ? (
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (canSaveNew && !createCustomer.isPending) createCustomer.mutate();
|
||||
}}
|
||||
>
|
||||
<LabeledField label={t("guestName")} htmlFor="pos-guest">
|
||||
<Input
|
||||
id="pos-guest"
|
||||
value={guestName}
|
||||
onChange={(e) => onGuestNameChange(e.target.value)}
|
||||
placeholder={t("guestNamePlaceholder")}
|
||||
className={compact ? "h-8 text-sm" : undefined}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("guestPhone")} htmlFor="pos-phone">
|
||||
<Input
|
||||
id="pos-phone"
|
||||
value={guestPhone}
|
||||
onChange={(e) => onGuestPhoneChange(e.target.value)}
|
||||
placeholder={t("guestPhonePlaceholder")}
|
||||
dir="ltr"
|
||||
className={cn("text-end", compact && "h-8 text-sm")}
|
||||
/>
|
||||
</LabeledField>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={!canSaveNew || createCustomer.isPending}
|
||||
>
|
||||
<UserPlus className="me-1.5 h-3.5 w-3.5" />
|
||||
{createCustomer.isPending ? "..." : tCrm("addCustomer")}
|
||||
</Button>
|
||||
{!compact ? (
|
||||
<p className="text-[10px] text-muted-foreground">{t("newCustomerHint")}</p>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<p className="text-center text-xs text-primary">{message}</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import * as signalR from "@microsoft/signalr";
|
||||
import { apiGet, apiPatch, apiPost, ApiClientError } from "@/lib/api/client";
|
||||
import { printErrorMessage, printReceipt } from "@/lib/api/print";
|
||||
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
|
||||
import { PosSlipModal } from "@/components/pos/pos-slip-modal";
|
||||
import type { Customer, Order, Table, TableBoardItem } from "@/lib/api/types";
|
||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||
import { formatPosOrderLabel } from "@/lib/pos-order-label";
|
||||
import { formatOrderNumber } from "@/lib/order-number";
|
||||
import { PosTableBoard } from "@/components/pos/pos-table-board";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCafeSettings } from "@/lib/hooks/use-cafe-settings";
|
||||
import { confirmPayLabel } from "@/lib/pos-confirm-pay-label";
|
||||
import { useConfirm } from "@/components/providers/confirm-provider";
|
||||
|
||||
type PaymentRow = {
|
||||
method: "Cash" | "Card" | "Credit";
|
||||
amount: string;
|
||||
};
|
||||
|
||||
type PosPayPanelProps = {
|
||||
cafeId: string;
|
||||
numberLocale: string;
|
||||
branchId?: string | null;
|
||||
};
|
||||
|
||||
export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPanelProps) {
|
||||
const t = useTranslations("pos");
|
||||
const tPrint = useTranslations("print");
|
||||
const tDashboard = useTranslations("dashboard");
|
||||
const queryClient = useQueryClient();
|
||||
const confirmDialog = useConfirm();
|
||||
const { data: cafeSettings } = useCafeSettings(cafeId);
|
||||
const cafeName = cafeSettings?.name ?? tDashboard("cafeName");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectedTableId, setSelectedTableId] = useState<string | null>(null);
|
||||
const [filterTableId, setFilterTableId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [payMessage, setPayMessage] = useState<string | null>(null);
|
||||
const [receiptOrder, setReceiptOrder] = useState<Order | null>(null);
|
||||
const [lastPaidOrderId, setLastPaidOrderId] = useState<string | null>(null);
|
||||
const [paymentRows, setPaymentRows] = useState<PaymentRow[]>([
|
||||
{ method: "Cash", amount: "" },
|
||||
]);
|
||||
const [loyaltyRedeem, setLoyaltyRedeem] = useState(0);
|
||||
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5080";
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [search]);
|
||||
|
||||
const { data: openOrders = [], isLoading } = useQuery({
|
||||
queryKey: ["orders-open", cafeId, debouncedSearch],
|
||||
queryFn: () => {
|
||||
const qs = debouncedSearch
|
||||
? `?search=${encodeURIComponent(debouncedSearch)}`
|
||||
: "";
|
||||
return apiGet<Order[]>(`/api/cafes/${cafeId}/orders/open${qs}`);
|
||||
},
|
||||
enabled: !!cafeId,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const { data: tables = [] } = useQuery({
|
||||
queryKey: ["tables", cafeId],
|
||||
queryFn: () => apiGet<Table[]>(`/api/cafes/${cafeId}/tables`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const displayedOrders = useMemo(() => {
|
||||
if (!filterTableId) return openOrders;
|
||||
return openOrders.filter((o) => o.tableId === filterTableId);
|
||||
}, [openOrders, filterTableId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cafeId) return;
|
||||
const token =
|
||||
typeof window !== "undefined" ? localStorage.getItem("meezi_access_token") : null;
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(`${apiBase}/hubs/kds`, { accessTokenFactory: () => token ?? "" })
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
connection
|
||||
.start()
|
||||
.then(() => connection.invoke("JoinCafe", cafeId))
|
||||
.catch(() => undefined);
|
||||
const refresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
|
||||
};
|
||||
connection.on("TableStatusChanged", refresh);
|
||||
connection.on("OrderStatusChanged", refresh);
|
||||
return () => {
|
||||
void connection.stop();
|
||||
};
|
||||
}, [cafeId, apiBase, queryClient]);
|
||||
|
||||
const selectOrder = (order: Order, tableId?: string | null) => {
|
||||
setSelectedId(order.id);
|
||||
setSelectedTableId(tableId ?? order.tableId ?? null);
|
||||
setPayMessage(null);
|
||||
};
|
||||
|
||||
const handleTableSelect = (table: TableBoardItem, activeOrder: Order | null) => {
|
||||
setFilterTableId(table.id);
|
||||
setSelectedTableId(table.id);
|
||||
if (activeOrder) {
|
||||
selectOrder(activeOrder, table.id);
|
||||
return;
|
||||
}
|
||||
setSelectedId(null);
|
||||
setPayMessage(t("noOrderOnTable"));
|
||||
};
|
||||
|
||||
const selected = openOrders.find((o) => o.id === selectedId) ?? null;
|
||||
|
||||
const remaining = useMemo(() => {
|
||||
if (!selected) return 0;
|
||||
return Math.max(0, selected.total - (selected.paidAmount ?? 0));
|
||||
}, [selected]);
|
||||
|
||||
const { data: payCustomer } = useQuery({
|
||||
queryKey: ["customer", cafeId, selected?.customerId],
|
||||
queryFn: () =>
|
||||
apiGet<Customer>(`/api/cafes/${cafeId}/customers/${selected!.customerId}`),
|
||||
enabled: !!cafeId && !!selected?.customerId,
|
||||
});
|
||||
|
||||
const maxLoyaltyRedeem = useMemo(() => {
|
||||
if (!payCustomer || !selected) return 0;
|
||||
const byDue = Math.floor(remaining / 100);
|
||||
return Math.min(payCustomer.loyaltyPoints, byDue);
|
||||
}, [payCustomer, selected, remaining]);
|
||||
|
||||
const loyaltyDiscount = loyaltyRedeem * 100;
|
||||
const effectiveRemaining = Math.max(0, remaining - loyaltyDiscount);
|
||||
|
||||
useEffect(() => {
|
||||
setLoyaltyRedeem(0);
|
||||
}, [selected?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
setPaymentRows([{ method: "Cash", amount: String(effectiveRemaining) }]);
|
||||
}, [selected?.id, selected?.total, selected?.paidAmount, effectiveRemaining]);
|
||||
|
||||
const payOrder = useMutation({
|
||||
mutationFn: async (order: Order) => {
|
||||
const payments = paymentRows
|
||||
.map((row) => ({
|
||||
method: row.method,
|
||||
amount: parseFloat(row.amount.replace(/,/g, "")) || 0,
|
||||
}))
|
||||
.filter((p) => p.amount > 0);
|
||||
|
||||
if (payments.length === 0) throw new Error("no payments");
|
||||
|
||||
const cardTotal = payments
|
||||
.filter((p) => p.method === "Card")
|
||||
.reduce((s, p) => s + p.amount, 0);
|
||||
|
||||
const payBranchId = order.branchId ?? branchId;
|
||||
if (cardTotal > 0 && payBranchId) {
|
||||
await requestPosPayment(cafeId, payBranchId, order.id, cardTotal);
|
||||
}
|
||||
|
||||
return apiPost(`/api/cafes/${cafeId}/orders/${order.id}/payments`, {
|
||||
payments,
|
||||
loyaltyPointsToRedeem: loyaltyRedeem > 0 ? loyaltyRedeem : undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: async (_data, order) => {
|
||||
setPayMessage(t("paySuccess"));
|
||||
setLastPaidOrderId(order.id);
|
||||
try {
|
||||
const paid = await apiGet<Order>(`/api/cafes/${cafeId}/orders/${order!.id}`);
|
||||
setReceiptOrder(paid);
|
||||
} catch {
|
||||
setReceiptOrder(order ?? null);
|
||||
}
|
||||
setSelectedId(null);
|
||||
setSelectedTableId(null);
|
||||
setFilterTableId(null);
|
||||
setSearch("");
|
||||
setPaymentRows([{ method: "Cash", amount: "" }]);
|
||||
setLoyaltyRedeem(0);
|
||||
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["customer", cafeId] });
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ApiClientError) {
|
||||
if (err.code.startsWith("POS_DEVICE")) {
|
||||
setPayMessage(posDeviceErrorMessage(err, t));
|
||||
return;
|
||||
}
|
||||
if (err.code === "NO_OPEN_SHIFT") {
|
||||
setPayMessage(t("payNeedsOpenShift"));
|
||||
return;
|
||||
}
|
||||
if (err.code === "LOYALTY_NO_CUSTOMER") {
|
||||
setPayMessage(t("loyaltyNoCustomer"));
|
||||
return;
|
||||
}
|
||||
if (err.code === "LOYALTY_INSUFFICIENT_POINTS") {
|
||||
setPayMessage(t("loyaltyInsufficient"));
|
||||
return;
|
||||
}
|
||||
setPayMessage(err.message || t("payError"));
|
||||
return;
|
||||
}
|
||||
setPayMessage(t("payError"));
|
||||
},
|
||||
});
|
||||
|
||||
const cancelOrder = useMutation({
|
||||
mutationFn: (orderId: string) =>
|
||||
apiPatch(`/api/cafes/${cafeId}/orders/${orderId}/status`, {
|
||||
status: "Cancelled",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setPayMessage(t("cancelOrderSuccess"));
|
||||
setSelectedId(null);
|
||||
setSelectedTableId(null);
|
||||
setFilterTableId(null);
|
||||
setPaymentRows([{ method: "Cash", amount: "" }]);
|
||||
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
|
||||
},
|
||||
onError: (err) => {
|
||||
setPayMessage(
|
||||
err instanceof ApiClientError ? err.message : t("cancelOrderError")
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const paymentSum = paymentRows.reduce(
|
||||
(s, row) => s + (parseFloat(row.amount.replace(/,/g, "")) || 0),
|
||||
0
|
||||
);
|
||||
const canPay =
|
||||
selected && paymentSum > 0 && paymentSum <= effectiveRemaining + 0.01;
|
||||
|
||||
const payButtonLabel = confirmPayLabel(paymentRows, t);
|
||||
|
||||
const thermalPrint = useMutation({
|
||||
mutationFn: (orderId: string) => printReceipt(cafeId, orderId),
|
||||
onSuccess: () => setPayMessage(tPrint("success")),
|
||||
onError: (err) => setPayMessage(printErrorMessage(err, tPrint)),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full gap-4 overflow-hidden">
|
||||
<Card className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<CardHeader className="shrink-0 space-y-3 pb-2">
|
||||
<CardTitle className="text-base">{t("openOrders")}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{t("payOpenOrdersHint")}</p>
|
||||
|
||||
<PosTableBoard
|
||||
cafeId={cafeId}
|
||||
numberLocale={numberLocale}
|
||||
branchId={branchId}
|
||||
mode="pay"
|
||||
selectedTableId={selectedTableId}
|
||||
selectedOrderId={selectedId}
|
||||
onSelectTable={handleTableSelect}
|
||||
/>
|
||||
|
||||
<LabeledField label={t("selectTable")} htmlFor="pay-table-filter">
|
||||
<select
|
||||
id="pay-table-filter"
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={filterTableId ?? ""}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value || null;
|
||||
setFilterTableId(id);
|
||||
setSelectedTableId(id);
|
||||
if (!id) {
|
||||
setSelectedId(null);
|
||||
setPayMessage(null);
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const order = await apiGet<Order>(
|
||||
`/api/cafes/${cafeId}/tables/${id}/active-order`
|
||||
);
|
||||
selectOrder(order, id);
|
||||
} catch {
|
||||
const match = openOrders.find((o) => o.tableId === id);
|
||||
if (match) selectOrder(match, id);
|
||||
else {
|
||||
setSelectedId(null);
|
||||
setPayMessage(t("noOrderOnTable"));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<option value="">{t("allTables")}</option>
|
||||
{tables?.map((tbl) => (
|
||||
<option key={tbl.id} value={tbl.id}>
|
||||
{t("table")} {tbl.number}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t("payPickByName")}
|
||||
</p>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("searchOpenOrder")}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pt-0">
|
||||
{payMessage && !selected ? (
|
||||
<p className="mb-2 text-center text-sm text-amber-700">{payMessage}</p>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">...</p>
|
||||
) : displayedOrders.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filterTableId ? t("noOpenOrdersOnTable") : t("noOpenOrders")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{displayedOrders.map((order) => {
|
||||
const label = formatPosOrderLabel(order, t("table"));
|
||||
const isSelected = selectedId === order.id;
|
||||
const guestLine =
|
||||
order.guestName?.trim() ||
|
||||
order.customerName?.trim() ||
|
||||
order.guestPhone ||
|
||||
order.customerPhone;
|
||||
return (
|
||||
<li key={order.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectOrder(order)}
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-1 rounded-lg border border-border bg-card px-4 py-3 text-start shadow-sm transition hover:border-primary",
|
||||
isSelected && "border-primary ring-1 ring-primary/30"
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{label}</p>
|
||||
{guestLine ? (
|
||||
<p className="truncate text-xs text-[#0C447C]">
|
||||
{guestLine}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatNumber(order.items.length, numberLocale)}{" "}
|
||||
{t("itemsCount")} · {formatOrderNumber(order)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-sm font-bold text-primary">
|
||||
{formatCurrency(order.total, numberLocale)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="flex h-full min-h-0 w-[min(100%,20rem)] shrink-0 flex-col overflow-hidden sm:w-72 lg:w-80">
|
||||
<CardHeader className="shrink-0 space-y-2 pb-2">
|
||||
<CardTitle className="text-lg">{t("payOrder")}</CardTitle>
|
||||
{selected ? (
|
||||
<div className="rounded-lg border border-[#0F6E56]/30 bg-[#E1F5EE] px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">{t("payFor")}</p>
|
||||
<p className="text-base font-semibold text-[#0F6E56]">
|
||||
{formatPosOrderLabel(selected, t("table"))}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("selectOrderToPay")}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden pt-2">
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto overscroll-contain">
|
||||
{selected.items.map((line) => (
|
||||
<div
|
||||
key={line.id}
|
||||
className="flex justify-between gap-2 rounded-md border border-border/60 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
{line.menuItemName} × {formatNumber(line.quantity, numberLocale)}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{formatCurrency(line.unitPrice * line.quantity, numberLocale)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="shrink-0 space-y-2 border-t border-border pt-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{t("subtotal")}</span>
|
||||
<span>{formatCurrency(selected.subtotal, numberLocale)}</span>
|
||||
</div>
|
||||
{selected.discountAmount > 0 ? (
|
||||
<div className="flex justify-between text-sm text-[#0F6E56]">
|
||||
<span>{t("discount")}</span>
|
||||
<span>-{formatCurrency(selected.discountAmount, numberLocale)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{t("tax")}</span>
|
||||
<span>{formatCurrency(selected.taxTotal, numberLocale)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-bold">
|
||||
<span>{t("total")}</span>
|
||||
<span>{formatCurrency(selected.total, numberLocale)}</span>
|
||||
</div>
|
||||
{(selected.paidAmount ?? 0) > 0 ? (
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{t("paidSoFar")}</span>
|
||||
<span>{formatCurrency(selected.paidAmount, numberLocale)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-between text-sm font-semibold text-primary">
|
||||
<span>{t("remaining")}</span>
|
||||
<span>{formatCurrency(effectiveRemaining, numberLocale)}</span>
|
||||
</div>
|
||||
{loyaltyDiscount > 0 ? (
|
||||
<div className="flex justify-between text-sm text-[#0F6E56]">
|
||||
<span>{t("loyaltyRedeemApplied")}</span>
|
||||
<span>-{formatCurrency(loyaltyDiscount, numberLocale)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selected.customerId && payCustomer ? (
|
||||
<div className="space-y-2 rounded-lg border border-[#0F6E56]/25 bg-[#E1F5EE]/50 p-2">
|
||||
<p className="text-xs font-medium text-[#0F6E56]">
|
||||
{t("loyaltyBalance", {
|
||||
points: formatNumber(payCustomer.loyaltyPoints, numberLocale),
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={maxLoyaltyRedeem}
|
||||
value={loyaltyRedeem || ""}
|
||||
onChange={(e) => {
|
||||
const n = Math.min(
|
||||
maxLoyaltyRedeem,
|
||||
Math.max(0, parseInt(e.target.value, 10) || 0)
|
||||
);
|
||||
setLoyaltyRedeem(n);
|
||||
}}
|
||||
className="h-8 w-24 tabular-nums"
|
||||
disabled={maxLoyaltyRedeem === 0}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
disabled={maxLoyaltyRedeem === 0}
|
||||
onClick={() => setLoyaltyRedeem(maxLoyaltyRedeem)}
|
||||
>
|
||||
{t("loyaltyUseMax")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t("loyaltyRedeemHint")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2 pt-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t("splitPayments")}
|
||||
</p>
|
||||
{paymentRows.map((row, idx) => (
|
||||
<div key={idx} className="flex gap-2">
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
value={row.method}
|
||||
onChange={(e) => {
|
||||
const method = e.target.value as PaymentRow["method"];
|
||||
setPaymentRows((rows) =>
|
||||
rows.map((r, i) => (i === idx ? { ...r, method } : r))
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="Cash">{t("cash")}</option>
|
||||
<option value="Card">{t("card")}</option>
|
||||
<option value="Credit">{t("credit")}</option>
|
||||
</select>
|
||||
<Input
|
||||
dir="ltr"
|
||||
className="h-9 flex-1 text-end tabular-nums"
|
||||
value={row.amount}
|
||||
onChange={(e) => {
|
||||
const amount = e.target.value;
|
||||
setPaymentRows((rows) =>
|
||||
rows.map((r, i) => (i === idx ? { ...r, amount } : r))
|
||||
);
|
||||
}}
|
||||
placeholder="0"
|
||||
/>
|
||||
{paymentRows.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setPaymentRows((rows) => rows.filter((_, i) => i !== idx))
|
||||
}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
setPaymentRows((rows) => [
|
||||
...rows,
|
||||
{ method: "Card", amount: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
{t("addPaymentRow")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{payMessage ? (
|
||||
<p className="text-center text-sm text-primary">{payMessage}</p>
|
||||
) : null}
|
||||
{lastPaidOrderId ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={thermalPrint.isPending}
|
||||
onClick={() => thermalPrint.mutate(lastPaidOrderId)}
|
||||
>
|
||||
{thermalPrint.isPending ? "..." : tPrint("printReceipt")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setReceiptOrder(selected)}
|
||||
>
|
||||
{t("previewBill")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full border-[#A32D2D]/40 text-[#A32D2D] hover:bg-red-50"
|
||||
disabled={cancelOrder.isPending}
|
||||
onClick={async () => {
|
||||
if (!selected) return;
|
||||
const ok = await confirmDialog({
|
||||
description: t("cancelOrderConfirm"),
|
||||
variant: "destructive",
|
||||
confirmLabel: t("cancelOrder"),
|
||||
});
|
||||
if (!ok) return;
|
||||
cancelOrder.mutate(selected.id);
|
||||
}}
|
||||
>
|
||||
{cancelOrder.isPending ? "..." : t("cancelOrder")}
|
||||
</Button>
|
||||
<form
|
||||
className="w-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (canPay && selected && !payOrder.isPending) {
|
||||
payOrder.mutate(selected);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!canPay || payOrder.isPending}
|
||||
>
|
||||
{payOrder.isPending ? "..." : payButtonLabel}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{receiptOrder ? (
|
||||
<PosSlipModal
|
||||
variant="bill"
|
||||
order={receiptOrder}
|
||||
cafeName={cafeName}
|
||||
onClose={() => setReceiptOrder(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user