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:
soroush.asadi
2026-05-28 18:45:57 +03:30
parent f717c02467
commit 0a33497d40
98 changed files with 17848 additions and 0 deletions
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from "@/i18n/routing";
export default function AdminLocaleHomePage({
params: { locale },
}: {
params: { locale: string };
}) {
redirect({ href: "/admin", locale });
}