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,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>
);
}
+26
View File
@@ -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,
};
+75
View File
@@ -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 };
+25
View File
@@ -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} />;
}
+48
View File
@@ -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 };
+33
View File
@@ -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,
};
+19
View File
@@ -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 };
+22
View File
@@ -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} />
),
}}
/>
);
}
+11
View File
@@ -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}
/>
);
}