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;
|
||||
}
|
||||
Reference in New Issue
Block a user