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 @@
|
||||
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*"],
|
||||
};
|
||||
Reference in New Issue
Block a user