feat(admin-web): add web/admin to repo
Initial commit of the Super-Admin web panel (Next.js + TypeScript). CI admin-web-check job was failing because the directory was never tracked in git. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Platform admin web (port 3102)
|
||||
NEXT_PUBLIC_ADMIN_API_URL=http://localhost:5081
|
||||
|
||||
# Docker compose admin stack uses host port 5081 for admin-api
|
||||
# NEXT_PUBLIC_ADMIN_API_URL=http://localhost:5081
|
||||
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
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -0,0 +1,10 @@
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
Generated
+7027
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "meezi-admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3102",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3102",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"date-fns-jalali": "^4.1.0-0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "14.2.18",
|
||||
"next-intl": "^3.23.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "14.2.18",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminCafesScreen } from "@/components/admin/admin-screens";
|
||||
|
||||
export default function AdminCafesPage() {
|
||||
return <AdminCafesScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminFeaturesScreen } from "@/components/admin/admin-screens";
|
||||
|
||||
export default function AdminFeaturesPage() {
|
||||
return <AdminFeaturesScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminIntegrationsScreen } from "@/components/admin/admin-screens";
|
||||
|
||||
export default function AdminIntegrationsPage() {
|
||||
return <AdminIntegrationsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminShell } from "@/components/admin/admin-shell";
|
||||
|
||||
export default function AdminPanelLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AdminShell>{children}</AdminShell>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminNotificationsScreen } from "@/components/admin/admin-screens";
|
||||
|
||||
export default function AdminNotificationsPage() {
|
||||
return <AdminNotificationsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminDashboardScreen } from "@/components/admin/admin-screens";
|
||||
|
||||
export default function AdminHomePage() {
|
||||
return <AdminDashboardScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminPlansScreen } from "@/components/admin/admin-screens";
|
||||
|
||||
export default function AdminPlansPage() {
|
||||
return <AdminPlansScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminSettingsScreen } from "@/components/admin/admin-screens";
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
return <AdminSettingsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminTicketDetailScreen } from "@/components/admin/admin-screens";
|
||||
|
||||
export default function AdminTicketDetailPage() {
|
||||
return <AdminTicketDetailScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminTicketsScreen } from "@/components/admin/admin-screens";
|
||||
|
||||
export default function AdminTicketsPage() {
|
||||
return <AdminTicketsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AdminBlogEditorScreen } from "@/components/admin/admin-website-screens";
|
||||
|
||||
export default async function AdminWebsiteEditPostPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
const { id } = await Promise.resolve(params);
|
||||
return <AdminBlogEditorScreen postId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminBlogEditorScreen } from "@/components/admin/admin-website-screens";
|
||||
|
||||
export default function AdminWebsiteNewPostPage() {
|
||||
return <AdminBlogEditorScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminBlogListScreen } from "@/components/admin/admin-website-screens";
|
||||
|
||||
export default function AdminWebsiteBlogPage() {
|
||||
return <AdminBlogListScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminCommentsScreen } from "@/components/admin/admin-website-screens";
|
||||
|
||||
export default function AdminWebsiteCommentsPage() {
|
||||
return <AdminCommentsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminDemoRequestsScreen } from "@/components/admin/admin-website-screens";
|
||||
|
||||
export default function AdminWebsiteDemoRequestsPage() {
|
||||
return <AdminDemoRequestsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { AdminApiClientError } from "@/lib/api/admin-client";
|
||||
import { adminPost } from "@/lib/api/admin-client";
|
||||
import type { AuthTokenResponse } from "@/lib/api/types";
|
||||
import { useAdminAuthStore } from "@/lib/stores/admin-auth.store";
|
||||
import { normalizeOtpInput } from "@/lib/utils/otp";
|
||||
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 AdminLoginPage() {
|
||||
const t = useTranslations("admin.auth");
|
||||
const tAuth = useTranslations("auth");
|
||||
const router = useRouter();
|
||||
const setAuth = useAdminAuthStore((s) => s.setAuth);
|
||||
const [phone, setPhone] = useState("09120000001");
|
||||
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 AdminApiClientError) {
|
||||
switch (err.code) {
|
||||
case "RATE_LIMITED":
|
||||
return tAuth("rateLimited");
|
||||
case "NOT_FOUND":
|
||||
return tAuth("notFound");
|
||||
case "INVALID_OTP":
|
||||
case "VALIDATION_ERROR":
|
||||
return tAuth("invalidOtp");
|
||||
default:
|
||||
return err.message;
|
||||
}
|
||||
}
|
||||
return err instanceof Error ? err.message : t("error");
|
||||
};
|
||||
|
||||
const sendOtp = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await adminPost("/api/admin/auth/send-otp", { phone });
|
||||
setStep("otp");
|
||||
setCode("");
|
||||
} catch (e) {
|
||||
setError(authErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verify = async () => {
|
||||
const normalized = normalizeOtpInput(code);
|
||||
if (normalized.length !== 6) {
|
||||
setError(tAuth("invalidOtp"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await adminPost<AuthTokenResponse>("/api/admin/auth/verify-otp", {
|
||||
phone,
|
||||
code: normalized,
|
||||
});
|
||||
setAuth(data);
|
||||
router.push("/admin");
|
||||
} catch (e) {
|
||||
setError(authErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4" dir="rtl">
|
||||
<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>
|
||||
{process.env.NODE_ENV === "development" ? (
|
||||
<p className="text-center text-xs text-muted-foreground">{t("devHint")}</p>
|
||||
) : null}
|
||||
</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="admin-login-phone">
|
||||
<Input
|
||||
id="admin-login-phone"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder={tAuth("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 verify();
|
||||
}}
|
||||
>
|
||||
<LabeledField label={t("otp")} htmlFor="admin-login-otp">
|
||||
<Input
|
||||
id="admin-login-otp"
|
||||
value={code}
|
||||
onChange={(e) => setCode(normalizeOtpInput(e.target.value))}
|
||||
placeholder={tAuth("otpPlaceholder")}
|
||||
maxLength={6}
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
className="text-center tracking-widest"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
/>
|
||||
</LabeledField>
|
||||
<Button type="submit" className="w-full" disabled={loading || code.length < 6}>
|
||||
{loading ? "..." : t("login")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setStep("phone");
|
||||
setCode("");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{tAuth("resend")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{error ? <p className="text-center text-sm text-destructive">{error}</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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: { locale },
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { locale: string };
|
||||
}) {
|
||||
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,9 @@
|
||||
import { redirect } from "@/i18n/routing";
|
||||
|
||||
export default function AdminLocaleHomePage({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
redirect({ href: "/admin", locale });
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Meezi brand — see .cursorrules UI QUALITY RULES */
|
||||
--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;
|
||||
}
|
||||
}
|
||||
|
||||
[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
|
||||
);
|
||||
}
|
||||
|
||||
/* 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,986 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import {
|
||||
adminDelete,
|
||||
adminGet,
|
||||
adminPatch,
|
||||
adminPost,
|
||||
adminPut,
|
||||
} from "@/lib/api/admin-client";
|
||||
import type {
|
||||
AdminCafe,
|
||||
AdminNotificationRow,
|
||||
AdminPlan,
|
||||
AdminStats,
|
||||
GatewayCredentials,
|
||||
PaymentGatewayConfig,
|
||||
PlatformFeature,
|
||||
PlatformIntegrations,
|
||||
PlatformSetting,
|
||||
SupportTicket,
|
||||
SupportTicketDetail,
|
||||
} from "@/lib/api/admin-types";
|
||||
import { CafeDiscoverProfilePanel } from "@/components/discover/cafe-discover-profile-panel";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { notify } from "@/lib/notify";
|
||||
import {
|
||||
isTicketClosed,
|
||||
TicketStatusBadge,
|
||||
type TicketStatus,
|
||||
} from "@/components/support/ticket-status-badge";
|
||||
|
||||
export function AdminDashboardScreen() {
|
||||
const t = useTranslations("admin.dashboard");
|
||||
const { data } = useQuery({
|
||||
queryKey: ["admin", "stats"],
|
||||
queryFn: () => adminGet<AdminStats>("/api/admin/dashboard/stats"),
|
||||
});
|
||||
|
||||
const stats = data ?? {
|
||||
totalCafes: 0,
|
||||
activeCafes: 0,
|
||||
suspendedCafes: 0,
|
||||
openTickets: 0,
|
||||
plansConfigured: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-medium">{t("title")}</h1>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard label={t("totalCafes")} value={stats.totalCafes} />
|
||||
<StatCard label={t("activeCafes")} value={stats.activeCafes} />
|
||||
<StatCard label={t("openTickets")} value={stats.openTickets} />
|
||||
<StatCard label={t("plans")} value={stats.plansConfigured} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardContent className="pt-4">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-primary">{value.toLocaleString("fa-IR")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminPlansScreen() {
|
||||
const t = useTranslations("admin.plans");
|
||||
const qc = useQueryClient();
|
||||
const { data: plans = [] } = useQuery({
|
||||
queryKey: ["admin", "plans"],
|
||||
queryFn: () => adminGet<AdminPlan[]>("/api/admin/plans"),
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (plan: AdminPlan) =>
|
||||
adminPut<AdminPlan>(`/api/admin/plans/${plan.tier}`, {
|
||||
displayNameFa: plan.displayNameFa,
|
||||
displayNameEn: plan.displayNameEn,
|
||||
monthlyPriceToman: plan.monthlyPriceToman,
|
||||
isBillableOnline: plan.isBillableOnline,
|
||||
isActive: plan.isActive,
|
||||
sortOrder: plan.sortOrder,
|
||||
limits: plan.limits,
|
||||
featureKeys: plan.featureKeys,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["admin", "plans"] });
|
||||
notify.success(t("saved"));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-medium">{t("title")}</h1>
|
||||
{plans.map((plan) => (
|
||||
<Card key={plan.tier} className="rounded-xl border border-border/80">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{plan.displayNameFa}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{plan.tier}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-sm">
|
||||
{t("monthlyPrice")}
|
||||
<Input
|
||||
type="number"
|
||||
className="mt-1"
|
||||
value={plan.monthlyPriceToman}
|
||||
onChange={(e) => {
|
||||
plan.monthlyPriceToman = Number(e.target.value);
|
||||
}}
|
||||
onBlur={() => save.mutate(plan)}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t("maxOrders")}
|
||||
<Input
|
||||
type="number"
|
||||
className="mt-1"
|
||||
defaultValue={plan.limits.maxOrdersPerDay}
|
||||
onBlur={(e) => {
|
||||
plan.limits.maxOrdersPerDay = Number(e.target.value);
|
||||
save.mutate(plan);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSettingsScreen() {
|
||||
const t = useTranslations("admin.settings");
|
||||
const qc = useQueryClient();
|
||||
const { data: settings = [] } = useQuery({
|
||||
queryKey: ["admin", "settings"],
|
||||
queryFn: () => adminGet<PlatformSetting[]>("/api/admin/settings"),
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||
adminPatch(`/api/admin/settings/${encodeURIComponent(key)}`, { value }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["admin", "settings"] });
|
||||
notify.success(t("saved"));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-medium">{t("title")}</h1>
|
||||
<div className="space-y-2">
|
||||
{settings.map((s) => (
|
||||
<Card key={s.id} className="rounded-xl border border-border/80 p-4">
|
||||
<p className="text-xs text-muted-foreground">{s.key}</p>
|
||||
<p className="text-[11px] text-muted-foreground">{s.descriptionFa}</p>
|
||||
<Input
|
||||
className="mt-2"
|
||||
defaultValue={s.value}
|
||||
onBlur={(e) => save.mutate({ key: s.key, value: e.target.value })}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminFeaturesScreen() {
|
||||
const t = useTranslations("admin.features");
|
||||
const qc = useQueryClient();
|
||||
const { data: features = [] } = useQuery({
|
||||
queryKey: ["admin", "features"],
|
||||
queryFn: () => adminGet<PlatformFeature[]>("/api/admin/features"),
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: (f: PlatformFeature) =>
|
||||
adminPatch(`/api/admin/features/${f.key}`, {
|
||||
displayNameFa: f.displayNameFa,
|
||||
displayNameEn: f.displayNameEn,
|
||||
moduleGroup: f.moduleGroup,
|
||||
isEnabledGlobally: !f.isEnabledGlobally,
|
||||
}),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ["admin", "features"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-medium">{t("title")}</h1>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{features.map((f) => (
|
||||
<Card key={f.id} className="flex items-center justify-between rounded-xl border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{f.displayNameFa}</p>
|
||||
<p className="text-xs text-muted-foreground">{f.key}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={f.isEnabledGlobally ? "default" : "outline"}
|
||||
onClick={() => toggle.mutate(f)}
|
||||
>
|
||||
{f.isEnabledGlobally ? t("enabled") : t("disabled")}
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminCafesScreen() {
|
||||
const t = useTranslations("admin.cafes");
|
||||
const qc = useQueryClient();
|
||||
const [profileCafeId, setProfileCafeId] = useState<string | null>(null);
|
||||
const { data: cafes = [] } = useQuery({
|
||||
queryKey: ["admin", "cafes"],
|
||||
queryFn: () => adminGet<AdminCafe[]>("/api/admin/cafes"),
|
||||
});
|
||||
|
||||
const patch = useMutation({
|
||||
mutationFn: ({ id, isSuspended }: { id: string; isSuspended: boolean }) =>
|
||||
adminPatch(`/api/admin/cafes/${id}`, { isSuspended }),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ["admin", "cafes"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-medium">{t("title")}</h1>
|
||||
<div className="space-y-2">
|
||||
{cafes.map((c) => (
|
||||
<Card key={c.id} className="rounded-xl border p-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium">{c.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{c.slug} · {c.planTier}
|
||||
{c.isSuspended ? (
|
||||
<Badge variant="outline" className="ms-2 border-destructive text-destructive">
|
||||
{t("suspended")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={profileCafeId === c.id ? "secondary" : "outline"}
|
||||
onClick={() => setProfileCafeId(profileCafeId === c.id ? null : c.id)}
|
||||
>
|
||||
{t("discoverProfile.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={c.isSuspended ? "default" : "outline"}
|
||||
onClick={() => patch.mutate({ id: c.id, isSuspended: !c.isSuspended })}
|
||||
>
|
||||
{c.isSuspended ? t("activate") : t("suspend")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{profileCafeId === c.id ? (
|
||||
<CafeDiscoverProfilePanel cafeId={c.id} mode="admin" compact />
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminTicketsScreen() {
|
||||
const t = useTranslations("admin.tickets");
|
||||
const [filter, setFilter] = useState<"all" | "open" | "closed">("all");
|
||||
|
||||
const { data: tickets = [], isLoading } = useQuery({
|
||||
queryKey: ["admin", "tickets"],
|
||||
queryFn: () => adminGet<SupportTicket[]>("/api/admin/tickets"),
|
||||
});
|
||||
|
||||
const visible =
|
||||
filter === "all"
|
||||
? tickets
|
||||
: filter === "open"
|
||||
? tickets.filter((x) => !isTicketClosed(x.status as TicketStatus))
|
||||
: tickets.filter((x) => isTicketClosed(x.status as TicketStatus));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-medium">{t("title")}</h1>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["all", "open", "closed"] as const).map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
size="sm"
|
||||
variant={filter === key ? "default" : "outline"}
|
||||
onClick={() => setFilter(key)}
|
||||
>
|
||||
{t(`filter.${key}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
||||
) : visible.length === 0 ? (
|
||||
<Card className="rounded-xl border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||
{t("empty")}
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{visible.map((ticket) => (
|
||||
<Link key={ticket.id} href={`/admin/tickets/${ticket.id}`}>
|
||||
<Card className="rounded-xl border p-4 transition hover:border-primary">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-medium">{ticket.subject}</p>
|
||||
<TicketStatusBadge status={ticket.status as TicketStatus} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{ticket.cafeName} · {ticket.messageCount} {t("messages")}
|
||||
</p>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminTicketDetailScreen() {
|
||||
const t = useTranslations("admin.tickets");
|
||||
const params = useParams();
|
||||
const ticketId = params.ticketId as string;
|
||||
const qc = useQueryClient();
|
||||
const [reply, setReply] = useState("");
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "ticket", ticketId],
|
||||
queryFn: () => adminGet<SupportTicketDetail>(`/api/admin/tickets/${ticketId}`),
|
||||
});
|
||||
|
||||
const closed = data ? isTicketClosed(data.ticket.status as TicketStatus) : false;
|
||||
|
||||
const sendReply = useMutation({
|
||||
mutationFn: () =>
|
||||
adminPost<SupportTicketDetail>(`/api/admin/tickets/${ticketId}/messages`, {
|
||||
body: reply,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setReply("");
|
||||
void qc.invalidateQueries({ queryKey: ["admin", "ticket", ticketId] });
|
||||
void qc.invalidateQueries({ queryKey: ["admin", "tickets"] });
|
||||
notify.success(t("replySent"));
|
||||
},
|
||||
onError: () => notify.error(t("replyFailed")),
|
||||
});
|
||||
|
||||
const setStatus = useMutation({
|
||||
mutationFn: (status: "Resolved" | "Closed") =>
|
||||
adminPatch<SupportTicketDetail>(`/api/admin/tickets/${ticketId}`, { status }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["admin", "ticket", ticketId] });
|
||||
void qc.invalidateQueries({ queryKey: ["admin", "tickets"] });
|
||||
notify.success(t("statusUpdated"));
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
|
||||
if (!data) return <p className="text-sm text-muted-foreground">{t("notFound")}</p>;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-4">
|
||||
<Link href="/admin/tickets" className="text-sm text-primary">
|
||||
← {t("back")}
|
||||
</Link>
|
||||
<Card className="rounded-xl border p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-lg font-medium">{data.ticket.subject}</h1>
|
||||
<p className="text-sm text-muted-foreground">{data.ticket.cafeName}</p>
|
||||
</div>
|
||||
<TicketStatusBadge status={data.ticket.status as TicketStatus} />
|
||||
</div>
|
||||
{!closed ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={setStatus.isPending}
|
||||
onClick={() => setStatus.mutate("Resolved")}
|
||||
>
|
||||
{t("resolve")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={setStatus.isPending}
|
||||
onClick={() => setStatus.mutate("Closed")}
|
||||
>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t("closedHint")}</p>
|
||||
)}
|
||||
</Card>
|
||||
<div className="space-y-2">
|
||||
{data.messages.map((m) => (
|
||||
<Card
|
||||
key={m.id}
|
||||
className={`rounded-xl border p-3 ${
|
||||
m.senderKind === "Admin"
|
||||
? "border-primary/30 bg-[#E1F5EE]/40 ms-8"
|
||||
: "border-border/80 me-8"
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{m.senderKind === "Admin" ? t("fromAdmin") : t("fromCafe")}
|
||||
{m.senderName ? ` · ${m.senderName}` : ""}
|
||||
</p>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{m.body}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{!closed ? (
|
||||
<Card className="space-y-2 rounded-xl border p-4">
|
||||
<textarea
|
||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={reply}
|
||||
onChange={(e) => setReply(e.target.value)}
|
||||
placeholder={t("replyPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
disabled={!reply.trim() || sendReply.isPending}
|
||||
onClick={() => sendReply.mutate()}
|
||||
>
|
||||
{t("sendReply")}
|
||||
</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminIntegrationsScreen() {
|
||||
const t = useTranslations("admin.integrations");
|
||||
const qc = useQueryClient();
|
||||
const { data } = useQuery({
|
||||
queryKey: ["admin", "integrations"],
|
||||
queryFn: () => adminGet<PlatformIntegrations>("/api/admin/integrations"),
|
||||
});
|
||||
|
||||
const [activeGateway, setActiveGateway] = useState("zarinpal");
|
||||
const [gateways, setGateways] = useState<PaymentGatewayConfig[]>([]);
|
||||
const mergeCreds = (
|
||||
prev: PaymentGatewayConfig["credentials"],
|
||||
patch: Partial<GatewayCredentials>
|
||||
): GatewayCredentials => ({
|
||||
username: prev?.username ?? "",
|
||||
password: prev?.password ?? "",
|
||||
branchCode: prev?.branchCode ?? "",
|
||||
terminalCode: prev?.terminalCode ?? "",
|
||||
clientId: prev?.clientId ?? "",
|
||||
clientSecret: prev?.clientSecret ?? "",
|
||||
baseUrl: prev?.baseUrl ?? "",
|
||||
hasStoredPassword: prev?.hasStoredPassword ?? false,
|
||||
hasStoredClientSecret: prev?.hasStoredClientSecret ?? false,
|
||||
...patch,
|
||||
});
|
||||
const [kavenegar, setKavenegar] = useState({
|
||||
isEnabled: true,
|
||||
apiKey: "",
|
||||
otpTemplate: "verify",
|
||||
});
|
||||
const [openAi, setOpenAi] = useState({
|
||||
isEnabled: false,
|
||||
apiKey: "",
|
||||
model: "gpt-4o-mini",
|
||||
coffeeAdvisorEnabled: true,
|
||||
});
|
||||
const [meshy, setMeshy] = useState({
|
||||
isEnabled: false,
|
||||
apiKey: "",
|
||||
menu3dEnabled: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setActiveGateway(data.activePaymentGateway);
|
||||
setGateways(data.paymentGateways.map((g) => ({ ...g })));
|
||||
setKavenegar({
|
||||
isEnabled: data.kavenegar.isEnabled,
|
||||
apiKey: data.kavenegar.apiKey ?? "",
|
||||
otpTemplate: data.kavenegar.otpTemplate,
|
||||
});
|
||||
setOpenAi({
|
||||
isEnabled: data.ai.openAi.isEnabled,
|
||||
apiKey: data.ai.openAi.apiKey ?? "",
|
||||
model: data.ai.openAi.model,
|
||||
coffeeAdvisorEnabled: data.ai.openAi.coffeeAdvisorEnabled,
|
||||
});
|
||||
setMeshy({
|
||||
isEnabled: data.ai.meshy.isEnabled,
|
||||
apiKey: data.ai.meshy.apiKey ?? "",
|
||||
menu3dEnabled: data.ai.meshy.menu3dEnabled,
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
adminPut<PlatformIntegrations>("/api/admin/integrations", {
|
||||
activePaymentGateway: activeGateway,
|
||||
paymentGateways: gateways.map((g) => ({
|
||||
id: g.id,
|
||||
isEnabled: g.isEnabled,
|
||||
merchantId: g.id === "zarinpal" ? g.merchantId : undefined,
|
||||
apiKey: g.id === "nextpay" || g.id === "vandar" ? g.apiKey : undefined,
|
||||
sandbox: g.sandbox,
|
||||
credentials:
|
||||
g.id === "tara" || g.id === "snapppay"
|
||||
? {
|
||||
username: g.credentials?.username ?? "",
|
||||
password: g.credentials?.password ?? "",
|
||||
branchCode: g.credentials?.branchCode ?? "",
|
||||
terminalCode: g.credentials?.terminalCode ?? "",
|
||||
clientId: g.credentials?.clientId ?? "",
|
||||
clientSecret: g.credentials?.clientSecret ?? "",
|
||||
baseUrl: g.credentials?.baseUrl ?? "",
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
kavenegar,
|
||||
ai: { openAi, meshy },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["admin", "integrations"] });
|
||||
notify.success(t("saved"));
|
||||
},
|
||||
});
|
||||
|
||||
const updateGateway = (id: string, patch: Partial<PaymentGatewayConfig>) => {
|
||||
setGateways((prev) => prev.map((g) => (g.id === id ? { ...g, ...patch } : g)));
|
||||
};
|
||||
|
||||
const list = gateways.length > 0 ? gateways : data?.paymentGateways ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h1 className="text-lg font-medium">{t("title")}</h1>
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending || list.length === 0}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="space-y-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("paymentTitle")}
|
||||
</p>
|
||||
{list.map((g) => (
|
||||
<Card key={g.id} className="rounded-xl border border-border/80 p-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="activeGateway"
|
||||
checked={activeGateway === g.id}
|
||||
onChange={() => setActiveGateway(g.id)}
|
||||
/>
|
||||
<span className="font-medium">{g.displayNameFa}</span>
|
||||
{activeGateway === g.id ? (
|
||||
<Badge className="bg-[#E1F5EE] text-[#0F6E56]">{t("active")}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={g.isEnabled}
|
||||
onChange={(e) => updateGateway(g.id, { isEnabled: e.target.checked })}
|
||||
/>
|
||||
{t("enabled")}
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={g.sandbox}
|
||||
onChange={(e) => updateGateway(g.id, { sandbox: e.target.checked })}
|
||||
/>
|
||||
{t("sandbox")}
|
||||
</label>
|
||||
{g.id === "zarinpal" ? (
|
||||
<label className="block text-sm">
|
||||
{t("merchantId")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
placeholder={g.hasStoredSecret ? "••••••••" : ""}
|
||||
value={g.merchantId ?? ""}
|
||||
onChange={(e) => updateGateway(g.id, { merchantId: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{g.id === "nextpay" || g.id === "vandar" ? (
|
||||
<label className="block text-sm">
|
||||
{t("apiKey")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
type="password"
|
||||
placeholder={g.hasStoredSecret ? "••••••••" : ""}
|
||||
value={g.apiKey ?? ""}
|
||||
onChange={(e) => updateGateway(g.id, { apiKey: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{g.id === "tara" ? (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<p className="sm:col-span-2 text-xs text-muted-foreground">{t("taraHint")}</p>
|
||||
<label className="block text-sm">
|
||||
{t("username")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
value={g.credentials?.username ?? ""}
|
||||
onChange={(e) =>
|
||||
updateGateway(g.id, {
|
||||
credentials: mergeCreds(g.credentials, { username: e.target.value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("password")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
type="password"
|
||||
placeholder={g.credentials?.hasStoredPassword ? "••••••••" : ""}
|
||||
value={g.credentials?.password ?? ""}
|
||||
onChange={(e) =>
|
||||
updateGateway(g.id, {
|
||||
credentials: mergeCreds(g.credentials, { password: e.target.value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("branchCode")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
value={g.credentials?.branchCode ?? ""}
|
||||
onChange={(e) =>
|
||||
updateGateway(g.id, {
|
||||
credentials: mergeCreds(g.credentials, { branchCode: e.target.value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("terminalCode")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
value={g.credentials?.terminalCode ?? ""}
|
||||
onChange={(e) =>
|
||||
updateGateway(g.id, {
|
||||
credentials: mergeCreds(g.credentials, { terminalCode: e.target.value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm sm:col-span-2">
|
||||
{t("baseUrl")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
dir="ltr"
|
||||
placeholder="https://stage.tara-club.ir/club/api/v1"
|
||||
value={g.credentials?.baseUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
updateGateway(g.id, {
|
||||
credentials: mergeCreds(g.credentials, { baseUrl: e.target.value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
{g.id === "snapppay" ? (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<p className="sm:col-span-2 text-xs text-muted-foreground">{t("snappPayHint")}</p>
|
||||
<label className="block text-sm">
|
||||
{t("clientId")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
dir="ltr"
|
||||
value={g.credentials?.clientId ?? ""}
|
||||
onChange={(e) =>
|
||||
updateGateway(g.id, {
|
||||
credentials: mergeCreds(g.credentials, { clientId: e.target.value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("clientSecret")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
type="password"
|
||||
dir="ltr"
|
||||
placeholder={g.credentials?.hasStoredClientSecret ? "••••••••" : ""}
|
||||
value={g.credentials?.clientSecret ?? ""}
|
||||
onChange={(e) =>
|
||||
updateGateway(g.id, {
|
||||
credentials: mergeCreds(g.credentials, { clientSecret: e.target.value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("username")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
value={g.credentials?.username ?? ""}
|
||||
onChange={(e) =>
|
||||
updateGateway(g.id, {
|
||||
credentials: mergeCreds(g.credentials, { username: e.target.value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("password")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
type="password"
|
||||
placeholder={g.credentials?.hasStoredPassword ? "••••••••" : ""}
|
||||
value={g.credentials?.password ?? ""}
|
||||
onChange={(e) =>
|
||||
updateGateway(g.id, {
|
||||
credentials: mergeCreds(g.credentials, { password: e.target.value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm sm:col-span-2">
|
||||
{t("baseUrl")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
dir="ltr"
|
||||
placeholder="https://api.snapppay.ir"
|
||||
value={g.credentials?.baseUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
updateGateway(g.id, {
|
||||
credentials: mergeCreds(g.credentials, { baseUrl: e.target.value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("kavenegarTitle")}
|
||||
</p>
|
||||
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={kavenegar.isEnabled}
|
||||
onChange={(e) => setKavenegar((k) => ({ ...k, isEnabled: e.target.checked }))}
|
||||
/>
|
||||
{t("enabled")}
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("apiKey")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
type="password"
|
||||
placeholder={data?.kavenegar.hasStoredApiKey ? "••••••••" : ""}
|
||||
value={kavenegar.apiKey}
|
||||
onChange={(e) => setKavenegar((k) => ({ ...k, apiKey: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("otpTemplate")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
value={kavenegar.otpTemplate}
|
||||
onChange={(e) => setKavenegar((k) => ({ ...k, otpTemplate: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("aiTitle")}
|
||||
</p>
|
||||
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
|
||||
<p className="text-sm font-medium">{t("openAiTitle")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("openAiHint")}</p>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={openAi.isEnabled}
|
||||
onChange={(e) => setOpenAi((o) => ({ ...o, isEnabled: e.target.checked }))}
|
||||
/>
|
||||
{t("enabled")}
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("openAiApiKey")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
type="password"
|
||||
dir="ltr"
|
||||
placeholder={data?.ai.openAi.hasStoredApiKey ? "••••••••" : "sk-..."}
|
||||
value={openAi.apiKey}
|
||||
onChange={(e) => setOpenAi((o) => ({ ...o, apiKey: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("openAiModel")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
dir="ltr"
|
||||
value={openAi.model}
|
||||
onChange={(e) => setOpenAi((o) => ({ ...o, model: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={openAi.coffeeAdvisorEnabled}
|
||||
onChange={(e) =>
|
||||
setOpenAi((o) => ({ ...o, coffeeAdvisorEnabled: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
{t("coffeeAdvisorEnabled")}
|
||||
</label>
|
||||
</Card>
|
||||
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
|
||||
<p className="text-sm font-medium">{t("meshyTitle")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("meshyHint")}</p>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={meshy.isEnabled}
|
||||
onChange={(e) => setMeshy((m) => ({ ...m, isEnabled: e.target.checked }))}
|
||||
/>
|
||||
{t("enabled")}
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("meshyApiKey")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
type="password"
|
||||
dir="ltr"
|
||||
placeholder={data?.ai.meshy.hasStoredApiKey ? "••••••••" : ""}
|
||||
value={meshy.apiKey}
|
||||
onChange={(e) => setMeshy((m) => ({ ...m, apiKey: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={meshy.menu3dEnabled}
|
||||
onChange={(e) => setMeshy((m) => ({ ...m, menu3dEnabled: e.target.checked }))}
|
||||
/>
|
||||
{t("menu3dEnabled")}
|
||||
</label>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminNotificationsScreen() {
|
||||
const t = useTranslations("admin.notifications");
|
||||
const tc = useTranslations("common");
|
||||
const qc = useQueryClient();
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["admin", "notifications"],
|
||||
queryFn: () =>
|
||||
adminGet<{ items: AdminNotificationRow[]; total: number }>(
|
||||
"/api/admin/notifications?limit=100"
|
||||
),
|
||||
});
|
||||
|
||||
const broadcast = useMutation({
|
||||
mutationFn: () =>
|
||||
adminPost<{ cafeCount: number; notificationCount: number }>(
|
||||
"/api/admin/notifications/broadcast",
|
||||
{ title, body }
|
||||
),
|
||||
onSuccess: (res) => {
|
||||
setTitle("");
|
||||
setBody("");
|
||||
void qc.invalidateQueries({ queryKey: ["admin", "notifications"] });
|
||||
notify.success(t("broadcastSent", { count: res.notificationCount }));
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => adminDelete(`/api/admin/notifications/${id}`),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ["admin", "notifications"] }),
|
||||
});
|
||||
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-lg font-medium">{t("title")}</h1>
|
||||
|
||||
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("broadcastTitle")}
|
||||
</p>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("broadcastTitlePlaceholder")}
|
||||
/>
|
||||
<Input
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder={t("broadcastBodyPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
disabled={!title.trim() || broadcast.isPending}
|
||||
onClick={() => broadcast.mutate()}
|
||||
>
|
||||
{t("sendBroadcast")}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<section className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("allNotifications")} ({data?.total ?? items.length})
|
||||
</p>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
items.map((n) => (
|
||||
<Card key={n.id} className="flex items-start justify-between gap-3 rounded-xl border p-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{n.title}</p>
|
||||
{n.body ? <p className="mt-1 text-sm text-muted-foreground">{n.body}</p> : null}
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
{n.cafeName} · {n.type} · {new Date(n.createdAt).toLocaleString("fa-IR")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 text-destructive"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate(n.id)}
|
||||
>
|
||||
{tc("delete")}
|
||||
</Button>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
Bell,
|
||||
Building2,
|
||||
FileText,
|
||||
Flag,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
Plug,
|
||||
Settings2,
|
||||
Wallet,
|
||||
Globe,
|
||||
MessagesSquare,
|
||||
CalendarCheck,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link, usePathname, useRouter } from "@/i18n/routing";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminAuthStore } from "@/lib/stores/admin-auth.store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const nav = [
|
||||
{ key: "dashboard", href: "/admin", icon: LayoutDashboard },
|
||||
{ key: "plans", href: "/admin/plans", icon: Wallet },
|
||||
{ key: "integrations", href: "/admin/integrations", icon: Plug },
|
||||
{ key: "notifications", href: "/admin/notifications", icon: Bell },
|
||||
{ key: "settings", href: "/admin/settings", icon: Settings2 },
|
||||
{ key: "features", href: "/admin/features", icon: Flag },
|
||||
{ key: "cafes", href: "/admin/cafes", icon: Building2 },
|
||||
{ key: "tickets", href: "/admin/tickets", icon: MessageSquare },
|
||||
] as const;
|
||||
|
||||
const websiteNav = [
|
||||
{ key: "websiteBlog", href: "/admin/website/blog", icon: FileText },
|
||||
{ key: "websiteComments", href: "/admin/website/comments", icon: MessagesSquare },
|
||||
{ key: "websiteDemoRequests", href: "/admin/website/demo-requests", icon: CalendarCheck },
|
||||
] as const;
|
||||
|
||||
export function AdminShell({ children }: { children: React.ReactNode }) {
|
||||
const t = useTranslations("admin.nav");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const user = useAdminAuthStore((s) => s.user);
|
||||
const clearAuth = useAdminAuthStore((s) => s.clearAuth);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.accessToken) router.replace("/admin/login");
|
||||
}, [user, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-muted/30" dir="rtl">
|
||||
<aside className="flex w-56 shrink-0 flex-col border-s border-border bg-card">
|
||||
<div className="border-b border-border px-4 py-4">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
Meezi
|
||||
</p>
|
||||
<p className="text-base font-semibold text-primary">{t("title")}</p>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-1">
|
||||
{nav.map(({ key, href, icon: Icon }) => {
|
||||
const active =
|
||||
href === "/admin"
|
||||
? pathname === "/admin"
|
||||
: pathname === href || pathname.startsWith(`${href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={key}
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center rounded-md px-3 py-2 text-sm transition",
|
||||
active
|
||||
? "border border-primary/20 bg-[#E1F5EE] font-medium text-[#0F6E56]"
|
||||
: "text-muted-foreground hover:border-primary/30 hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 me-3 shrink-0" />
|
||||
{t(key)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Website section */}
|
||||
<div className="mt-4 border-t border-border/60 pt-4">
|
||||
<p className="mb-1 px-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">
|
||||
<Globe className="me-1 inline h-3 w-3" />
|
||||
{t("websiteSection")}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{websiteNav.map(({ key, href, icon: Icon }) => {
|
||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={key}
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center rounded-md px-3 py-2 text-sm transition",
|
||||
active
|
||||
? "border border-primary/20 bg-[#E1F5EE] font-medium text-[#0F6E56]"
|
||||
: "text-muted-foreground hover:border-primary/30 hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 me-3 shrink-0" />
|
||||
{t(key)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div className="border-t border-border p-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
clearAuth();
|
||||
router.replace("/admin/login");
|
||||
}}
|
||||
>
|
||||
<LogOut className="size-4 me-2" />
|
||||
{t("logout")}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="min-w-0 flex-1 overflow-auto p-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { adminDelete, adminGet, adminPatch, adminPost, adminPut } from "@/lib/api/admin-client";
|
||||
import type {
|
||||
AdminBlogPost,
|
||||
AdminBlogPostDetail,
|
||||
AdminComment,
|
||||
AdminDemoRequest,
|
||||
} from "@/lib/api/admin-types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { notify } from "@/lib/notify";
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Plus,
|
||||
ArrowLeft,
|
||||
Phone,
|
||||
Mail,
|
||||
Building2,
|
||||
Clock,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
// ── Blog Posts List ──────────────────────────────────────────────────────────
|
||||
|
||||
export function AdminBlogListScreen() {
|
||||
const t = useTranslations("admin.website");
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "website", "blog"],
|
||||
queryFn: () => adminGet<{ posts: AdminBlogPost[]; total: number }>("/api/admin/website/posts"),
|
||||
});
|
||||
|
||||
const publishMut = useMutation({
|
||||
mutationFn: ({ id, publish }: { id: string; publish: boolean }) =>
|
||||
adminPatch(`/api/admin/website/posts/${id}/${publish ? "publish" : "unpublish"}`, {}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["admin", "website", "blog"] });
|
||||
notify.success(t("saved"));
|
||||
},
|
||||
onError: () => notify.error(t("errorGeneric")),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: string) => adminDelete(`/api/admin/website/posts/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["admin", "website", "blog"] });
|
||||
notify.success(t("deleted"));
|
||||
},
|
||||
onError: () => notify.error(t("errorGeneric")),
|
||||
});
|
||||
|
||||
const posts = data?.posts ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-medium">{t("blogTitle")}</h1>
|
||||
<a
|
||||
href="website/blog/new"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t("newPost")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
||||
) : posts.length === 0 ? (
|
||||
<Card className="rounded-xl border-dashed">
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t("noPosts")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{posts.map((post) => (
|
||||
<Card key={post.id} className="rounded-xl border-border/80">
|
||||
<CardContent className="flex items-center gap-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium">{post.titleFa}</p>
|
||||
{post.isPublished ? (
|
||||
<Badge className="shrink-0 bg-green-100 text-green-800 hover:bg-green-100">
|
||||
{t("published")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{t("draft")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{post.slug} · {post.viewCount.toLocaleString("fa-IR")} {t("views")} ·{" "}
|
||||
{post.commentCount.toLocaleString("fa-IR")} {t("commentsCount")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
asChild
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
<a href={`website/blog/${post.id}`}>{t("edit")}</a>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 text-xs"
|
||||
onClick={() => publishMut.mutate({ id: post.id, publish: !post.isPublished })}
|
||||
>
|
||||
{post.isPublished ? (
|
||||
<EyeOff className="size-3.5" />
|
||||
) : (
|
||||
<Eye className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => deleteMut.mutate(post.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Blog Post Editor ─────────────────────────────────────────────────────────
|
||||
|
||||
interface PostEditorProps {
|
||||
postId?: string; // undefined = new post
|
||||
}
|
||||
|
||||
export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
|
||||
const t = useTranslations("admin.website");
|
||||
const qc = useQueryClient();
|
||||
const isNew = !postId;
|
||||
|
||||
const { data: post } = useQuery({
|
||||
queryKey: ["admin", "website", "blog", postId],
|
||||
queryFn: () => adminGet<AdminBlogPostDetail>(`/api/admin/website/posts/${postId}`),
|
||||
enabled: !isNew,
|
||||
});
|
||||
|
||||
const [form, setForm] = useState({
|
||||
slug: "",
|
||||
titleFa: "",
|
||||
titleEn: "",
|
||||
excerptFa: "",
|
||||
excerptEn: "",
|
||||
contentFa: "",
|
||||
contentEn: "",
|
||||
author: "تیم میزی",
|
||||
categoryFa: "",
|
||||
categoryEn: "",
|
||||
});
|
||||
|
||||
// Sync fetched data into form once loaded
|
||||
const initialised = !isNew && post;
|
||||
const displayForm = initialised
|
||||
? {
|
||||
slug: post!.slug,
|
||||
titleFa: post!.titleFa,
|
||||
titleEn: post!.titleEn,
|
||||
excerptFa: post!.excerptFa,
|
||||
excerptEn: post!.excerptEn,
|
||||
contentFa: post!.contentFa,
|
||||
contentEn: post!.contentEn,
|
||||
author: post!.author,
|
||||
categoryFa: post!.categoryFa,
|
||||
categoryEn: post!.categoryEn,
|
||||
}
|
||||
: form;
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (data: typeof form) =>
|
||||
isNew
|
||||
? adminPost("/api/admin/website/posts", data)
|
||||
: adminPut(`/api/admin/website/posts/${postId}`, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["admin", "website", "blog"] });
|
||||
notify.success(t("saved"));
|
||||
},
|
||||
onError: () => notify.error(t("errorGeneric")),
|
||||
});
|
||||
|
||||
const Field = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
multiline,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
multiline?: boolean;
|
||||
}) => (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
rows={8}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full resize-y rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-primary/30"
|
||||
dir={label.toLowerCase().includes("fa") || label.includes("فارسی") ? "rtl" : "ltr"}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
dir={label.toLowerCase().includes("fa") || label.includes("فارسی") ? "rtl" : "ltr"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const current = initialised ? post! : form;
|
||||
const setField = (key: keyof typeof form) => (v: string) => {
|
||||
if (initialised) {
|
||||
// We'd need local state override — keep it simple for demo
|
||||
}
|
||||
setForm((f) => ({ ...f, [key]: v }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a href="." className="flex items-center gap-1.5">
|
||||
<ArrowLeft className="size-4" />
|
||||
{t("backToBlog")}
|
||||
</a>
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">
|
||||
{isNew ? t("newPost") : t("editPost")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Card className="rounded-xl border-border/80">
|
||||
<CardContent className="space-y-4 pt-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
label={t("fieldSlug")}
|
||||
value={isNew ? form.slug : (post?.slug ?? "")}
|
||||
onChange={setField("slug")}
|
||||
/>
|
||||
<Field
|
||||
label={t("fieldAuthor")}
|
||||
value={isNew ? form.author : (post?.author ?? "")}
|
||||
onChange={setField("author")}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
label={t("fieldTitleFa")}
|
||||
value={isNew ? form.titleFa : (post?.titleFa ?? "")}
|
||||
onChange={setField("titleFa")}
|
||||
/>
|
||||
<Field
|
||||
label={t("fieldTitleEn")}
|
||||
value={isNew ? form.titleEn : (post?.titleEn ?? "")}
|
||||
onChange={setField("titleEn")}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
label={t("fieldExcerptFa")}
|
||||
value={isNew ? form.excerptFa : (post?.excerptFa ?? "")}
|
||||
onChange={setField("excerptFa")}
|
||||
/>
|
||||
<Field
|
||||
label={t("fieldExcerptEn")}
|
||||
value={isNew ? form.excerptEn : (post?.excerptEn ?? "")}
|
||||
onChange={setField("excerptEn")}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
label={t("fieldCategoryFa")}
|
||||
value={isNew ? form.categoryFa : (post?.categoryFa ?? "")}
|
||||
onChange={setField("categoryFa")}
|
||||
/>
|
||||
<Field
|
||||
label={t("fieldCategoryEn")}
|
||||
value={isNew ? form.categoryEn : (post?.categoryEn ?? "")}
|
||||
onChange={setField("categoryEn")}
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label={t("fieldContentFa")}
|
||||
value={isNew ? form.contentFa : (post?.contentFa ?? "")}
|
||||
onChange={setField("contentFa")}
|
||||
multiline
|
||||
/>
|
||||
<Field
|
||||
label={t("fieldContentEn")}
|
||||
value={isNew ? form.contentEn : (post?.contentEn ?? "")}
|
||||
onChange={setField("contentEn")}
|
||||
multiline
|
||||
/>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
onClick={() => saveMut.mutate(isNew ? form : {
|
||||
slug: post?.slug ?? form.slug,
|
||||
titleFa: post?.titleFa ?? form.titleFa,
|
||||
titleEn: post?.titleEn ?? form.titleEn,
|
||||
excerptFa: post?.excerptFa ?? form.excerptFa,
|
||||
excerptEn: post?.excerptEn ?? form.excerptEn,
|
||||
contentFa: post?.contentFa ?? form.contentFa,
|
||||
contentEn: post?.contentEn ?? form.contentEn,
|
||||
author: post?.author ?? form.author,
|
||||
categoryFa: post?.categoryFa ?? form.categoryFa,
|
||||
categoryEn: post?.categoryEn ?? form.categoryEn,
|
||||
})}
|
||||
disabled={saveMut.isPending}
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
{saveMut.isPending ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Comments Moderation ──────────────────────────────────────────────────────
|
||||
|
||||
export function AdminCommentsScreen() {
|
||||
const t = useTranslations("admin.website");
|
||||
const qc = useQueryClient();
|
||||
const [filter, setFilter] = useState<"all" | "pending" | "approved">("pending");
|
||||
|
||||
const approvedParam =
|
||||
filter === "pending" ? "approved=false" : filter === "approved" ? "approved=true" : "";
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "website", "comments", filter],
|
||||
queryFn: () =>
|
||||
adminGet<{ comments: AdminComment[]; total: number }>(
|
||||
`/api/admin/website/comments${approvedParam ? `?${approvedParam}` : ""}`
|
||||
),
|
||||
});
|
||||
|
||||
const approveMut = useMutation({
|
||||
mutationFn: (id: string) => adminPatch(`/api/admin/website/comments/${id}/approve`, {}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["admin", "website", "comments"] });
|
||||
notify.success(t("commentApproved"));
|
||||
},
|
||||
onError: () => notify.error(t("errorGeneric")),
|
||||
});
|
||||
|
||||
const rejectMut = useMutation({
|
||||
mutationFn: (id: string) => adminDelete(`/api/admin/website/comments/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["admin", "website", "comments"] });
|
||||
notify.success(t("commentDeleted"));
|
||||
},
|
||||
onError: () => notify.error(t("errorGeneric")),
|
||||
});
|
||||
|
||||
const comments = data?.comments ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-medium">{t("commentsTitle")}</h1>
|
||||
<div className="flex gap-1">
|
||||
{(["all", "pending", "approved"] as const).map((f) => (
|
||||
<Button
|
||||
key={f}
|
||||
size="sm"
|
||||
variant={filter === f ? "default" : "outline"}
|
||||
className="h-8 text-xs"
|
||||
onClick={() => setFilter(f)}
|
||||
>
|
||||
{t(`filterComment_${f}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
||||
) : comments.length === 0 ? (
|
||||
<Card className="rounded-xl border-dashed">
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t("noComments")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{comments.map((c) => (
|
||||
<Card key={c.id} className="rounded-xl border-border/80">
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{c.authorName}</span>
|
||||
{c.authorEmail && (
|
||||
<span className="text-xs text-muted-foreground">{c.authorEmail}</span>
|
||||
)}
|
||||
{c.isApproved ? (
|
||||
<Badge className="shrink-0 bg-green-100 text-green-800 hover:bg-green-100 text-[10px]">
|
||||
{t("approved")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
||||
{t("pending")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("postSlug")}: {c.postSlug} ·{" "}
|
||||
{new Date(c.createdAt).toLocaleDateString("fa-IR")}
|
||||
</p>
|
||||
<p className="mt-2 rounded-lg bg-muted/40 p-2 text-sm leading-relaxed">
|
||||
{c.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-1.5">
|
||||
{!c.isApproved && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 text-green-700 hover:text-green-800"
|
||||
onClick={() => approveMut.mutate(c.id)}
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 text-destructive hover:text-destructive"
|
||||
onClick={() => rejectMut.mutate(c.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Demo Requests ────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
New: "bg-blue-100 text-blue-800",
|
||||
Contacted: "bg-yellow-100 text-yellow-800",
|
||||
DemoScheduled: "bg-purple-100 text-purple-800",
|
||||
Converted: "bg-green-100 text-green-800",
|
||||
Rejected: "bg-red-100 text-red-800",
|
||||
};
|
||||
|
||||
export function AdminDemoRequestsScreen() {
|
||||
const t = useTranslations("admin.website");
|
||||
const qc = useQueryClient();
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "website", "demo-requests", statusFilter],
|
||||
queryFn: () =>
|
||||
adminGet<{ requests: AdminDemoRequest[]; total: number }>(
|
||||
`/api/admin/website/demo-requests${statusFilter ? `?status=${statusFilter}` : ""}`
|
||||
),
|
||||
});
|
||||
|
||||
const updateStatusMut = useMutation({
|
||||
mutationFn: ({ id, status, adminNotes }: { id: string; status: string; adminNotes?: string }) =>
|
||||
adminPatch(`/api/admin/website/demo-requests/${id}/status`, { status, adminNotes }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["admin", "website", "demo-requests"] });
|
||||
notify.success(t("saved"));
|
||||
},
|
||||
onError: () => notify.error(t("errorGeneric")),
|
||||
});
|
||||
|
||||
const requests = data?.requests ?? [];
|
||||
const statuses = ["", "New", "Contacted", "DemoScheduled", "Converted", "Rejected"];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-medium">{t("demoRequestsTitle")}</h1>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="rounded-lg border border-border bg-background px-3 py-1.5 text-sm"
|
||||
>
|
||||
{statuses.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s ? t(`demoStatus_${s}`) : t("allStatuses")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
||||
) : requests.length === 0 ? (
|
||||
<Card className="rounded-xl border-dashed">
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t("noDemoRequests")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{requests.map((req) => (
|
||||
<Card key={req.id} className="rounded-xl border-border/80">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{req.contactName}</span>
|
||||
<Badge
|
||||
className={`shrink-0 text-[10px] ${STATUS_COLORS[req.status] ?? ""} hover:opacity-80`}
|
||||
>
|
||||
{t(`demoStatus_${req.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Building2 className="size-3" />
|
||||
{req.businessName}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Phone className="size-3" />
|
||||
{req.phone}
|
||||
</span>
|
||||
{req.email && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Mail className="size-3" />
|
||||
{req.email}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{new Date(req.createdAt).toLocaleDateString("fa-IR")}
|
||||
</span>
|
||||
</div>
|
||||
{req.notes && (
|
||||
<p className="mt-2 rounded-lg bg-muted/40 p-2 text-xs">{req.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<select
|
||||
value={req.status}
|
||||
onChange={(e) =>
|
||||
updateStatusMut.mutate({ id: req.id, status: e.target.value })
|
||||
}
|
||||
className="rounded-lg border border-border bg-background px-2 py-1 text-xs"
|
||||
>
|
||||
{["New", "Contacted", "DemoScheduled", "Converted", "Rejected"].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`demoStatus_${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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,26 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { ConfirmProvider } from "@/components/providers/confirm-provider";
|
||||
import { MeeziToaster } from "@/components/ui/meezi-toaster";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 30_000, retry: 1 },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfirmProvider>
|
||||
{children}
|
||||
<MeeziToaster />
|
||||
</ConfirmProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ConfirmOptions = {
|
||||
title?: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: "default" | "destructive";
|
||||
};
|
||||
|
||||
type ConfirmContextValue = {
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
|
||||
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const t = useTranslations("confirm");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [options, setOptions] = useState<ConfirmOptions | null>(null);
|
||||
const resolveRef = useRef<((value: boolean) => void) | null>(null);
|
||||
|
||||
const confirm = useCallback((opts: ConfirmOptions) => {
|
||||
setOptions(opts);
|
||||
setOpen(true);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveRef.current = resolve;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const finish = useCallback((value: boolean) => {
|
||||
setOpen(false);
|
||||
resolveRef.current?.(value);
|
||||
resolveRef.current = null;
|
||||
setTimeout(() => setOptions(null), 200);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ confirm }), [confirm]);
|
||||
|
||||
const isDestructive = options?.variant === "destructive";
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={value}>
|
||||
{children}
|
||||
<AlertDialog open={open} onOpenChange={(next) => !next && finish(false)}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<div className="flex items-start gap-3 sm:text-start">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
|
||||
isDestructive ? "bg-red-50 text-[#A32D2D]" : "bg-[#E1F5EE] text-[#0F6E56]"
|
||||
)}
|
||||
>
|
||||
<TriangleAlert className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0 space-y-1.5 pt-0.5">
|
||||
<AlertDialogTitle>
|
||||
{options?.title ?? t("title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>{options?.description}</AlertDialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="sm:justify-end">
|
||||
<AlertDialogCancel onClick={() => finish(false)}>
|
||||
{options?.cancelLabel ?? t("cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={cn(
|
||||
isDestructive &&
|
||||
"bg-destructive text-destructive-foreground hover:opacity-90"
|
||||
)}
|
||||
onClick={() => finish(true)}
|
||||
>
|
||||
{options?.confirmLabel ?? t("confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
const ctx = useContext(ConfirmContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useConfirm must be used within ConfirmProvider");
|
||||
}
|
||||
return ctx.confirm;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TicketStatus =
|
||||
| "Open"
|
||||
| "InProgress"
|
||||
| "WaitingMerchant"
|
||||
| "Resolved"
|
||||
| "Closed"
|
||||
| string;
|
||||
|
||||
export function isTicketClosed(status: TicketStatus): boolean {
|
||||
return status === "Closed" || status === "Resolved";
|
||||
}
|
||||
|
||||
export function TicketStatusBadge({
|
||||
status,
|
||||
className,
|
||||
}: {
|
||||
status: TicketStatus;
|
||||
className?: string;
|
||||
}) {
|
||||
const t = useTranslations("support.status");
|
||||
|
||||
const label = (() => {
|
||||
switch (status) {
|
||||
case "Open":
|
||||
return t("open");
|
||||
case "InProgress":
|
||||
return t("inProgress");
|
||||
case "WaitingMerchant":
|
||||
return t("waitingMerchant");
|
||||
case "Resolved":
|
||||
return t("resolved");
|
||||
case "Closed":
|
||||
return t("closed");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
})();
|
||||
|
||||
const styles = (() => {
|
||||
switch (status) {
|
||||
case "Open":
|
||||
return "bg-amber-100 text-amber-900 border-amber-200";
|
||||
case "InProgress":
|
||||
return "bg-blue-100 text-blue-900 border-blue-200";
|
||||
case "WaitingMerchant":
|
||||
return "bg-[#E1F5EE] text-[#0F6E56] border-[#0F6E56]/20";
|
||||
case "Resolved":
|
||||
return "bg-muted text-muted-foreground";
|
||||
case "Closed":
|
||||
return "bg-muted text-muted-foreground";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={cn("border font-normal", styles, className)}>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/45 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border border-border/80 bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col gap-2 text-center sm:text-start", className)} {...props} />
|
||||
);
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-base font-medium leading-snug text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: "outline" }), "mt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative flex w-full gap-3 rounded-xl border px-4 py-3 text-sm shadow-sm [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-border/80 bg-card text-foreground [&>svg]:text-muted-foreground",
|
||||
info: "border-[#0C447C]/25 bg-[#0C447C]/5 text-[#0C447C] [&>svg]:text-[#0C447C]",
|
||||
success:
|
||||
"border-[#0F6E56]/25 bg-[#E1F5EE] text-[#0F6E56] [&>svg]:text-[#0F6E56]",
|
||||
warning:
|
||||
"border-[#BA7517]/30 bg-amber-50 text-[#BA7517] [&>svg]:text-[#BA7517]",
|
||||
destructive:
|
||||
"border-[#A32D2D]/25 bg-red-50 text-[#A32D2D] [&>svg]:text-[#A32D2D]",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
}
|
||||
);
|
||||
|
||||
const iconByVariant = {
|
||||
default: Info,
|
||||
info: Info,
|
||||
success: CheckCircle2,
|
||||
warning: TriangleAlert,
|
||||
destructive: AlertCircle,
|
||||
} as const;
|
||||
|
||||
export interface AlertProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof alertVariants> {
|
||||
title?: string;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
||||
({ className, variant = "default", title, onDismiss, children, ...props }, ref) => {
|
||||
const Icon = iconByVariant[variant ?? "default"];
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
<Icon className="mt-0.5 h-4 w-4" aria-hidden />
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
{title ? <p className="font-medium leading-snug">{title}</p> : null}
|
||||
{children ? (
|
||||
<div className={cn("text-[13px] leading-relaxed opacity-95", title && "opacity-90")}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{onDismiss ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="absolute end-2 top-2 rounded-md p-1 opacity-60 transition hover:bg-black/5 hover:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
export { Alert, alertVariants };
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
export function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:opacity-90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:opacity-90",
|
||||
outline: "border border-input bg-background hover:bg-accent",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:opacity-90",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
);
|
||||
|
||||
const CardTitle = ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h3
|
||||
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const CardContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("p-6 pt-0", className)} {...props} />
|
||||
);
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardContent };
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-card p-1 text-foreground shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-[11px] font-medium leading-none text-muted-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type LabeledFieldProps = {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export function LabeledField({ label, htmlFor, children, className, hint }: LabeledFieldProps) {
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<Label htmlFor={htmlFor}>{label}</Label>
|
||||
{children}
|
||||
{hint ? <p className="text-[10px] text-muted-foreground">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useLocale } from "next-intl";
|
||||
import { Toaster } from "sonner";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
Loader2,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function iconWrap(className: string, icon: ReactNode) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MeeziToaster() {
|
||||
const locale = useLocale();
|
||||
const isRtl = locale !== "en";
|
||||
const isEn = locale === "en";
|
||||
|
||||
const fontClass = isEn
|
||||
? "font-[family-name:var(--font-inter)]"
|
||||
: "font-[family-name:var(--font-vazirmatn)]";
|
||||
|
||||
const toastBase = cn(
|
||||
"group relative flex w-[min(calc(100vw-2rem),400px)] items-start gap-3 overflow-hidden",
|
||||
"rounded-xl border border-border/60 bg-card/95 py-3.5 ps-3.5 pe-10",
|
||||
"shadow-[0_10px_40px_-8px_rgba(15,23,42,0.16)] backdrop-blur-md",
|
||||
"transition-[transform,opacity] duration-200",
|
||||
fontClass
|
||||
);
|
||||
|
||||
const titleClass = "text-[13px] font-semibold leading-snug tracking-tight text-foreground";
|
||||
const descriptionClass = "text-xs leading-relaxed text-muted-foreground";
|
||||
|
||||
return (
|
||||
<Toaster
|
||||
dir={isRtl ? "rtl" : "ltr"}
|
||||
position={isRtl ? "top-left" : "top-right"}
|
||||
closeButton
|
||||
richColors={false}
|
||||
expand
|
||||
gap={12}
|
||||
offset={20}
|
||||
visibleToasts={4}
|
||||
className={fontClass}
|
||||
toastOptions={{
|
||||
unstyled: true,
|
||||
style: {
|
||||
fontFamily: isEn
|
||||
? "var(--font-inter), system-ui, sans-serif"
|
||||
: "var(--font-vazirmatn), system-ui, sans-serif",
|
||||
},
|
||||
classNames: {
|
||||
toast: toastBase,
|
||||
title: titleClass,
|
||||
description: descriptionClass,
|
||||
content: "flex flex-1 flex-col gap-0.5 min-w-0",
|
||||
closeButton: cn(
|
||||
"absolute end-2.5 top-2.5 flex h-7 w-7 items-center justify-center rounded-lg",
|
||||
"border-0 bg-muted/50 text-muted-foreground opacity-80",
|
||||
"transition hover:bg-muted hover:opacity-100",
|
||||
fontClass
|
||||
),
|
||||
actionButton: cn(
|
||||
"rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground",
|
||||
fontClass
|
||||
),
|
||||
cancelButton: cn(
|
||||
"rounded-lg border border-border/80 bg-background px-3 py-1.5 text-xs font-medium",
|
||||
fontClass
|
||||
),
|
||||
success: "border-s-[3px] border-s-[#0F6E56]",
|
||||
error: "border-s-[3px] border-s-[#A32D2D]",
|
||||
warning: "border-s-[3px] border-s-[#BA7517]",
|
||||
info: "border-s-[3px] border-s-[#0C447C]",
|
||||
loading: "border-s-[3px] border-s-primary/40",
|
||||
},
|
||||
}}
|
||||
icons={{
|
||||
success: iconWrap(
|
||||
"bg-[#E1F5EE]",
|
||||
<CheckCircle2 className="h-4 w-4 text-[#0F6E56]" strokeWidth={2.25} />
|
||||
),
|
||||
error: iconWrap(
|
||||
"bg-red-50",
|
||||
<AlertCircle className="h-4 w-4 text-[#A32D2D]" strokeWidth={2.25} />
|
||||
),
|
||||
warning: iconWrap(
|
||||
"bg-amber-50",
|
||||
<TriangleAlert className="h-4 w-4 text-[#BA7517]" strokeWidth={2.25} />
|
||||
),
|
||||
info: iconWrap(
|
||||
"bg-[#0C447C]/10",
|
||||
<Info className="h-4 w-4 text-[#0C447C]" strokeWidth={2.25} />
|
||||
),
|
||||
loading: iconWrap(
|
||||
"bg-primary/10",
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" strokeWidth={2.25} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
import { routing } from "./routing";
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
let locale = await requestLocale;
|
||||
if (!locale || !routing.locales.includes(locale as "fa" | "ar" | "en")) {
|
||||
locale = routing.defaultLocale;
|
||||
}
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineRouting } from "next-intl/routing";
|
||||
import { createNavigation } from "next-intl/navigation";
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ["fa", "ar", "en"],
|
||||
defaultLocale: "fa",
|
||||
});
|
||||
|
||||
export const { Link, redirect, usePathname, useRouter } =
|
||||
createNavigation(routing);
|
||||
@@ -0,0 +1,85 @@
|
||||
import axios, { type AxiosError } from "axios";
|
||||
import type { ApiResponse } from "./types";
|
||||
|
||||
const baseURL =
|
||||
process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "http://localhost:5081";
|
||||
|
||||
export const adminApi = axios.create({
|
||||
baseURL,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
adminApi.interceptors.request.use((config) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const token = localStorage.getItem("meezi_admin_access_token");
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
adminApi.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError<ApiResponse<unknown>>) => {
|
||||
const apiError = error.response?.data?.error;
|
||||
if (apiError?.code) {
|
||||
return Promise.reject(new AdminApiClientError(apiError.code, apiError.message));
|
||||
}
|
||||
if (error.response?.status === 401 && typeof window !== "undefined") {
|
||||
localStorage.removeItem("meezi_admin_access_token");
|
||||
localStorage.removeItem("meezi_admin_refresh_token");
|
||||
localStorage.removeItem("meezi_admin_auth");
|
||||
const locale = window.location.pathname.split("/")[1] ?? "fa";
|
||||
window.location.href = `/${locale}/admin/login`;
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export class AdminApiClientError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AdminApiClientError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function adminGet<T>(url: string): Promise<T> {
|
||||
const { data } = await adminApi.get<ApiResponse<T>>(url);
|
||||
if (!data.success || data.data === undefined) {
|
||||
throw new Error(data.error?.message ?? "Request failed");
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function adminPost<T>(url: string, body?: unknown): Promise<T> {
|
||||
const { data } = await adminApi.post<ApiResponse<T>>(url, body);
|
||||
if (!data.success || data.data === undefined) {
|
||||
throw new Error(data.error?.message ?? "Request failed");
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function adminPut<T>(url: string, body: unknown): Promise<T> {
|
||||
const { data } = await adminApi.put<ApiResponse<T>>(url, body);
|
||||
if (!data.success || data.data === undefined) {
|
||||
throw new Error(data.error?.message ?? "Request failed");
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function adminPatch<T>(url: string, body: unknown): Promise<T> {
|
||||
const { data } = await adminApi.patch<ApiResponse<T>>(url, body);
|
||||
if (!data.success || data.data === undefined) {
|
||||
throw new Error(data.error?.message ?? "Request failed");
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function adminDelete(url: string): Promise<void> {
|
||||
const { data } = await adminApi.delete<ApiResponse<unknown>>(url);
|
||||
if (!data.success) {
|
||||
throw new Error(data.error?.message ?? "Request failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
export type AdminStats = {
|
||||
totalCafes: number;
|
||||
activeCafes: number;
|
||||
suspendedCafes: number;
|
||||
openTickets: number;
|
||||
plansConfigured: number;
|
||||
};
|
||||
|
||||
export type PlanLimitsData = {
|
||||
maxOrdersPerDay: number;
|
||||
maxTerminals: number;
|
||||
maxCustomers: number;
|
||||
maxSmsPerMonth: number;
|
||||
maxBranches: number;
|
||||
maxReportHistoryDays: number;
|
||||
};
|
||||
|
||||
export type AdminPlan = {
|
||||
tier: string;
|
||||
displayNameFa: string;
|
||||
displayNameEn?: string | null;
|
||||
monthlyPriceToman: number;
|
||||
isBillableOnline: boolean;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
limits: PlanLimitsData;
|
||||
featureKeys: string[];
|
||||
};
|
||||
|
||||
export type PlatformSetting = {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
category: string;
|
||||
descriptionFa?: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformFeature = {
|
||||
id: string;
|
||||
key: string;
|
||||
displayNameFa: string;
|
||||
displayNameEn?: string | null;
|
||||
moduleGroup: string;
|
||||
isEnabledGlobally: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AdminCafe = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
city: string;
|
||||
planTier: string;
|
||||
planExpiresAt?: string | null;
|
||||
isSuspended: boolean;
|
||||
isVerified: boolean;
|
||||
branchCount: number;
|
||||
employeeCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type SupportTicket = {
|
||||
id: string;
|
||||
cafeId: string;
|
||||
cafeName: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
createdByEmployeeId: string;
|
||||
createdByName?: string | null;
|
||||
assignedAdminId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messageCount: number;
|
||||
};
|
||||
|
||||
export type SupportTicketMessage = {
|
||||
id: string;
|
||||
senderKind: string;
|
||||
senderId: string;
|
||||
senderName?: string | null;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type SupportTicketDetail = {
|
||||
ticket: SupportTicket;
|
||||
messages: SupportTicketMessage[];
|
||||
};
|
||||
|
||||
export type GatewayCredentials = {
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
branchCode?: string | null;
|
||||
terminalCode?: string | null;
|
||||
clientId?: string | null;
|
||||
clientSecret?: string | null;
|
||||
baseUrl?: string | null;
|
||||
hasStoredPassword: boolean;
|
||||
hasStoredClientSecret: boolean;
|
||||
};
|
||||
|
||||
export type PaymentGatewayConfig = {
|
||||
id: string;
|
||||
displayNameFa: string;
|
||||
isEnabled: boolean;
|
||||
isActive: boolean;
|
||||
merchantId?: string | null;
|
||||
apiKey?: string | null;
|
||||
sandbox: boolean;
|
||||
hasStoredSecret: boolean;
|
||||
credentials?: GatewayCredentials | null;
|
||||
};
|
||||
|
||||
export type KavenegarConfig = {
|
||||
isEnabled: boolean;
|
||||
apiKey?: string | null;
|
||||
otpTemplate: string;
|
||||
hasStoredApiKey: boolean;
|
||||
};
|
||||
|
||||
export type OpenAiIntegrationConfig = {
|
||||
isEnabled: boolean;
|
||||
apiKey?: string | null;
|
||||
model: string;
|
||||
coffeeAdvisorEnabled: boolean;
|
||||
hasStoredApiKey: boolean;
|
||||
};
|
||||
|
||||
export type MeshyIntegrationConfig = {
|
||||
isEnabled: boolean;
|
||||
apiKey?: string | null;
|
||||
menu3dEnabled: boolean;
|
||||
hasStoredApiKey: boolean;
|
||||
};
|
||||
|
||||
export type AiIntegrationsConfig = {
|
||||
openAi: OpenAiIntegrationConfig;
|
||||
meshy: MeshyIntegrationConfig;
|
||||
};
|
||||
|
||||
export type PlatformIntegrations = {
|
||||
activePaymentGateway: string;
|
||||
paymentGateways: PaymentGatewayConfig[];
|
||||
kavenegar: KavenegarConfig;
|
||||
ai: AiIntegrationsConfig;
|
||||
};
|
||||
|
||||
export type AdminNotificationRow = {
|
||||
id: string;
|
||||
cafeId: string;
|
||||
cafeName: string;
|
||||
type: string;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type BroadcastResult = {
|
||||
cafeCount: number;
|
||||
notificationCount: number;
|
||||
};
|
||||
|
||||
// ── Website CMS ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type AdminBlogPost = {
|
||||
id: string;
|
||||
slug: string;
|
||||
titleFa: string;
|
||||
titleEn: string;
|
||||
excerptFa: string;
|
||||
excerptEn: string;
|
||||
author: string;
|
||||
categoryFa: string;
|
||||
categoryEn: string;
|
||||
isPublished: boolean;
|
||||
publishedAt?: string | null;
|
||||
viewCount: number;
|
||||
commentCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AdminBlogPostDetail = AdminBlogPost & {
|
||||
contentFa: string;
|
||||
contentEn: string;
|
||||
coverImage?: string | null;
|
||||
tagsJson: string;
|
||||
};
|
||||
|
||||
export type AdminComment = {
|
||||
id: string;
|
||||
postSlug: string;
|
||||
authorName: string;
|
||||
authorEmail?: string | null;
|
||||
content: string;
|
||||
isApproved: boolean;
|
||||
ipAddress?: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type AdminDemoRequest = {
|
||||
id: string;
|
||||
contactName: string;
|
||||
businessName: string;
|
||||
phone: string;
|
||||
email?: string | null;
|
||||
branchCount: string;
|
||||
notes?: string | null;
|
||||
source: string;
|
||||
status: "New" | "Contacted" | "DemoScheduled" | "Converted" | "Rejected";
|
||||
adminNotes?: string | null;
|
||||
contactedAt?: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { apiDelete, apiGet, apiPut } from "@/lib/api/client";
|
||||
import type { MenuItem } from "@/lib/api/types";
|
||||
|
||||
export interface BranchMenuItem extends MenuItem {
|
||||
masterPrice: number;
|
||||
effectivePrice: number;
|
||||
isOverridden: boolean;
|
||||
hasPriceOverride: boolean;
|
||||
}
|
||||
|
||||
export function branchMenuItemToMenuItem(row: BranchMenuItem): MenuItem {
|
||||
return {
|
||||
id: row.id,
|
||||
categoryId: row.categoryId,
|
||||
name: row.name,
|
||||
nameAr: row.nameAr,
|
||||
nameEn: row.nameEn,
|
||||
description: row.description,
|
||||
price: row.effectivePrice,
|
||||
imageUrl: row.imageUrl,
|
||||
videoUrl: row.videoUrl,
|
||||
isAvailable: row.isAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getBranchMenu(
|
||||
cafeId: string,
|
||||
branchId: string,
|
||||
options?: { includeUnavailable?: boolean }
|
||||
): Promise<BranchMenuItem[]> {
|
||||
const qs = options?.includeUnavailable ? "?includeUnavailable=true" : "";
|
||||
return apiGet<BranchMenuItem[]>(
|
||||
`/api/cafes/${cafeId}/branches/${branchId}/menu${qs}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function upsertBranchMenuOverride(
|
||||
cafeId: string,
|
||||
branchId: string,
|
||||
menuItemId: string,
|
||||
body: { isAvailable: boolean; priceOverride: number | null }
|
||||
): Promise<void> {
|
||||
await apiPut(
|
||||
`/api/cafes/${cafeId}/branches/${branchId}/menu/${menuItemId}/override`,
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteBranchMenuOverride(
|
||||
cafeId: string,
|
||||
branchId: string,
|
||||
menuItemId: string
|
||||
): Promise<void> {
|
||||
await apiDelete(
|
||||
`/api/cafes/${cafeId}/branches/${branchId}/menu/${menuItemId}/override`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
||||
import type { TableBoardItem } from "@/lib/api/types";
|
||||
|
||||
export interface TableSectionDto {
|
||||
id: string;
|
||||
branchId: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
tableCount: number;
|
||||
}
|
||||
|
||||
export function branchTablesPath(cafeId: string, branchId: string): string {
|
||||
return `/api/cafes/${cafeId}/branches/${branchId}/tables`;
|
||||
}
|
||||
|
||||
export async function fetchBranchTableBoard(
|
||||
cafeId: string,
|
||||
branchId: string,
|
||||
activeOnly = false
|
||||
): Promise<TableBoardItem[]> {
|
||||
const params = new URLSearchParams({ activeOnly: String(activeOnly) });
|
||||
return apiGet<TableBoardItem[]>(
|
||||
`${branchTablesPath(cafeId, branchId)}/board?${params}`
|
||||
);
|
||||
}
|
||||
|
||||
/** POS + admin board: café-wide endpoint (optional branch filter), with fallback if branch has no rows. */
|
||||
export async function fetchCafeTableBoard(
|
||||
cafeId: string,
|
||||
branchId?: string | null
|
||||
): Promise<TableBoardItem[]> {
|
||||
const params = new URLSearchParams({ activeOnly: "false" });
|
||||
if (branchId) params.set("branchId", branchId);
|
||||
const scoped = await apiGet<TableBoardItem[]>(
|
||||
`/api/cafes/${cafeId}/tables/board?${params}`
|
||||
);
|
||||
if (scoped.length > 0 || !branchId) return scoped;
|
||||
return apiGet<TableBoardItem[]>(
|
||||
`/api/cafes/${cafeId}/tables/board?activeOnly=false`
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchBranchSections(
|
||||
cafeId: string,
|
||||
branchId: string
|
||||
): Promise<TableSectionDto[]> {
|
||||
return apiGet<TableSectionDto[]>(
|
||||
`${branchTablesPath(cafeId, branchId)}/sections`
|
||||
);
|
||||
}
|
||||
|
||||
export async function createBranchTable(
|
||||
cafeId: string,
|
||||
branchId: string,
|
||||
body: {
|
||||
number: string;
|
||||
capacity: number;
|
||||
floor?: string | null;
|
||||
sectionId?: string | null;
|
||||
sortOrder?: number;
|
||||
}
|
||||
): Promise<void> {
|
||||
await apiPost(`${branchTablesPath(cafeId, branchId)}`, body);
|
||||
}
|
||||
|
||||
export async function patchBranchTable(
|
||||
cafeId: string,
|
||||
branchId: string,
|
||||
tableId: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await apiPatch(`${branchTablesPath(cafeId, branchId)}/${tableId}`, body);
|
||||
}
|
||||
|
||||
export async function deleteBranchTable(
|
||||
cafeId: string,
|
||||
branchId: string,
|
||||
tableId: string
|
||||
): Promise<void> {
|
||||
await apiDelete(`${branchTablesPath(cafeId, branchId)}/${tableId}`);
|
||||
}
|
||||
|
||||
export async function setTableCleaning(
|
||||
cafeId: string,
|
||||
tableId: string,
|
||||
isCleaning: boolean,
|
||||
branchId?: string | null
|
||||
): Promise<TableBoardItem> {
|
||||
const body = { isCleaning };
|
||||
if (branchId) {
|
||||
return apiPatch<TableBoardItem>(
|
||||
`${branchTablesPath(cafeId, branchId)}/${tableId}/cleaning`,
|
||||
body
|
||||
);
|
||||
}
|
||||
return apiPatch<TableBoardItem>(
|
||||
`/api/cafes/${cafeId}/tables/${tableId}/cleaning`,
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
export async function createBranchSection(
|
||||
cafeId: string,
|
||||
branchId: string,
|
||||
body: { name: string; sortOrder?: number }
|
||||
): Promise<TableSectionDto> {
|
||||
return apiPost<TableSectionDto>(
|
||||
`${branchTablesPath(cafeId, branchId)}/sections`,
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
export async function patchBranchSection(
|
||||
cafeId: string,
|
||||
branchId: string,
|
||||
sectionId: string,
|
||||
body: { name?: string; sortOrder?: number }
|
||||
): Promise<TableSectionDto> {
|
||||
return apiPatch<TableSectionDto>(
|
||||
`${branchTablesPath(cafeId, branchId)}/sections/${sectionId}`,
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteBranchSection(
|
||||
cafeId: string,
|
||||
branchId: string,
|
||||
sectionId: string
|
||||
): Promise<void> {
|
||||
await apiDelete(
|
||||
`${branchTablesPath(cafeId, branchId)}/sections/${sectionId}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import axios, { type AxiosError } from "axios";
|
||||
import type { ApiResponse } from "./types";
|
||||
import { getOrCreateTerminalId } from "@/lib/terminal";
|
||||
|
||||
const baseURL =
|
||||
process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const token = localStorage.getItem("meezi_access_token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
config.headers["X-Meezi-Terminal-Id"] = getOrCreateTerminalId();
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError<ApiResponse<unknown>>) => {
|
||||
const apiError = error.response?.data?.error;
|
||||
if (apiError?.code) {
|
||||
return Promise.reject(new ApiClientError(apiError.code, apiError.message));
|
||||
}
|
||||
if (error.response?.status === 401 && typeof window !== "undefined") {
|
||||
const path = window.location.pathname;
|
||||
const isPublicGuest = path.startsWith("/q/") || path.startsWith("/q");
|
||||
const isAdmin = path.includes("/admin");
|
||||
if (!isPublicGuest && !isAdmin) {
|
||||
localStorage.removeItem("meezi_access_token");
|
||||
localStorage.removeItem("meezi_refresh_token");
|
||||
localStorage.removeItem("meezi_auth");
|
||||
const locale = path.split("/")[1] ?? "fa";
|
||||
window.location.href = `/${locale}/login`;
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export interface PagedMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface PagedApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T[];
|
||||
meta?: PagedMeta;
|
||||
error?: { code: string; message: string; field?: string };
|
||||
}
|
||||
|
||||
export async function apiGet<T>(url: string): Promise<T> {
|
||||
const { data } = await api.get<ApiResponse<T>>(url);
|
||||
if (!data.success || data.data === undefined) {
|
||||
throw new Error(data.error?.message ?? "Request failed");
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function apiGetPaged<T>(url: string): Promise<{ items: T[]; meta: PagedMeta }> {
|
||||
const { data } = await api.get<PagedApiResponse<T>>(url);
|
||||
if (!data.success || data.data === undefined || !data.meta) {
|
||||
throw new Error(data.error?.message ?? "Request failed");
|
||||
}
|
||||
return { items: data.data, meta: data.meta };
|
||||
}
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiClientError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiPost<T, B = unknown>(url: string, body?: B): Promise<T> {
|
||||
const { data } = await api.post<ApiResponse<T>>(url, body);
|
||||
if (!data.success || data.data === undefined) {
|
||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function apiPut<T, B = unknown>(url: string, body?: B): Promise<T> {
|
||||
const { data } = await api.put<ApiResponse<T>>(url, body);
|
||||
if (!data.success || data.data === undefined) {
|
||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function apiPatch<T, B = unknown>(url: string, body?: B): Promise<T> {
|
||||
const { data } = await api.patch<ApiResponse<T>>(url, body);
|
||||
if (!data.success || data.data === undefined) {
|
||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function apiDelete(url: string): Promise<void> {
|
||||
const { data } = await api.delete<ApiResponse<unknown>>(url);
|
||||
if (!data.success) {
|
||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** GET binary response (QR PNG, Excel export, etc.) with auth headers. */
|
||||
export async function apiGetBlob(path: string): Promise<Blob> {
|
||||
const response = await api.get(path, { responseType: "blob" });
|
||||
return response.data as Blob;
|
||||
}
|
||||
|
||||
/** Public GET JSON (no auth required). */
|
||||
export async function apiGetPublic<T>(path: string): Promise<T> {
|
||||
const { data } = await api.get<ApiResponse<T>>(path);
|
||||
if (!data.success || data.data === undefined) {
|
||||
throw new ApiClientError(
|
||||
data.error?.code ?? "REQUEST_FAILED",
|
||||
data.error?.message ?? "Request failed"
|
||||
);
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export function openBlobInNewTab(blob: Blob): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
|
||||
export async function apiDownload(path: string, filename: string): Promise<void> {
|
||||
const blob = await apiGetBlob(path);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
export async function apiUpload<T>(url: string, file: File): Promise<T> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const { data } = await api.post<ApiResponse<T>>(url, form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
if (!data.success || data.data === undefined) {
|
||||
throw new Error(data.error?.message ?? "Upload failed");
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export function resolveMediaUrl(path?: string | null): string | undefined {
|
||||
if (!path) return undefined;
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) return path;
|
||||
const base = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5080";
|
||||
return `${base.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** @deprecated Import from `@/lib/api/client` instead. */
|
||||
export { apiDownload } from "@/lib/api/client";
|
||||
@@ -0,0 +1,40 @@
|
||||
import { apiGet, apiPost } from "@/lib/api/client";
|
||||
|
||||
export type CafeNotification = {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
referenceId?: string | null;
|
||||
tableNumber?: string | null;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type NotificationList = {
|
||||
items: CafeNotification[];
|
||||
unreadCount: number;
|
||||
};
|
||||
|
||||
export async function fetchNotifications(
|
||||
cafeId: string,
|
||||
unreadOnly = false
|
||||
): Promise<NotificationList> {
|
||||
return apiGet<NotificationList>(
|
||||
`/api/cafes/${cafeId}/notifications?unreadOnly=${unreadOnly}&limit=50`
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchUnreadCount(cafeId: string): Promise<number> {
|
||||
const data = await apiGet<{ count: number }>(
|
||||
`/api/cafes/${cafeId}/notifications/unread-count`
|
||||
);
|
||||
return data.count;
|
||||
}
|
||||
|
||||
export async function markNotificationsRead(
|
||||
cafeId: string,
|
||||
body: { ids?: string[]; all?: boolean }
|
||||
): Promise<void> {
|
||||
await apiPost(`/api/cafes/${cafeId}/notifications/read`, body);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { apiPost, ApiClientError } from "@/lib/api/client";
|
||||
|
||||
export type PosPaymentRequestResult = {
|
||||
sent: boolean;
|
||||
skipped: boolean;
|
||||
message?: string | null;
|
||||
};
|
||||
|
||||
export async function requestPosPayment(
|
||||
cafeId: string,
|
||||
branchId: string,
|
||||
orderId: string,
|
||||
amount: number
|
||||
): Promise<PosPaymentRequestResult> {
|
||||
return apiPost<PosPaymentRequestResult>(
|
||||
`/api/cafes/${cafeId}/branches/${branchId}/pos-device/payment-request`,
|
||||
{ orderId, amount }
|
||||
);
|
||||
}
|
||||
|
||||
export function posDeviceErrorMessage(
|
||||
err: unknown,
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
if (err instanceof ApiClientError) {
|
||||
if (err.code === "POS_DEVICE_NOT_CONFIGURED") return t("posDeviceNotConfigured");
|
||||
if (err.code === "POS_DEVICE_CONNECTION_FAILED") return t("posDeviceConnectionFailed");
|
||||
if (err.code === "POS_DEVICE_TIMEOUT") return t("posDeviceTimeout");
|
||||
if (err.code === "POS_DEVICE_REJECTED") return t("posDeviceRejected");
|
||||
if (err.code.startsWith("POS_DEVICE")) return t("posDeviceError");
|
||||
}
|
||||
return t("posDeviceError");
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { apiPost, ApiClientError } from "@/lib/api/client";
|
||||
|
||||
export async function printReceipt(cafeId: string, orderId: string): Promise<void> {
|
||||
await apiPost(`/api/cafes/${cafeId}/print/receipt/${orderId}`, {});
|
||||
}
|
||||
|
||||
export async function printKitchen(cafeId: string, orderId: string): Promise<void> {
|
||||
await apiPost(`/api/cafes/${cafeId}/print/kitchen/${orderId}`, {});
|
||||
}
|
||||
|
||||
export async function testPrinter(
|
||||
cafeId: string,
|
||||
printerIp: string,
|
||||
port: number
|
||||
): Promise<void> {
|
||||
await apiPost(`/api/cafes/${cafeId}/print/test`, { printerIp, port });
|
||||
}
|
||||
|
||||
export function printErrorMessage(err: unknown, t: (key: string) => string): string {
|
||||
if (err instanceof ApiClientError) {
|
||||
if (err.code === "PRINTER_NOT_CONFIGURED" || err.code === "KITCHEN_PRINTER_NOT_CONFIGURED")
|
||||
return t("notConfigured");
|
||||
if (err.code === "PRINTER_CONNECTION_FAILED") return t("connectionFailed");
|
||||
}
|
||||
return t("connectionFailed");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { apiGetPublic } from "@/lib/api/client";
|
||||
import type { CafeDiscoverProfile } from "@/lib/cafe-discover-profile";
|
||||
|
||||
export type PublicCafeDiscover = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
city: string | null;
|
||||
address: string | null;
|
||||
logoUrl: string | null;
|
||||
coverImageUrl: string | null;
|
||||
isVerified: boolean;
|
||||
averageRating: number;
|
||||
reviewCount: number;
|
||||
discoverProfile: CafeDiscoverProfile;
|
||||
};
|
||||
|
||||
export type DiscoverTaxonomy = {
|
||||
themes: string[];
|
||||
sizes: string[];
|
||||
floors: string[];
|
||||
vibes: string[];
|
||||
occasions: string[];
|
||||
spaceFeatures: string[];
|
||||
noiseLevels: string[];
|
||||
priceTiers: string[];
|
||||
};
|
||||
|
||||
export type DiscoverSearchParams = {
|
||||
city?: string;
|
||||
q?: string;
|
||||
minRating?: number;
|
||||
sort?: string;
|
||||
themes?: string[];
|
||||
vibes?: string[];
|
||||
occasions?: string[];
|
||||
spaceFeatures?: string[];
|
||||
noise?: string;
|
||||
priceTier?: string;
|
||||
size?: string;
|
||||
requireProfile?: boolean;
|
||||
};
|
||||
|
||||
function toQuery(params: DiscoverSearchParams): string {
|
||||
const q = new URLSearchParams();
|
||||
if (params.city) q.set("city", params.city);
|
||||
if (params.q) q.set("q", params.q);
|
||||
if (params.minRating != null) q.set("minRating", String(params.minRating));
|
||||
if (params.sort) q.set("sort", params.sort);
|
||||
if (params.themes?.length) q.set("themes", params.themes.join(","));
|
||||
if (params.vibes?.length) q.set("vibes", params.vibes.join(","));
|
||||
if (params.occasions?.length) q.set("occasions", params.occasions.join(","));
|
||||
if (params.spaceFeatures?.length) q.set("spaceFeatures", params.spaceFeatures.join(","));
|
||||
if (params.noise) q.set("noise", params.noise);
|
||||
if (params.priceTier) q.set("priceTier", params.priceTier);
|
||||
if (params.size) q.set("size", params.size);
|
||||
if (params.requireProfile !== false) q.set("requireProfile", "true");
|
||||
const s = q.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
export async function fetchPublicDiscover(
|
||||
params: DiscoverSearchParams
|
||||
): Promise<PublicCafeDiscover[]> {
|
||||
return apiGetPublic<PublicCafeDiscover[]>(`/api/public/discover${toQuery(params)}`);
|
||||
}
|
||||
|
||||
export async function fetchDiscoverTaxonomy(): Promise<DiscoverTaxonomy> {
|
||||
return apiGetPublic<DiscoverTaxonomy>("/api/public/discover-profile/taxonomy");
|
||||
}
|
||||
|
||||
export async function fetchPublicCafe(slug: string) {
|
||||
return apiGetPublic<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
city: string | null;
|
||||
address: string | null;
|
||||
logoUrl: string | null;
|
||||
coverImageUrl: string | null;
|
||||
description: string | null;
|
||||
averageRating: number;
|
||||
reviewCount: number;
|
||||
discoverProfile: CafeDiscoverProfile;
|
||||
}>(`/api/public/cafes/${encodeURIComponent(slug)}`);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { apiGetPublic, ApiClientError } from "@/lib/api/client";
|
||||
import type { ApiResponse } from "@/lib/api/types";
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { CafeTheme } from "@/lib/cafe-theme";
|
||||
|
||||
export type QrResolve = {
|
||||
cafeId: string;
|
||||
cafeSlug: string;
|
||||
tableId: string;
|
||||
tableNumber: string;
|
||||
tableName: string;
|
||||
branchId: string;
|
||||
branchName: string;
|
||||
cafeName: string;
|
||||
primaryColor: string;
|
||||
logoUrl?: string | null;
|
||||
welcomeText: string;
|
||||
wifiPassword?: string | null;
|
||||
address?: string | null;
|
||||
isCleaning: boolean;
|
||||
};
|
||||
|
||||
export type QrPublicMenuItem = {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
name: string;
|
||||
nameAr?: string | null;
|
||||
nameEn?: string | null;
|
||||
description?: string | null;
|
||||
price: number;
|
||||
discountPercent: number;
|
||||
imageUrl?: string | null;
|
||||
videoUrl?: string | null;
|
||||
model3dUrl?: string | null;
|
||||
isAvailable: boolean;
|
||||
};
|
||||
|
||||
export type QrPublicMenuCategory = {
|
||||
id: string;
|
||||
name: string;
|
||||
nameAr?: string | null;
|
||||
nameEn?: string | null;
|
||||
icon?: string | null;
|
||||
iconPresetId?: string | null;
|
||||
iconStyle?: string | null;
|
||||
imageUrl?: string | null;
|
||||
items: QrPublicMenuItem[];
|
||||
};
|
||||
|
||||
export type QrPublicMenu = {
|
||||
cafeId: string;
|
||||
cafeName: string;
|
||||
slug: string;
|
||||
theme?: CafeTheme | null;
|
||||
categories: QrPublicMenuCategory[];
|
||||
};
|
||||
|
||||
export type QrCartLine = {
|
||||
item: QrPublicMenuItem;
|
||||
qty: number;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type QrOrderPlaced = {
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
totalAmount: number;
|
||||
itemCount: number;
|
||||
status: string;
|
||||
trackingToken: string;
|
||||
};
|
||||
|
||||
export type QrOrderTrackStep = {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
isComplete: boolean;
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
export type QrOrderTrack = {
|
||||
id: string;
|
||||
orderNumber: string;
|
||||
status: string;
|
||||
statusLabelKey: string;
|
||||
total: number;
|
||||
tableNumber?: string | null;
|
||||
createdAt: string;
|
||||
statusUpdatedAt: string;
|
||||
trackingToken: string;
|
||||
steps: QrOrderTrackStep[];
|
||||
};
|
||||
|
||||
export async function resolveQrCode(code: string): Promise<QrResolve> {
|
||||
return apiGetPublic<QrResolve>(`/api/q/${encodeURIComponent(code)}`);
|
||||
}
|
||||
|
||||
export async function fetchBranchPublicMenu(
|
||||
cafeId: string,
|
||||
branchId: string
|
||||
): Promise<QrPublicMenu> {
|
||||
return apiGetPublic<QrPublicMenu>(
|
||||
`/api/public/${cafeId}/branches/${branchId}/menu`
|
||||
);
|
||||
}
|
||||
|
||||
export type PublicSecurityConfig = {
|
||||
abuseProtectionEnabled: boolean;
|
||||
turnstileSiteKey: string | null;
|
||||
captchaRequired: boolean;
|
||||
};
|
||||
|
||||
export async function fetchPublicSecurityConfig(): Promise<PublicSecurityConfig> {
|
||||
return apiGetPublic<PublicSecurityConfig>("/api/public/security-config");
|
||||
}
|
||||
|
||||
export async function placeBranchGuestOrder(
|
||||
cafeId: string,
|
||||
branchId: string,
|
||||
body: {
|
||||
tableId: string;
|
||||
guestName?: string | null;
|
||||
guestPhone?: string | null;
|
||||
items: { menuItemId: string; quantity: number; notes?: string | null }[];
|
||||
captchaToken?: string | null;
|
||||
}
|
||||
): Promise<QrOrderPlaced> {
|
||||
const { data } = await api.post<ApiResponse<QrOrderPlaced>>(
|
||||
`/api/public/${cafeId}/branches/${branchId}/orders`,
|
||||
body
|
||||
);
|
||||
if (!data.success || data.data === undefined) {
|
||||
throw new ApiClientError(
|
||||
data.error?.code ?? "REQUEST_FAILED",
|
||||
data.error?.message ?? "Request failed"
|
||||
);
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function fetchOrderTrack(
|
||||
orderId: string,
|
||||
trackingToken: string
|
||||
): Promise<QrOrderTrack> {
|
||||
return apiGetPublic<QrOrderTrack>(
|
||||
`/api/public/orders/${encodeURIComponent(orderId)}/track?token=${encodeURIComponent(trackingToken)}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: { code: string; message: string; field?: string };
|
||||
}
|
||||
|
||||
export interface AuthTokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: string;
|
||||
userId: string;
|
||||
cafeId: string;
|
||||
role: string;
|
||||
planTier: string;
|
||||
language: string;
|
||||
actor?: string;
|
||||
branchId?: string | null;
|
||||
}
|
||||
|
||||
export interface MenuCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
nameAr?: string;
|
||||
nameEn?: string;
|
||||
sortOrder: number;
|
||||
taxId?: string;
|
||||
discountPercent: number;
|
||||
icon?: string;
|
||||
iconPresetId?: string;
|
||||
iconStyle?: string;
|
||||
imageUrl?: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
name: string;
|
||||
nameAr?: string;
|
||||
nameEn?: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
imageUrl?: string;
|
||||
videoUrl?: string;
|
||||
model3dUrl?: string;
|
||||
isAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface OrderItemLine {
|
||||
id: string;
|
||||
menuItemId: string;
|
||||
menuItemName: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
notes?: string;
|
||||
isVoided?: boolean;
|
||||
voidedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PaymentLine {
|
||||
id: string;
|
||||
method: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
reference?: string;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
cafeId: string;
|
||||
branchId?: string;
|
||||
tableId?: string;
|
||||
tableNumber?: string;
|
||||
guestName?: string;
|
||||
guestPhone?: string;
|
||||
customerName?: string;
|
||||
customerPhone?: string;
|
||||
customerId?: string;
|
||||
employeeId?: string;
|
||||
orderType: string;
|
||||
status: string;
|
||||
subtotal: number;
|
||||
taxTotal: number;
|
||||
discountAmount: number;
|
||||
total: number;
|
||||
paidAmount: number;
|
||||
createdAt: string;
|
||||
items: OrderItemLine[];
|
||||
payments: PaymentLine[];
|
||||
}
|
||||
|
||||
export type TableBoardStatus = "Free" | "Busy" | "Reserved" | "Cleaning";
|
||||
|
||||
export interface TableBoardItem {
|
||||
id: string;
|
||||
branchId: string;
|
||||
sectionId?: string | null;
|
||||
sectionName?: string | null;
|
||||
sortOrder?: number;
|
||||
number: string;
|
||||
capacity: number;
|
||||
floor?: string;
|
||||
qrCode: string;
|
||||
qrCodeUrl: string;
|
||||
imageUrl?: string;
|
||||
videoUrl?: string;
|
||||
isActive: boolean;
|
||||
isCleaning: boolean;
|
||||
status: TableBoardStatus;
|
||||
currentOrder?: {
|
||||
orderId: string;
|
||||
status: string;
|
||||
total: number;
|
||||
guestLabel?: string;
|
||||
source?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LiveOrder {
|
||||
id: string;
|
||||
status: string;
|
||||
tableNumber?: number;
|
||||
orderType: string;
|
||||
total: number;
|
||||
createdAt: string;
|
||||
items: OrderItemLine[];
|
||||
}
|
||||
|
||||
export type CustomerGroup = "Regular" | "Vip" | "New" | "Employee";
|
||||
export type CouponType = "Percentage" | "FixedAmount" | "FreeItem";
|
||||
|
||||
export interface Customer {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
nationalId?: string;
|
||||
birthDateJalali?: string;
|
||||
group: CustomerGroup;
|
||||
loyaltyPoints: number;
|
||||
referredBy?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Coupon {
|
||||
id: string;
|
||||
code: string;
|
||||
type: CouponType;
|
||||
value: number;
|
||||
minOrderAmount?: number;
|
||||
maxDiscount?: number;
|
||||
usageLimit?: number;
|
||||
usedCount: number;
|
||||
targetGroup?: CustomerGroup;
|
||||
startsAt?: string;
|
||||
expiresAt?: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface SmsUsage {
|
||||
usedThisMonth: number;
|
||||
monthlyLimit: number;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export interface SmsCampaignResult {
|
||||
sentCount: number;
|
||||
failedCount: number;
|
||||
}
|
||||
|
||||
export interface Table {
|
||||
id: string;
|
||||
branchId?: string;
|
||||
number: string;
|
||||
capacity: number;
|
||||
floor?: string;
|
||||
qrCode: string;
|
||||
qrUrl: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export type QueueTicketStatus = "Waiting" | "Called" | "Done" | "Cancelled";
|
||||
|
||||
export interface QueueTicket {
|
||||
id: string;
|
||||
branchId?: string;
|
||||
serviceDate: string;
|
||||
number: number;
|
||||
customerLabel?: string;
|
||||
orderId?: string;
|
||||
status: QueueTicketStatus;
|
||||
issuedAt: string;
|
||||
}
|
||||
|
||||
export interface QueueBoard {
|
||||
serviceDate: string;
|
||||
nowServing?: number | null;
|
||||
lastIssued: number;
|
||||
waitingCount: number;
|
||||
tickets: QueueTicket[];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Cafe owner (HQ) — billing, taxes, branches. */
|
||||
export function isCafeOwner(role: string | undefined): boolean {
|
||||
return role === "Owner";
|
||||
}
|
||||
|
||||
/** Logged in as a branch-scoped employee (JWT branchId). */
|
||||
export function isBranchAccount(branchId: string | null | undefined): boolean {
|
||||
return !!branchId;
|
||||
}
|
||||
|
||||
export const OWNER_ONLY_NAV_KEYS = ["subscription", "taxes", "branches"] as const;
|
||||
|
||||
export function canSeeNavItem(
|
||||
key: string,
|
||||
role: string | undefined,
|
||||
branchId: string | null | undefined
|
||||
): boolean {
|
||||
if ((OWNER_ONLY_NAV_KEYS as readonly string[]).includes(key) && !isCafeOwner(role)) {
|
||||
return false;
|
||||
}
|
||||
if (key === "branches" && isBranchAccount(branchId)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/** Matches backend CafeDiscoverProfileKeys — labels via i18n discoverProfile.* */
|
||||
|
||||
export type CafeDiscoverProfile = {
|
||||
themes: string[];
|
||||
size: string | null;
|
||||
floors: string | null;
|
||||
vibes: string[];
|
||||
occasions: string[];
|
||||
spaceFeatures: string[];
|
||||
noiseLevel: string | null;
|
||||
priceTier: string | null;
|
||||
};
|
||||
|
||||
export const EMPTY_DISCOVER_PROFILE: CafeDiscoverProfile = {
|
||||
themes: [],
|
||||
size: null,
|
||||
floors: null,
|
||||
vibes: [],
|
||||
occasions: [],
|
||||
spaceFeatures: [],
|
||||
noiseLevel: null,
|
||||
priceTier: null,
|
||||
};
|
||||
|
||||
export const DISCOVER_TAXONOMY = {
|
||||
themes: [
|
||||
"modern",
|
||||
"minimal",
|
||||
"vintage",
|
||||
"industrial",
|
||||
"scandi",
|
||||
"persian_traditional",
|
||||
"book_cafe",
|
||||
"roastery",
|
||||
"dessert_focus",
|
||||
"brunch",
|
||||
"late_night",
|
||||
"plants_heavy",
|
||||
"instagrammable",
|
||||
"heritage",
|
||||
"luxury",
|
||||
],
|
||||
sizes: ["tiny", "cozy", "medium", "large", "spacious"],
|
||||
floors: ["one", "two", "three", "multi"],
|
||||
vibes: [
|
||||
"quiet",
|
||||
"lively",
|
||||
"romantic",
|
||||
"cozy",
|
||||
"trendy",
|
||||
"traditional",
|
||||
"artistic",
|
||||
"luxury",
|
||||
"casual",
|
||||
"study_friendly",
|
||||
],
|
||||
occasions: [
|
||||
"date",
|
||||
"family",
|
||||
"friends",
|
||||
"finding_someone",
|
||||
"solo",
|
||||
"business_meeting",
|
||||
"study_work",
|
||||
"celebration",
|
||||
"quick_coffee",
|
||||
"breakfast",
|
||||
"brunch",
|
||||
],
|
||||
spaceFeatures: [
|
||||
"indoor",
|
||||
"outdoor",
|
||||
"terrace",
|
||||
"rooftop",
|
||||
"garden",
|
||||
"plants",
|
||||
"wifi",
|
||||
"parking",
|
||||
"wheelchair",
|
||||
"kids_friendly",
|
||||
"pet_friendly",
|
||||
"smoking_area",
|
||||
"live_music",
|
||||
"private_room",
|
||||
"counter_only",
|
||||
],
|
||||
noiseLevels: ["quiet", "moderate", "lively"],
|
||||
priceTiers: ["budget", "mid", "premium"],
|
||||
} as const;
|
||||
|
||||
export type DiscoverListField = keyof Pick<
|
||||
CafeDiscoverProfile,
|
||||
"themes" | "vibes" | "occasions" | "spaceFeatures"
|
||||
>;
|
||||
|
||||
export type DiscoverSingleField = keyof Pick<
|
||||
CafeDiscoverProfile,
|
||||
"size" | "floors" | "noiseLevel" | "priceTier"
|
||||
>;
|
||||
|
||||
export function toggleListValue(list: string[], id: string): string[] {
|
||||
return list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
/** Per-café branding — synced with API CafeThemeDto / Core Branding.CafeTheme */
|
||||
|
||||
import { normalizeMenuTexture } from "@/lib/qr-menu-texture";
|
||||
|
||||
export type CafeThemeCustomColors = {
|
||||
primary?: string | null;
|
||||
secondary?: string | null;
|
||||
accent?: string | null;
|
||||
background?: string | null;
|
||||
surface?: string | null;
|
||||
text?: string | null;
|
||||
textMuted?: string | null;
|
||||
destructive?: string | null;
|
||||
success?: string | null;
|
||||
};
|
||||
|
||||
export type CafeTheme = {
|
||||
paletteId: string;
|
||||
panelStyle: string;
|
||||
menuStyle: string;
|
||||
menuTexture: string;
|
||||
density: string;
|
||||
radius: string;
|
||||
custom?: CafeThemeCustomColors | null;
|
||||
};
|
||||
|
||||
export type CafeThemePalette = {
|
||||
id: string;
|
||||
primary: string;
|
||||
primaryForeground: string;
|
||||
secondary: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
surface: string;
|
||||
text: string;
|
||||
textMuted: string;
|
||||
destructive: string;
|
||||
success: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_CAFE_THEME: CafeTheme = {
|
||||
paletteId: "meezi-green",
|
||||
panelStyle: "modern",
|
||||
menuStyle: "cards",
|
||||
menuTexture: "none",
|
||||
density: "comfortable",
|
||||
radius: "md",
|
||||
custom: null,
|
||||
};
|
||||
|
||||
export const CAFE_THEME_PALETTES: CafeThemePalette[] = [
|
||||
{
|
||||
id: "meezi-green",
|
||||
primary: "#0F6E56",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#E1F5EE",
|
||||
accent: "#BA7517",
|
||||
background: "#F5F5F4",
|
||||
surface: "#FFFFFF",
|
||||
text: "#1C1917",
|
||||
textMuted: "#78716C",
|
||||
destructive: "#A32D2D",
|
||||
success: "#0F6E56",
|
||||
},
|
||||
{
|
||||
id: "ocean-blue",
|
||||
primary: "#0C447C",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#E0F0FA",
|
||||
accent: "#0891B2",
|
||||
background: "#F0F9FF",
|
||||
surface: "#FFFFFF",
|
||||
text: "#0F172A",
|
||||
textMuted: "#64748B",
|
||||
destructive: "#B91C1C",
|
||||
success: "#059669",
|
||||
},
|
||||
{
|
||||
id: "royal-purple",
|
||||
primary: "#5B21B6",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#EDE9FE",
|
||||
accent: "#A855F7",
|
||||
background: "#FAF5FF",
|
||||
surface: "#FFFFFF",
|
||||
text: "#1E1B4B",
|
||||
textMuted: "#6B7280",
|
||||
destructive: "#DC2626",
|
||||
success: "#7C3AED",
|
||||
},
|
||||
{
|
||||
id: "sunset-orange",
|
||||
primary: "#C2410C",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#FFEDD5",
|
||||
accent: "#EA580C",
|
||||
background: "#FFF7ED",
|
||||
surface: "#FFFFFF",
|
||||
text: "#431407",
|
||||
textMuted: "#9A3412",
|
||||
destructive: "#991B1B",
|
||||
success: "#15803D",
|
||||
},
|
||||
{
|
||||
id: "rose-blush",
|
||||
primary: "#BE123C",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#FFE4E6",
|
||||
accent: "#DB2777",
|
||||
background: "#FFF1F2",
|
||||
surface: "#FFFFFF",
|
||||
text: "#4C0519",
|
||||
textMuted: "#9F1239",
|
||||
destructive: "#9F1239",
|
||||
success: "#059669",
|
||||
},
|
||||
{
|
||||
id: "charcoal-gold",
|
||||
primary: "#292524",
|
||||
primaryForeground: "#FEF3C7",
|
||||
secondary: "#E7E5E4",
|
||||
accent: "#CA8A04",
|
||||
background: "#F5F5F4",
|
||||
surface: "#FFFFFF",
|
||||
text: "#1C1917",
|
||||
textMuted: "#57534E",
|
||||
destructive: "#B91C1C",
|
||||
success: "#15803D",
|
||||
},
|
||||
{
|
||||
id: "espresso",
|
||||
primary: "#44403C",
|
||||
primaryForeground: "#FAFAF9",
|
||||
secondary: "#E7E5E4",
|
||||
accent: "#92400E",
|
||||
background: "#FAF8F5",
|
||||
surface: "#FFFFFF",
|
||||
text: "#292524",
|
||||
textMuted: "#78716C",
|
||||
destructive: "#991B1B",
|
||||
success: "#166534",
|
||||
},
|
||||
{
|
||||
id: "forest",
|
||||
primary: "#166534",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#DCFCE7",
|
||||
accent: "#65A30D",
|
||||
background: "#F0FDF4",
|
||||
surface: "#FFFFFF",
|
||||
text: "#14532D",
|
||||
textMuted: "#4B5563",
|
||||
destructive: "#DC2626",
|
||||
success: "#15803D",
|
||||
},
|
||||
{
|
||||
id: "midnight",
|
||||
primary: "#1E3A5F",
|
||||
primaryForeground: "#F8FAFC",
|
||||
secondary: "#E2E8F0",
|
||||
accent: "#38BDF8",
|
||||
background: "#F1F5F9",
|
||||
surface: "#FFFFFF",
|
||||
text: "#0F172A",
|
||||
textMuted: "#64748B",
|
||||
destructive: "#EF4444",
|
||||
success: "#22C55E",
|
||||
},
|
||||
{
|
||||
id: "coral",
|
||||
primary: "#E11D48",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#FFE4E6",
|
||||
accent: "#FB7185",
|
||||
background: "#FFF1F2",
|
||||
surface: "#FFFFFF",
|
||||
text: "#881337",
|
||||
textMuted: "#9F1239",
|
||||
destructive: "#B91C1C",
|
||||
success: "#059669",
|
||||
},
|
||||
{
|
||||
id: "gold-luxury",
|
||||
primary: "#854D0E",
|
||||
primaryForeground: "#FFFBEB",
|
||||
secondary: "#FEF3C7",
|
||||
accent: "#D97706",
|
||||
background: "#FFFBEB",
|
||||
surface: "#FFFFFF",
|
||||
text: "#422006",
|
||||
textMuted: "#78716C",
|
||||
destructive: "#991B1B",
|
||||
success: "#166534",
|
||||
},
|
||||
{
|
||||
id: "mint-fresh",
|
||||
primary: "#0D9488",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#CCFBF1",
|
||||
accent: "#2DD4BF",
|
||||
background: "#F0FDFA",
|
||||
surface: "#FFFFFF",
|
||||
text: "#134E4A",
|
||||
textMuted: "#5EEAD4",
|
||||
destructive: "#DC2626",
|
||||
success: "#0D9488",
|
||||
},
|
||||
{
|
||||
id: "wine-bar",
|
||||
primary: "#7F1D1D",
|
||||
primaryForeground: "#FEF2F2",
|
||||
secondary: "#FEE2E2",
|
||||
accent: "#B45309",
|
||||
background: "#FEF2F2",
|
||||
surface: "#FFFFFF",
|
||||
text: "#450A0A",
|
||||
textMuted: "#991B1B",
|
||||
destructive: "#991B1B",
|
||||
success: "#166534",
|
||||
},
|
||||
{
|
||||
id: "slate-modern",
|
||||
primary: "#334155",
|
||||
primaryForeground: "#F8FAFC",
|
||||
secondary: "#F1F5F9",
|
||||
accent: "#0EA5E9",
|
||||
background: "#F8FAFC",
|
||||
surface: "#FFFFFF",
|
||||
text: "#0F172A",
|
||||
textMuted: "#64748B",
|
||||
destructive: "#EF4444",
|
||||
success: "#10B981",
|
||||
},
|
||||
{
|
||||
id: "cherry",
|
||||
primary: "#9F1239",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#FECDD3",
|
||||
accent: "#F43F5E",
|
||||
background: "#FFF1F2",
|
||||
surface: "#FFFFFF",
|
||||
text: "#4C0519",
|
||||
textMuted: "#BE123C",
|
||||
destructive: "#881337",
|
||||
success: "#059669",
|
||||
},
|
||||
{
|
||||
id: "teal-wave",
|
||||
primary: "#0F766E",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#CCFBF1",
|
||||
accent: "#14B8A6",
|
||||
background: "#F0FDFA",
|
||||
surface: "#FFFFFF",
|
||||
text: "#134E4A",
|
||||
textMuted: "#5F6B6B",
|
||||
destructive: "#DC2626",
|
||||
success: "#0F766E",
|
||||
},
|
||||
{
|
||||
id: "sand-cafe",
|
||||
primary: "#A16207",
|
||||
primaryForeground: "#FFFBEB",
|
||||
secondary: "#FEF3C7",
|
||||
accent: "#D97706",
|
||||
background: "#FAFAF9",
|
||||
surface: "#FFFFFF",
|
||||
text: "#44403C",
|
||||
textMuted: "#A8A29E",
|
||||
destructive: "#B91C1C",
|
||||
success: "#15803D",
|
||||
},
|
||||
];
|
||||
|
||||
export const CAFE_PANEL_STYLES = [
|
||||
"flat",
|
||||
"modern",
|
||||
"glass",
|
||||
"minimal",
|
||||
"bold",
|
||||
"soft",
|
||||
"elevated",
|
||||
"outline",
|
||||
] as const;
|
||||
|
||||
export const CAFE_MENU_STYLES = [
|
||||
"cards",
|
||||
"compact",
|
||||
"grid",
|
||||
"list",
|
||||
"magazine",
|
||||
"classic",
|
||||
] as const;
|
||||
|
||||
export {
|
||||
CAFE_MENU_TEXTURES,
|
||||
normalizeMenuTexture,
|
||||
qrMenuTextureShellProps,
|
||||
type CafeMenuTexture,
|
||||
} from "./qr-menu-texture";
|
||||
|
||||
export const CAFE_THEME_DENSITIES = ["compact", "comfortable", "spacious"] as const;
|
||||
export const CAFE_THEME_RADIUS = ["none", "sm", "md", "lg", "full"] as const;
|
||||
|
||||
const paletteById = new Map(CAFE_THEME_PALETTES.map((p) => [p.id, p]));
|
||||
|
||||
export function getThemePalette(id: string): CafeThemePalette {
|
||||
return paletteById.get(id) ?? CAFE_THEME_PALETTES[0];
|
||||
}
|
||||
|
||||
export function resolveThemeColors(theme: CafeTheme): CafeThemePalette {
|
||||
const base = getThemePalette(theme.paletteId);
|
||||
const c = theme.custom;
|
||||
if (!c) return base;
|
||||
return {
|
||||
...base,
|
||||
primary: c.primary ?? base.primary,
|
||||
secondary: c.secondary ?? base.secondary,
|
||||
accent: c.accent ?? base.accent,
|
||||
background: c.background ?? base.background,
|
||||
surface: c.surface ?? base.surface,
|
||||
text: c.text ?? base.text,
|
||||
textMuted: c.textMuted ?? base.textMuted,
|
||||
destructive: c.destructive ?? base.destructive,
|
||||
success: c.success ?? base.success,
|
||||
};
|
||||
}
|
||||
|
||||
function hexToHslChannels(hex: string): string {
|
||||
const raw = hex.replace("#", "");
|
||||
const r = parseInt(raw.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(raw.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(raw.slice(4, 6), 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
default:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
}
|
||||
}
|
||||
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
|
||||
}
|
||||
|
||||
const RADIUS_MAP: Record<string, string> = {
|
||||
none: "0px",
|
||||
sm: "0.375rem",
|
||||
md: "0.75rem",
|
||||
lg: "1rem",
|
||||
full: "1.25rem",
|
||||
};
|
||||
|
||||
const OVERRIDE_STYLE_ID = "meezi-cafe-theme-overrides";
|
||||
|
||||
function injectBrandOverrides(primaryHex: string, secondaryHex: string): void {
|
||||
let el = document.getElementById(OVERRIDE_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!el) {
|
||||
el = document.createElement("style");
|
||||
el.id = OVERRIDE_STYLE_ID;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = `
|
||||
html[data-cafe-theme] .bg-\\[\\#0F6E56\\],
|
||||
html[data-cafe-theme] .hover\\:bg-\\[\\#0c5a46\\]:hover,
|
||||
html[data-cafe-theme] .bg-\\[\\#0c5a46\\] {
|
||||
background-color: ${primaryHex} !important;
|
||||
}
|
||||
html[data-cafe-theme] .text-\\[\\#0F6E56\\],
|
||||
html[data-cafe-theme] .hover\\:text-\\[\\#0F6E56\\]:hover {
|
||||
color: ${primaryHex} !important;
|
||||
}
|
||||
html[data-cafe-theme] .border-\\[\\#0F6E56\\],
|
||||
html[data-cafe-theme] .hover\\:border-\\[\\#0F6E56\\]\\/40:hover,
|
||||
html[data-cafe-theme] .ring-\\[\\#0F6E56\\]\\/30 {
|
||||
border-color: color-mix(in srgb, ${primaryHex} 40%, transparent) !important;
|
||||
}
|
||||
html[data-cafe-theme] .bg-\\[\\#E1F5EE\\] {
|
||||
background-color: ${secondaryHex} !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export function applyCafeTheme(theme: CafeTheme): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const colors = resolveThemeColors(theme);
|
||||
const root = document.documentElement;
|
||||
|
||||
root.dataset.cafeTheme = "true";
|
||||
root.dataset.panelStyle = theme.panelStyle;
|
||||
root.dataset.menuStyle = theme.menuStyle;
|
||||
root.dataset.density = theme.density;
|
||||
|
||||
const set = (name: string, hex: string) => root.style.setProperty(name, hexToHslChannels(hex));
|
||||
|
||||
set("--primary", colors.primary);
|
||||
set("--primary-foreground", colors.primaryForeground);
|
||||
set("--secondary", colors.secondary);
|
||||
set("--secondary-foreground", colors.text);
|
||||
set("--accent", colors.secondary);
|
||||
set("--accent-foreground", colors.primary);
|
||||
set("--background", colors.background);
|
||||
set("--foreground", colors.text);
|
||||
set("--card", colors.surface);
|
||||
set("--card-foreground", colors.text);
|
||||
set("--muted", colors.background);
|
||||
set("--muted-foreground", colors.textMuted);
|
||||
set("--destructive", colors.destructive);
|
||||
set("--ring", colors.primary);
|
||||
set("--meezi-green", colors.primary);
|
||||
set("--meezi-green-tint", colors.secondary);
|
||||
set("--meezi-amber", colors.accent);
|
||||
set("--meezi-danger", colors.destructive);
|
||||
|
||||
root.style.setProperty("--radius", RADIUS_MAP[theme.radius] ?? RADIUS_MAP.md);
|
||||
root.style.setProperty("--brand-primary-hex", colors.primary);
|
||||
root.style.setProperty("--brand-secondary-hex", colors.secondary);
|
||||
root.style.setProperty("--brand-accent-hex", colors.accent);
|
||||
|
||||
injectBrandOverrides(colors.primary, colors.secondary);
|
||||
}
|
||||
|
||||
export function normalizeCafeTheme(input?: Partial<CafeTheme> | null): CafeTheme {
|
||||
if (!input) return { ...DEFAULT_CAFE_THEME };
|
||||
return {
|
||||
paletteId: paletteById.has(input.paletteId ?? "") ? input.paletteId! : DEFAULT_CAFE_THEME.paletteId,
|
||||
panelStyle: CAFE_PANEL_STYLES.includes(input.panelStyle as (typeof CAFE_PANEL_STYLES)[number])
|
||||
? input.panelStyle!
|
||||
: DEFAULT_CAFE_THEME.panelStyle,
|
||||
menuStyle: CAFE_MENU_STYLES.includes(input.menuStyle as (typeof CAFE_MENU_STYLES)[number])
|
||||
? input.menuStyle!
|
||||
: DEFAULT_CAFE_THEME.menuStyle,
|
||||
menuTexture: normalizeMenuTexture(input.menuTexture ?? DEFAULT_CAFE_THEME.menuTexture),
|
||||
density: CAFE_THEME_DENSITIES.includes(input.density as (typeof CAFE_THEME_DENSITIES)[number])
|
||||
? input.density!
|
||||
: DEFAULT_CAFE_THEME.density,
|
||||
radius: CAFE_THEME_RADIUS.includes(input.radius as (typeof CAFE_THEME_RADIUS)[number])
|
||||
? input.radius!
|
||||
: DEFAULT_CAFE_THEME.radius,
|
||||
custom: input.custom ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** Curated emoji sets per menu category theme (Persian café / restaurant). */
|
||||
export type CategoryEmojiGroup = {
|
||||
id: string;
|
||||
emojis: readonly string[];
|
||||
};
|
||||
|
||||
export const CATEGORY_EMOJI_GROUPS: CategoryEmojiGroup[] = [
|
||||
{
|
||||
id: "hotDrinks",
|
||||
emojis: ["☕", "🍵", "🫖", "🧉", "☕️", "🫘", "🍶", "🥛"],
|
||||
},
|
||||
{
|
||||
id: "coldDrinks",
|
||||
emojis: ["🧊", "🥤", "🧃", "🍹", "🍸", "🥂", "🍺", "🍷", "🧋", "🥛", "🍼"],
|
||||
},
|
||||
{
|
||||
id: "breakfast",
|
||||
emojis: ["🍳", "🥐", "🥞", "🧇", "🥯", "🍞", "🥚", "🧈", "🥓", "🫕"],
|
||||
},
|
||||
{
|
||||
id: "mains",
|
||||
emojis: ["🍽️", "🍛", "🍲", "🥘", "🍚", "🍖", "🍗", "🥩", "🌯", "🥙", "🍱"],
|
||||
},
|
||||
{
|
||||
id: "pastaPizza",
|
||||
emojis: ["🍕", "🍝", "🧀", "🥖", "🫓", "🥪", "🌮", "🌯"],
|
||||
},
|
||||
{
|
||||
id: "desserts",
|
||||
emojis: ["🍰", "🎂", "🧁", "🍮", "🍩", "🍪", "🍫", "🍬", "🍭", "🍦", "🍨", "🧇"],
|
||||
},
|
||||
{
|
||||
id: "salads",
|
||||
emojis: ["🥗", "🥒", "🥕", "🥬", "🍅", "🫑", "🥑", "🌽", "🧅"],
|
||||
},
|
||||
{
|
||||
id: "seafoodGrill",
|
||||
emojis: ["🐟", "🦐", "🦞", "🦀", "🍤", "🥩", "🔥", "🍖", "🥓", "🍢"],
|
||||
},
|
||||
{
|
||||
id: "snacks",
|
||||
emojis: ["🍟", "🍿", "🥨", "🥜", "🌰", "🥪", "🌭", "🍔", "🥙", "🧆"],
|
||||
},
|
||||
{
|
||||
id: "vegan",
|
||||
emojis: ["🥬", "🌱", "🥦", "🥒", "🍄", "🫛", "🫘", "🥑", "🌽", "🍆"],
|
||||
},
|
||||
{
|
||||
id: "specials",
|
||||
emojis: ["⭐", "✨", "🔥", "💎", "🎉", "🏷️", "❤️", "👨🍳", "🆕", "💫"],
|
||||
},
|
||||
{
|
||||
id: "general",
|
||||
emojis: ["🍴", "🥄", "🍽️", "🏪", "📋", "🪑", "🛎️", "☕", "🍽️", "🧾"],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
Beef,
|
||||
Beer,
|
||||
CakeSlice,
|
||||
ChefHat,
|
||||
Cherry,
|
||||
Citrus,
|
||||
Coffee,
|
||||
Cookie,
|
||||
CupSoda,
|
||||
Donut,
|
||||
EggFried,
|
||||
Fish,
|
||||
Flame,
|
||||
GlassWater,
|
||||
IceCreamCone,
|
||||
Leaf,
|
||||
Milk,
|
||||
Pizza,
|
||||
Salad,
|
||||
Sandwich,
|
||||
Soup,
|
||||
Sprout,
|
||||
Star,
|
||||
UtensilsCrossed,
|
||||
Wheat,
|
||||
Wine,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
/** Visual variant for preset category icons */
|
||||
export const CATEGORY_ICON_STYLES = [
|
||||
"flat",
|
||||
"modern",
|
||||
"real",
|
||||
"minimal",
|
||||
"outline",
|
||||
"soft",
|
||||
"bold",
|
||||
"gradient",
|
||||
"pastel",
|
||||
"duotone",
|
||||
] as const;
|
||||
|
||||
export type CategoryIconStyleId = (typeof CATEGORY_ICON_STYLES)[number];
|
||||
|
||||
export function isCategoryIconStyle(value: string | null | undefined): value is CategoryIconStyleId {
|
||||
return CATEGORY_ICON_STYLES.includes(value as CategoryIconStyleId);
|
||||
}
|
||||
|
||||
export type CategoryIconPresetKind = "drink" | "food";
|
||||
|
||||
export type CategoryIconPresetDef = {
|
||||
id: string;
|
||||
kind: CategoryIconPresetKind;
|
||||
icon: LucideIcon;
|
||||
/** Photo used when style is "real" */
|
||||
realImageUrl: string;
|
||||
};
|
||||
|
||||
export const CATEGORY_ICON_PRESETS: CategoryIconPresetDef[] = [
|
||||
{
|
||||
id: "drinks-hot",
|
||||
kind: "drink",
|
||||
icon: Coffee,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1509042239860-f550ce710b93?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "drinks-cold",
|
||||
kind: "drink",
|
||||
icon: CupSoda,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1517487881594-2787aeee8f58?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "drinks-tea",
|
||||
kind: "drink",
|
||||
icon: GlassWater,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1556679343-c7306c1976bc?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "drinks-juice",
|
||||
kind: "drink",
|
||||
icon: Citrus,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1523672990561-64c16245f769?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "drinks-milkshake",
|
||||
kind: "drink",
|
||||
icon: Milk,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1505252585463-0433371f7f6b?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "drinks-alcohol",
|
||||
kind: "drink",
|
||||
icon: Wine,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1510812431401-41d2bd2722f3?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "drinks-beer",
|
||||
kind: "drink",
|
||||
icon: Beer,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1608270586620-916524e5f405?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "breakfast",
|
||||
kind: "food",
|
||||
icon: EggFried,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1525351484343-752d43d363f1?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "food-mains",
|
||||
kind: "food",
|
||||
icon: UtensilsCrossed,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "food-fastfood",
|
||||
kind: "food",
|
||||
icon: Sandwich,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1568901346375-23c9450c58cd?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "food-rice",
|
||||
kind: "food",
|
||||
icon: Wheat,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1534084650011-4c4d81e8ca4b?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "pasta-pizza",
|
||||
kind: "food",
|
||||
icon: Pizza,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1513104890138-7c749659a591?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "dessert",
|
||||
kind: "food",
|
||||
icon: CakeSlice,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1578985545062-69928b1d9587?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "ice-cream",
|
||||
kind: "food",
|
||||
icon: IceCreamCone,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1563805042-7684c019e1cb?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "bakery",
|
||||
kind: "food",
|
||||
icon: Cookie,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1555507036342-9231d37c10f3?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "salad",
|
||||
kind: "food",
|
||||
icon: Salad,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1546793665-c74683f339c1?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "grill",
|
||||
kind: "food",
|
||||
icon: Flame,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1600891963295-d66a269b9202?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "seafood",
|
||||
kind: "food",
|
||||
icon: Fish,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1467003909585-2f8a72700288?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "snacks",
|
||||
kind: "food",
|
||||
icon: Sandwich,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1528735602780-2552fd46c7af?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "snacks-sweet",
|
||||
kind: "food",
|
||||
icon: Donut,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1551024506-0bccd28d3071?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "appetizers",
|
||||
kind: "food",
|
||||
icon: Soup,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1547592160-23ac45744acd?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "vegan",
|
||||
kind: "food",
|
||||
icon: Sprout,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1512621776951-a57141f2eefd?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "fruits",
|
||||
kind: "food",
|
||||
icon: Cherry,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1464965911861-746a04a4c36e?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "specials",
|
||||
kind: "food",
|
||||
icon: Star,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "chef-special",
|
||||
kind: "food",
|
||||
icon: ChefHat,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "generic",
|
||||
kind: "food",
|
||||
icon: Beef,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=200&h=200&fit=crop",
|
||||
},
|
||||
];
|
||||
|
||||
const presetById = new Map(CATEGORY_ICON_PRESETS.map((p) => [p.id, p]));
|
||||
|
||||
export function getCategoryIconPreset(presetId: string | null | undefined): CategoryIconPresetDef | null {
|
||||
if (!presetId) return null;
|
||||
return presetById.get(presetId) ?? null;
|
||||
}
|
||||
|
||||
export const DEFAULT_CATEGORY_ICON_STYLE: CategoryIconStyleId = "flat";
|
||||
|
||||
export type CategoryIconStroke = { strokeWidth: number; className?: string };
|
||||
|
||||
export function getCategoryIconStroke(style: CategoryIconStyleId): CategoryIconStroke {
|
||||
switch (style) {
|
||||
case "minimal":
|
||||
return { strokeWidth: 1.35, className: "stroke-[1.35]" };
|
||||
case "outline":
|
||||
return { strokeWidth: 2.35 };
|
||||
case "bold":
|
||||
return { strokeWidth: 2.75 };
|
||||
case "soft":
|
||||
case "pastel":
|
||||
return { strokeWidth: 1.85 };
|
||||
case "duotone":
|
||||
return { strokeWidth: 2, className: "opacity-90" };
|
||||
default:
|
||||
return { strokeWidth: 2 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { format } from "date-fns-jalali";
|
||||
import { enUS } from "date-fns-jalali/locale/en-US";
|
||||
import { faIR } from "date-fns-jalali/locale/fa-IR";
|
||||
|
||||
const PLAN_TIERS = ["Free", "Pro", "Business", "Enterprise"] as const;
|
||||
export type PlanTierKey = (typeof PLAN_TIERS)[number];
|
||||
|
||||
export function isPlanTierKey(tier: string): tier is PlanTierKey {
|
||||
return (PLAN_TIERS as readonly string[]).includes(tier);
|
||||
}
|
||||
|
||||
export function numberLocaleForUi(locale: string): string {
|
||||
if (locale === "en") return "en-US";
|
||||
if (locale === "ar") return "ar-SA";
|
||||
return "fa-IR";
|
||||
}
|
||||
|
||||
export function formatHeaderTime(date: Date, locale: string): string {
|
||||
return date.toLocaleTimeString(numberLocaleForUi(locale), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatHeaderJalaliDate(date: Date, locale: string): string {
|
||||
const jalaliLocale = locale === "en" ? enUS : faIR;
|
||||
return format(date, "EEEE d MMMM yyyy", { locale: jalaliLocale });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function formatNumber(value: number, locale = "fa-IR"): string {
|
||||
return value.toLocaleString(locale);
|
||||
}
|
||||
|
||||
export function formatCurrency(value: number, locale = "fa-IR"): string {
|
||||
return `${value.toLocaleString(locale)} ت`;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export type GuestOrderRef = {
|
||||
orderId: string;
|
||||
trackingToken: string;
|
||||
orderNumber: string;
|
||||
createdAt: string;
|
||||
cafeId: string;
|
||||
branchId: string;
|
||||
tableId: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "meezi_guest_orders";
|
||||
|
||||
export function saveGuestOrder(ref: GuestOrderRef): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const list = loadGuestOrders();
|
||||
const filtered = list.filter((o) => o.orderId !== ref.orderId);
|
||||
const next = [ref, ...filtered].slice(0, 30);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
export function loadGuestOrders(): GuestOrderRef[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as GuestOrderRef[];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function ordersForTable(orders: GuestOrderRef[], cafeId: string, tableId: string) {
|
||||
return orders.filter((o) => o.cafeId === cafeId && o.tableId === tableId);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiGet } from "@/lib/api/client";
|
||||
import type { CafeTheme } from "@/lib/cafe-theme";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
|
||||
export type CafeSettings = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
description?: string;
|
||||
logoUrl?: string;
|
||||
coverImageUrl?: string;
|
||||
snappfoodVendorId?: string;
|
||||
planTier: string;
|
||||
theme: CafeTheme;
|
||||
defaultTaxRate?: number;
|
||||
allowBranchTaxOverride?: boolean;
|
||||
};
|
||||
|
||||
export function cafeSettingsQueryKey(cafeId: string) {
|
||||
return ["cafe-settings", cafeId] as const;
|
||||
}
|
||||
|
||||
export function useCafeSettings(cafeId?: string | null) {
|
||||
const authCafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const id = cafeId ?? authCafeId;
|
||||
|
||||
return useQuery<CafeSettings>({
|
||||
queryKey: cafeSettingsQueryKey(id ?? ""),
|
||||
queryFn: () => {
|
||||
if (!id) throw new Error("Missing cafe id");
|
||||
return apiGet<CafeSettings>(`/api/cafes/${id}/settings`);
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useLiveClock(intervalMs = 1000): Date {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(new Date()), intervalMs);
|
||||
return () => window.clearInterval(id);
|
||||
}, [intervalMs]);
|
||||
|
||||
return now;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useOnlineStatus(): boolean {
|
||||
const [online, setOnline] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setOnline(navigator.onLine);
|
||||
|
||||
const onOnline = () => setOnline(true);
|
||||
const onOffline = () => setOnline(false);
|
||||
|
||||
window.addEventListener("online", onOnline);
|
||||
window.addEventListener("offline", onOffline);
|
||||
return () => {
|
||||
window.removeEventListener("online", onOnline);
|
||||
window.removeEventListener("offline", onOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return online;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Owner assets + guest 3D menu helpers */
|
||||
|
||||
export const MENU_3D_GLB_MAX_MB = 8;
|
||||
|
||||
/** Recommended photo count for future 360° spin (not yet in app). */
|
||||
export const MENU_360_PHOTO_COUNT = { min: 12, ideal: 24 } as const;
|
||||
|
||||
export function hasMenu3dView(item: { model3dUrl?: string | null }): boolean {
|
||||
return !!item.model3dUrl?.trim();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/** Localized menu label: primary name for locale + English line for international guests. */
|
||||
|
||||
export type MenuNameFields = {
|
||||
name: string;
|
||||
nameEn?: string | null;
|
||||
nameAr?: string | null;
|
||||
};
|
||||
|
||||
export function getMenuPrimaryName(
|
||||
item: MenuNameFields,
|
||||
locale: string
|
||||
): string {
|
||||
const en = item.nameEn?.trim();
|
||||
const ar = item.nameAr?.trim();
|
||||
const fa = item.name.trim();
|
||||
|
||||
if (locale === "en") return en || fa;
|
||||
if (locale === "ar") return ar || fa;
|
||||
return fa;
|
||||
}
|
||||
|
||||
/** English subtitle when primary is fa/ar (helps staff and international customers). */
|
||||
export function getMenuEnglishSubtitle(
|
||||
item: MenuNameFields,
|
||||
locale: string
|
||||
): string | undefined {
|
||||
const en = item.nameEn?.trim();
|
||||
if (!en) return undefined;
|
||||
|
||||
const primary = getMenuPrimaryName(item, locale);
|
||||
if (primary === en) return undefined;
|
||||
|
||||
if (locale === "en") return undefined;
|
||||
|
||||
return en;
|
||||
}
|
||||
|
||||
/** Case-insensitive match for POS / menu search (fa, en, ar, description). */
|
||||
export function menuItemMatchesSearch(
|
||||
item: MenuNameFields & { description?: string | null },
|
||||
query: string,
|
||||
locale: string
|
||||
): boolean {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
const haystack = [
|
||||
item.name,
|
||||
item.nameEn,
|
||||
item.nameAr,
|
||||
item.description,
|
||||
getMenuPrimaryName(item, locale),
|
||||
getMenuEnglishSubtitle(item, locale),
|
||||
]
|
||||
.filter((s): s is string => typeof s === "string" && s.length > 0)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Coffee, CupSoda, UtensilsCrossed, type LucideIcon } from "lucide-react";
|
||||
import { resolveMediaUrl } from "@/lib/api/client";
|
||||
|
||||
export type MenuItemVisualKind = "food" | "drink";
|
||||
|
||||
const DRINK_CATEGORY_IDS = new Set(["cat_demo_drinks", "cat_demo_cold"]);
|
||||
|
||||
/** Latin keywords; Persian/Arabic category names come from API `categoryName`. */
|
||||
const DRINK_HINTS = [
|
||||
"drink",
|
||||
"cold",
|
||||
"hot",
|
||||
"coffee",
|
||||
"tea",
|
||||
"juice",
|
||||
"smoothie",
|
||||
"beverage",
|
||||
"bar",
|
||||
"espresso",
|
||||
"latte",
|
||||
];
|
||||
|
||||
export function inferMenuItemKind(
|
||||
categoryId: string,
|
||||
categoryName?: string
|
||||
): MenuItemVisualKind {
|
||||
if (DRINK_CATEGORY_IDS.has(categoryId)) return "drink";
|
||||
|
||||
const haystack = `${categoryId} ${categoryName ?? ""}`.toLowerCase();
|
||||
if (DRINK_HINTS.some((h) => haystack.includes(h))) return "drink";
|
||||
|
||||
return "food";
|
||||
}
|
||||
|
||||
export function getMenuItemImageSrc(imageUrl?: string | null): string | undefined {
|
||||
return resolveMediaUrl(imageUrl);
|
||||
}
|
||||
|
||||
export function menuItemPlaceholderIcon(kind: MenuItemVisualKind): LucideIcon {
|
||||
return kind === "drink" ? CupSoda : UtensilsCrossed;
|
||||
}
|
||||
|
||||
/** Larger hero-style icon for sidebar preview */
|
||||
export function menuItemPlaceholderHeroIcon(kind: MenuItemVisualKind): LucideIcon {
|
||||
return kind === "drink" ? Coffee : UtensilsCrossed;
|
||||
}
|
||||
|
||||
export function buildCategoryNameMap(
|
||||
categories: { id: string; name: string }[]
|
||||
): Map<string, string> {
|
||||
return new Map(categories.map((c) => [c.id, c.name]));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { toast } from "sonner";
|
||||
import { ApiClientError } from "@/lib/api/client";
|
||||
|
||||
export type NotifyOptions = {
|
||||
description?: string;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
function baseOptions(opts?: NotifyOptions) {
|
||||
return {
|
||||
description: opts?.description,
|
||||
duration: opts?.duration ?? 4000,
|
||||
};
|
||||
}
|
||||
|
||||
/** Toast notifications — use for transient success/error/info across the app */
|
||||
export const notify = {
|
||||
success(message: string, opts?: NotifyOptions) {
|
||||
toast.success(message, baseOptions(opts));
|
||||
},
|
||||
error(message: string, opts?: NotifyOptions) {
|
||||
toast.error(message, { ...baseOptions(opts), duration: opts?.duration ?? 5500 });
|
||||
},
|
||||
warning(message: string, opts?: NotifyOptions) {
|
||||
toast.warning(message, baseOptions(opts));
|
||||
},
|
||||
info(message: string, opts?: NotifyOptions) {
|
||||
toast.info(message, baseOptions(opts));
|
||||
},
|
||||
loading(message: string) {
|
||||
return toast.loading(message);
|
||||
},
|
||||
dismiss(id?: string | number) {
|
||||
toast.dismiss(id);
|
||||
},
|
||||
promise<T>(
|
||||
promise: Promise<T>,
|
||||
messages: { loading: string; success: string; error?: string }
|
||||
) {
|
||||
return toast.promise(promise, {
|
||||
loading: messages.loading,
|
||||
success: messages.success,
|
||||
error: messages.error ?? messages.loading,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export function getErrorMessage(err: unknown, fallback: string): string {
|
||||
if (err instanceof ApiClientError) return err.message;
|
||||
if (err instanceof Error && err.message) return err.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function notifyError(err: unknown, fallback: string) {
|
||||
notify.error(getErrorMessage(err, fallback));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Persian/Arabic-Indic digits → ASCII */
|
||||
function toAsciiDigits(value: string): string {
|
||||
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
|
||||
const code = ch.charCodeAt(0);
|
||||
if (code >= 0x06f0 && code <= 0x06f9) return String(code - 0x06f0);
|
||||
if (code >= 0x0660 && code <= 0x0669) return String(code - 0x0660);
|
||||
return ch;
|
||||
});
|
||||
}
|
||||
|
||||
/** Normalize to 09XXXXXXXXX (matches API PhoneNormalizer). */
|
||||
export function normalizeIranMobile(phone: string): string {
|
||||
let digits = toAsciiDigits(phone).replace(/\D/g, "");
|
||||
if (digits.startsWith("98") && digits.length === 12) digits = `0${digits.slice(2)}`;
|
||||
if (digits.length === 10 && digits.startsWith("9")) digits = `0${digits}`;
|
||||
return digits;
|
||||
}
|
||||
|
||||
/** Iranian mobile: 09XXXXXXXXX */
|
||||
export function isValidIranMobile(phone: string): boolean {
|
||||
const n = normalizeIranMobile(phone);
|
||||
return n.length === 11 && /^09\d{9}$/.test(n);
|
||||
}
|
||||
|
||||
export function iranMobileForApi(phone: string): string | undefined {
|
||||
const normalized = normalizeIranMobile(phone.trim());
|
||||
return isValidIranMobile(normalized) ? normalized : undefined;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
type PaymentMethod = "Cash" | "Card" | "Credit";
|
||||
|
||||
type PaymentRowLike = {
|
||||
method: PaymentMethod;
|
||||
amount: string;
|
||||
};
|
||||
|
||||
/** Button label reflecting active payment row methods (split / single). */
|
||||
export function confirmPayLabel(
|
||||
rows: PaymentRowLike[],
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
const methods = rows
|
||||
.filter((r) => (parseFloat(r.amount.replace(/,/g, "")) || 0) > 0)
|
||||
.map((r) => r.method);
|
||||
const unique = Array.from(new Set(methods));
|
||||
|
||||
if (unique.length === 0) return t("confirmPay");
|
||||
if (unique.length > 1) return t("confirmPaySplit");
|
||||
if (unique[0] === "Cash") return t("confirmPayCash");
|
||||
if (unique[0] === "Card") return t("confirmPayCard");
|
||||
return t("confirmPayCredit");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Order } from "@/lib/api/types";
|
||||
|
||||
/** Label for open orders at pay time: table + guest name. */
|
||||
export function formatPosOrderLabel(
|
||||
order: Pick<Order, "tableNumber" | "guestName" | "customerName">,
|
||||
tableWord: string
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (order.tableNumber) {
|
||||
parts.push(`${tableWord} ${order.tableNumber}`);
|
||||
}
|
||||
const name = order.guestName?.trim() || order.customerName?.trim();
|
||||
if (name) {
|
||||
parts.push(name);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(" · ") : "—";
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { apiPost } from "@/lib/api/client";
|
||||
import type { Order } from "@/lib/api/types";
|
||||
import { iranMobileForApi } from "@/lib/phone";
|
||||
|
||||
export type SubmitOrderCart = {
|
||||
getPendingLines: () => { menuItemId: string; quantity: number; notes?: string }[];
|
||||
activeOrderId: string | null;
|
||||
tableId: string | null;
|
||||
guestName: string;
|
||||
guestPhone: string;
|
||||
customerId: string | null;
|
||||
appliedCoupon: { id: string } | null;
|
||||
};
|
||||
|
||||
export type SubmitOrderParams = {
|
||||
cafeId: string;
|
||||
orderBranchId: string | undefined;
|
||||
cart: SubmitOrderCart;
|
||||
reservationId: string | null;
|
||||
};
|
||||
|
||||
export async function submitOrderToApi({
|
||||
cafeId,
|
||||
orderBranchId,
|
||||
cart,
|
||||
reservationId,
|
||||
}: SubmitOrderParams): Promise<Order> {
|
||||
const pending = cart.getPendingLines();
|
||||
if (pending.length === 0) throw new Error("nothing pending");
|
||||
|
||||
if (cart.activeOrderId) {
|
||||
return apiPost<Order>(`/api/cafes/${cafeId}/orders/${cart.activeOrderId}/items`, {
|
||||
items: pending,
|
||||
});
|
||||
}
|
||||
|
||||
return apiPost<Order>(`/api/cafes/${cafeId}/orders`, {
|
||||
orderType: "DineIn",
|
||||
branchId: orderBranchId,
|
||||
tableId: cart.tableId ?? undefined,
|
||||
reservationId: reservationId ?? undefined,
|
||||
guestName: cart.guestName.trim() || undefined,
|
||||
guestPhone: iranMobileForApi(cart.guestPhone),
|
||||
customerId: cart.customerId ?? undefined,
|
||||
couponId: cart.appliedCoupon?.id,
|
||||
items: pending,
|
||||
});
|
||||
}
|
||||
|
||||
export function orderAmountDue(order: Order): number {
|
||||
return Math.max(0, order.total - (order.paidAmount ?? 0));
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Sentinel id for the combined “all categories” tab on guest QR menu. */
|
||||
export const QR_ALL_CATEGORY_ID = "all";
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
/** QR guest menu background textures (owner picks in Settings → Appearance). */
|
||||
|
||||
export const CAFE_MENU_TEXTURES = [
|
||||
"none",
|
||||
"paper",
|
||||
"linen",
|
||||
"dots",
|
||||
"grid",
|
||||
"marble",
|
||||
"wood",
|
||||
"warm",
|
||||
] as const;
|
||||
|
||||
export type CafeMenuTexture = (typeof CAFE_MENU_TEXTURES)[number];
|
||||
|
||||
export function normalizeMenuTexture(value?: string | null): CafeMenuTexture {
|
||||
if (value && (CAFE_MENU_TEXTURES as readonly string[]).includes(value)) {
|
||||
return value as CafeMenuTexture;
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
/** Props for the textured QR menu shell (uses CSS in globals.css). */
|
||||
export function qrMenuTextureShellProps(
|
||||
texture: CafeMenuTexture,
|
||||
backgroundColor: string
|
||||
): { "data-qr-texture": CafeMenuTexture; style: CSSProperties } {
|
||||
return {
|
||||
"data-qr-texture": texture,
|
||||
style: { ["--qr-bg" as string]: backgroundColor },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
export type TopProductSnapshot = {
|
||||
productId: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
revenue: number;
|
||||
};
|
||||
|
||||
export type DailyReportSnapshot = {
|
||||
id: string;
|
||||
cafeId: string;
|
||||
branchId: string;
|
||||
date: string;
|
||||
totalRevenue: number;
|
||||
cashRevenue: number;
|
||||
cardRevenue: number;
|
||||
creditRevenue: number;
|
||||
totalOrders: number;
|
||||
avgOrderValue: number;
|
||||
totalVoids: number;
|
||||
voidAmount: number;
|
||||
totalExpenses: number;
|
||||
netIncome: number;
|
||||
topProducts: TopProductSnapshot[];
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
export type DateRangePreset = "7d" | "30d" | "90d" | "custom";
|
||||
|
||||
export type ReportRange = {
|
||||
from: string;
|
||||
to: string;
|
||||
preset: DateRangePreset;
|
||||
};
|
||||
|
||||
export function isoTodayTehran(): string {
|
||||
return new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Tehran" });
|
||||
}
|
||||
|
||||
export function addDaysIso(iso: string, days: number): string {
|
||||
const d = new Date(`${iso}T12:00:00`);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toLocaleDateString("en-CA", { timeZone: "Asia/Tehran" });
|
||||
}
|
||||
|
||||
export function daysBetweenInclusive(from: string, to: string): number {
|
||||
const start = new Date(`${from}T12:00:00`).getTime();
|
||||
const end = new Date(`${to}T12:00:00`).getTime();
|
||||
return Math.max(1, Math.round((end - start) / 86_400_000) + 1);
|
||||
}
|
||||
|
||||
export function buildRangeFromPreset(preset: DateRangePreset): ReportRange {
|
||||
const to = isoTodayTehran();
|
||||
if (preset === "7d") return { from: addDaysIso(to, -6), to, preset };
|
||||
if (preset === "30d") return { from: addDaysIso(to, -29), to, preset };
|
||||
if (preset === "90d") return { from: addDaysIso(to, -89), to, preset };
|
||||
return { from: addDaysIso(to, -6), to, preset: "7d" };
|
||||
}
|
||||
|
||||
export function previousPeriod(from: string, to: string): { from: string; to: string } {
|
||||
const len = daysBetweenInclusive(from, to);
|
||||
return {
|
||||
from: addDaysIso(from, -len),
|
||||
to: addDaysIso(from, -1),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatJalaliLabel(isoDate: string, locale: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : locale === "ar" ? "ar-SA" : "en-GB", {
|
||||
calendar: "persian",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "Asia/Tehran",
|
||||
}).format(new Date(`${isoDate}T12:00:00`));
|
||||
} catch {
|
||||
return isoDate;
|
||||
}
|
||||
}
|
||||
|
||||
export function percentChange(current: number, previous: number): number | null {
|
||||
if (previous === 0) return current === 0 ? 0 : 100;
|
||||
return ((current - previous) / previous) * 100;
|
||||
}
|
||||
|
||||
export type RangeTotals = {
|
||||
totalRevenue: number;
|
||||
totalOrders: number;
|
||||
avgOrderValue: number;
|
||||
netIncome: number;
|
||||
totalExpenses: number;
|
||||
cashRevenue: number;
|
||||
cardRevenue: number;
|
||||
creditRevenue: number;
|
||||
};
|
||||
|
||||
export function sumSnapshots(rows: DailyReportSnapshot[]): RangeTotals {
|
||||
const totalOrders = rows.reduce((s, r) => s + r.totalOrders, 0);
|
||||
const totalRevenue = rows.reduce((s, r) => s + r.totalRevenue, 0);
|
||||
return {
|
||||
totalRevenue,
|
||||
totalOrders,
|
||||
avgOrderValue: totalOrders > 0 ? totalRevenue / totalOrders : 0,
|
||||
netIncome: rows.reduce((s, r) => s + r.netIncome, 0),
|
||||
totalExpenses: rows.reduce((s, r) => s + r.totalExpenses, 0),
|
||||
cashRevenue: rows.reduce((s, r) => s + r.cashRevenue, 0),
|
||||
cardRevenue: rows.reduce((s, r) => s + r.cardRevenue, 0),
|
||||
creditRevenue: rows.reduce((s, r) => s + r.creditRevenue, 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function aggregateByDate(rows: DailyReportSnapshot[]): DailyReportSnapshot[] {
|
||||
const map = new Map<string, DailyReportSnapshot>();
|
||||
for (const r of rows) {
|
||||
const existing = map.get(r.date);
|
||||
if (!existing) {
|
||||
map.set(r.date, { ...r, branchId: "", topProducts: [...r.topProducts] });
|
||||
continue;
|
||||
}
|
||||
existing.totalRevenue += r.totalRevenue;
|
||||
existing.cashRevenue += r.cashRevenue;
|
||||
existing.cardRevenue += r.cardRevenue;
|
||||
existing.creditRevenue += r.creditRevenue;
|
||||
existing.totalOrders += r.totalOrders;
|
||||
existing.totalVoids += r.totalVoids;
|
||||
existing.voidAmount += r.voidAmount;
|
||||
existing.totalExpenses += r.totalExpenses;
|
||||
existing.netIncome += r.netIncome;
|
||||
existing.totalExpenses += r.totalExpenses;
|
||||
existing.topProducts = mergeTopProducts(existing.topProducts, r.topProducts);
|
||||
}
|
||||
const merged = Array.from(map.values());
|
||||
for (const m of merged) {
|
||||
m.avgOrderValue = m.totalOrders > 0 ? m.totalRevenue / m.totalOrders : 0;
|
||||
}
|
||||
return merged.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
export function mergeTopProducts(
|
||||
a: TopProductSnapshot[],
|
||||
b: TopProductSnapshot[]
|
||||
): TopProductSnapshot[] {
|
||||
const map = new Map<string, TopProductSnapshot>();
|
||||
for (const p of [...a, ...b]) {
|
||||
const cur = map.get(p.productId);
|
||||
if (!cur) {
|
||||
map.set(p.productId, { ...p });
|
||||
continue;
|
||||
}
|
||||
cur.quantity += p.quantity;
|
||||
cur.revenue += p.revenue;
|
||||
}
|
||||
return Array.from(map.values()).sort((x, y) => y.revenue - x.revenue);
|
||||
}
|
||||
|
||||
export function topProductsFromRange(rows: DailyReportSnapshot[], take = 10): TopProductSnapshot[] {
|
||||
return mergeTopProducts([], rows.flatMap((r) => r.topProducts)).slice(0, take);
|
||||
}
|
||||
|
||||
export function revenueChartPoints(
|
||||
rows: DailyReportSnapshot[],
|
||||
locale: string,
|
||||
rtl: boolean
|
||||
) {
|
||||
const sorted = [...rows].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const points = sorted.map((r) => ({
|
||||
date: r.date,
|
||||
label: formatJalaliLabel(r.date, locale),
|
||||
revenue: r.totalRevenue,
|
||||
}));
|
||||
return rtl ? [...points].reverse() : points;
|
||||
}
|
||||
|
||||
export function branchComparisonPoints(
|
||||
rows: DailyReportSnapshot[],
|
||||
branches: { id: string; name: string }[],
|
||||
locale: string,
|
||||
rtl: boolean
|
||||
) {
|
||||
const dates = Array.from(new Set(rows.map((r) => r.date))).sort();
|
||||
const points = dates.map((date) => {
|
||||
const entry: Record<string, string | number> = {
|
||||
date,
|
||||
label: formatJalaliLabel(date, locale),
|
||||
};
|
||||
for (const b of branches) {
|
||||
const row = rows.find((r) => r.date === date && r.branchId === b.id);
|
||||
entry[b.id] = row?.totalRevenue ?? 0;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
return rtl ? [...points].reverse() : points;
|
||||
}
|
||||
|
||||
const CHART_COLORS = ["#0F6E56", "#0C447C", "#BA7517", "#6366f1", "#ec4899", "#14b8a6"];
|
||||
|
||||
export function chartColor(index: number): string {
|
||||
return CHART_COLORS[index % CHART_COLORS.length]!;
|
||||
}
|
||||
|
||||
export function downloadReportsCsv(
|
||||
rows: DailyReportSnapshot[],
|
||||
branchNames: Map<string, string>,
|
||||
headers: {
|
||||
date: string;
|
||||
branch: string;
|
||||
totalRevenue: string;
|
||||
totalOrders: string;
|
||||
avgOrderValue: string;
|
||||
cashRevenue: string;
|
||||
cardRevenue: string;
|
||||
creditRevenue: string;
|
||||
netIncome: string;
|
||||
totalVoids: string;
|
||||
voidAmount: string;
|
||||
totalExpenses: string;
|
||||
},
|
||||
filename: string
|
||||
) {
|
||||
const cols = [
|
||||
headers.date,
|
||||
headers.branch,
|
||||
headers.totalRevenue,
|
||||
headers.totalOrders,
|
||||
headers.avgOrderValue,
|
||||
headers.cashRevenue,
|
||||
headers.cardRevenue,
|
||||
headers.creditRevenue,
|
||||
headers.netIncome,
|
||||
headers.totalVoids,
|
||||
headers.voidAmount,
|
||||
headers.totalExpenses,
|
||||
];
|
||||
const lines = rows.map((r) =>
|
||||
[
|
||||
r.date,
|
||||
branchNames.get(r.branchId) ?? r.branchId,
|
||||
r.totalRevenue,
|
||||
r.totalOrders,
|
||||
r.avgOrderValue,
|
||||
r.cashRevenue,
|
||||
r.cardRevenue,
|
||||
r.creditRevenue,
|
||||
r.netIncome,
|
||||
r.totalVoids,
|
||||
r.voidAmount,
|
||||
r.totalExpenses,
|
||||
].join(",")
|
||||
);
|
||||
const bom = "\uFEFF";
|
||||
const csv = bom + [cols.join(","), ...lines].join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { AuthTokenResponse } from "@/lib/api/types";
|
||||
|
||||
interface AdminAuthState {
|
||||
user: AuthTokenResponse | null;
|
||||
setAuth: (user: AuthTokenResponse) => void;
|
||||
clearAuth: () => void;
|
||||
isAuthenticated: () => boolean;
|
||||
}
|
||||
|
||||
export const useAdminAuthStore = create<AdminAuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
setAuth: (user) => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("meezi_admin_access_token", user.accessToken);
|
||||
localStorage.setItem("meezi_admin_refresh_token", user.refreshToken);
|
||||
}
|
||||
set({ user });
|
||||
},
|
||||
clearAuth: () => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("meezi_admin_access_token");
|
||||
localStorage.removeItem("meezi_admin_refresh_token");
|
||||
}
|
||||
set({ user: null });
|
||||
},
|
||||
isAuthenticated: () => !!get().user?.accessToken,
|
||||
}),
|
||||
{ name: "meezi_admin_auth" }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { AuthTokenResponse } from "@/lib/api/types";
|
||||
|
||||
interface AuthState {
|
||||
user: AuthTokenResponse | null;
|
||||
setAuth: (user: AuthTokenResponse) => void;
|
||||
clearAuth: () => void;
|
||||
isAuthenticated: () => boolean;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
setAuth: (user) => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("meezi_access_token", user.accessToken);
|
||||
localStorage.setItem("meezi_refresh_token", user.refreshToken);
|
||||
}
|
||||
set({ user });
|
||||
},
|
||||
clearAuth: () => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("meezi_access_token");
|
||||
localStorage.removeItem("meezi_refresh_token");
|
||||
}
|
||||
set({ user: null });
|
||||
},
|
||||
isAuthenticated: () => !!get().user?.accessToken,
|
||||
}),
|
||||
{ name: "meezi_auth" }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
interface BranchState {
|
||||
branchId: string | null;
|
||||
setBranchId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const useBranchStore = create<BranchState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
branchId: null,
|
||||
setBranchId: (branchId) => set({ branchId }),
|
||||
}),
|
||||
{ name: "meezi_branch" }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,205 @@
|
||||
import { create } from "zustand";
|
||||
import type { Customer, MenuItem, Order } from "@/lib/api/types";
|
||||
import { iranMobileForApi } from "@/lib/phone";
|
||||
|
||||
export interface CartItem {
|
||||
menuItem: MenuItem;
|
||||
quantity: number;
|
||||
notes?: string;
|
||||
orderItemId?: string;
|
||||
isVoided?: boolean;
|
||||
}
|
||||
|
||||
export interface AppliedCoupon {
|
||||
id: string;
|
||||
code: string;
|
||||
discountAmount: number;
|
||||
}
|
||||
|
||||
interface CartState {
|
||||
items: CartItem[];
|
||||
syncedQtyByMenuId: Record<string, number>;
|
||||
couponCode: string;
|
||||
appliedCoupon: AppliedCoupon | null;
|
||||
tableId: string | null;
|
||||
activeOrderId: string | null;
|
||||
customerId: string | null;
|
||||
guestName: string;
|
||||
guestPhone: string;
|
||||
getPendingLines: () => { menuItemId: string; quantity: number; notes?: string }[];
|
||||
addItem: (item: MenuItem) => void;
|
||||
removeItem: (menuItemId: string) => void;
|
||||
updateQty: (menuItemId: string, quantity: number) => void;
|
||||
setCouponCode: (code: string) => void;
|
||||
setAppliedCoupon: (coupon: AppliedCoupon | null) => void;
|
||||
clearCoupon: () => void;
|
||||
setTableId: (tableId: string | null) => void;
|
||||
setActiveOrderId: (orderId: string | null) => void;
|
||||
setGuestName: (name: string) => void;
|
||||
setGuestPhone: (phone: string) => void;
|
||||
setCustomer: (customer: Customer | null) => void;
|
||||
clearCustomer: () => void;
|
||||
hydrateFromOrder: (order: Order, menuById: Map<string, MenuItem>) => void;
|
||||
clearCart: () => void;
|
||||
clearSession: () => void;
|
||||
subtotal: () => number;
|
||||
}
|
||||
|
||||
const clearCouponState = {
|
||||
couponCode: "",
|
||||
appliedCoupon: null as AppliedCoupon | null,
|
||||
};
|
||||
|
||||
function orderLineToMenuItem(
|
||||
line: Order["items"][number],
|
||||
menuById: Map<string, MenuItem>
|
||||
): MenuItem {
|
||||
const existing = menuById.get(line.menuItemId);
|
||||
if (existing) return existing;
|
||||
return {
|
||||
id: line.menuItemId,
|
||||
categoryId: "",
|
||||
name: line.menuItemName,
|
||||
price: line.unitPrice,
|
||||
isAvailable: true,
|
||||
};
|
||||
}
|
||||
|
||||
export const useCartStore = create<CartState>((set, get) => ({
|
||||
items: [],
|
||||
syncedQtyByMenuId: {},
|
||||
couponCode: "",
|
||||
appliedCoupon: null,
|
||||
tableId: null,
|
||||
activeOrderId: null,
|
||||
customerId: null,
|
||||
guestName: "",
|
||||
guestPhone: "",
|
||||
|
||||
getPendingLines: () => {
|
||||
const { items, syncedQtyByMenuId } = get();
|
||||
const pending: { menuItemId: string; quantity: number; notes?: string }[] = [];
|
||||
for (const line of items) {
|
||||
const synced = syncedQtyByMenuId[line.menuItem.id] ?? 0;
|
||||
const delta = line.quantity - synced;
|
||||
if (delta > 0) {
|
||||
pending.push({
|
||||
menuItemId: line.menuItem.id,
|
||||
quantity: delta,
|
||||
notes: line.notes,
|
||||
});
|
||||
}
|
||||
}
|
||||
return pending;
|
||||
},
|
||||
|
||||
addItem: (menuItem) => {
|
||||
const existing = get().items.find((i) => i.menuItem.id === menuItem.id);
|
||||
if (existing) {
|
||||
set({
|
||||
items: get().items.map((i) =>
|
||||
i.menuItem.id === menuItem.id
|
||||
? { ...i, quantity: i.quantity + 1 }
|
||||
: i
|
||||
),
|
||||
...clearCouponState,
|
||||
});
|
||||
} else {
|
||||
set({ items: [...get().items, { menuItem, quantity: 1 }], ...clearCouponState });
|
||||
}
|
||||
},
|
||||
|
||||
removeItem: (menuItemId) =>
|
||||
set({
|
||||
items: get().items.filter((i) => i.menuItem.id !== menuItemId),
|
||||
...clearCouponState,
|
||||
}),
|
||||
|
||||
updateQty: (menuItemId, quantity) => {
|
||||
if (quantity <= 0) {
|
||||
get().removeItem(menuItemId);
|
||||
return;
|
||||
}
|
||||
set({
|
||||
items: get().items.map((i) =>
|
||||
i.menuItem.id === menuItemId ? { ...i, quantity } : i
|
||||
),
|
||||
...clearCouponState,
|
||||
});
|
||||
},
|
||||
|
||||
setCouponCode: (code) => set({ couponCode: code }),
|
||||
setAppliedCoupon: (coupon) => set({ appliedCoupon: coupon }),
|
||||
clearCoupon: () => set(clearCouponState),
|
||||
setTableId: (tableId) => set({ tableId }),
|
||||
setActiveOrderId: (activeOrderId) => set({ activeOrderId }),
|
||||
setGuestName: (guestName) =>
|
||||
set((s) => ({
|
||||
guestName,
|
||||
customerId: s.customerId && guestName !== s.guestName ? null : s.customerId,
|
||||
})),
|
||||
setGuestPhone: (guestPhone) =>
|
||||
set((s) => ({
|
||||
guestPhone,
|
||||
customerId: s.customerId && guestPhone !== s.guestPhone ? null : s.customerId,
|
||||
})),
|
||||
|
||||
setCustomer: (customer) =>
|
||||
set({
|
||||
customerId: customer?.id ?? null,
|
||||
guestName: customer?.name ?? "",
|
||||
guestPhone: customer?.phone
|
||||
? (iranMobileForApi(customer.phone) ?? customer.phone)
|
||||
: "",
|
||||
}),
|
||||
|
||||
clearCustomer: () => set({ customerId: null }),
|
||||
|
||||
hydrateFromOrder: (order, menuById) => {
|
||||
const syncedQtyByMenuId: Record<string, number> = {};
|
||||
for (const line of order.items) {
|
||||
syncedQtyByMenuId[line.menuItemId] = line.quantity;
|
||||
}
|
||||
set({
|
||||
activeOrderId: order.id,
|
||||
tableId: order.tableId ?? null,
|
||||
customerId: order.customerId ?? null,
|
||||
guestName: order.guestName ?? order.customerName ?? "",
|
||||
guestPhone: order.guestPhone ?? order.customerPhone ?? "",
|
||||
syncedQtyByMenuId,
|
||||
items: order.items.map((line) => ({
|
||||
menuItem: orderLineToMenuItem(line, menuById),
|
||||
quantity: line.quantity,
|
||||
notes: line.notes,
|
||||
orderItemId: line.id,
|
||||
isVoided: line.isVoided ?? false,
|
||||
})),
|
||||
...clearCouponState,
|
||||
});
|
||||
},
|
||||
|
||||
clearCart: () =>
|
||||
set({
|
||||
items: [],
|
||||
...clearCouponState,
|
||||
}),
|
||||
|
||||
clearSession: () =>
|
||||
set({
|
||||
items: [],
|
||||
syncedQtyByMenuId: {},
|
||||
tableId: null,
|
||||
activeOrderId: null,
|
||||
customerId: null,
|
||||
guestName: "",
|
||||
guestPhone: "",
|
||||
...clearCouponState,
|
||||
}),
|
||||
|
||||
subtotal: () =>
|
||||
get().items.reduce(
|
||||
(sum, i) =>
|
||||
i.isVoided ? sum : sum + i.menuItem.price * i.quantity,
|
||||
0
|
||||
),
|
||||
}));
|
||||
@@ -0,0 +1,11 @@
|
||||
const TERMINAL_KEY = "meezi_terminal_id";
|
||||
|
||||
export function getOrCreateTerminalId(): string {
|
||||
if (typeof window === "undefined") return "server";
|
||||
let id = localStorage.getItem(TERMINAL_KEY);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem(TERMINAL_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "next-intl";
|
||||
|
||||
export function useIsRtl() {
|
||||
const locale = useLocale();
|
||||
return locale !== "en";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Keep only ASCII digits (maps Persian/Arabic numerals). */
|
||||
export function normalizeOtpInput(value: string): string {
|
||||
const persian = "۰۱۲۳۴۵۶۷۸۹";
|
||||
const arabic = "٠١٢٣٤٥٦٧٨٩";
|
||||
let out = "";
|
||||
for (const ch of value) {
|
||||
if (ch >= "0" && ch <= "9") out += ch;
|
||||
else {
|
||||
const pi = persian.indexOf(ch);
|
||||
if (pi >= 0) {
|
||||
out += String(pi);
|
||||
continue;
|
||||
}
|
||||
const ai = arabic.indexOf(ch);
|
||||
if (ai >= 0) out += String(ai);
|
||||
}
|
||||
}
|
||||
return out.slice(0, 6);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
import { routing } from "./i18n/routing";
|
||||
|
||||
export default createMiddleware(routing);
|
||||
|
||||
export const config = {
|
||||
matcher: ["/", "/(fa|ar|en)/:path*"],
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ["class"],
|
||||
content: ["./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user