Files
meezi/web/admin/src/components/admin/admin-screens.tsx
T
soroush.asadi 1aaab6c593
CI/CD / CI · API (dotnet build + test) (push) Successful in 58s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 33s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 1m42s
fix(admin): integrations save uses rendered list (fixes dropped Zarinpal merchantId)
The integrations form rendered from  (gateways state, falling back to fetched data) but SAVED from the  state and edited via updateGateway on . If gateways hadn't hydrated, edits (e.g. Zarinpal merchantId) were written to an empty array and the save sent nothing. Now updateGateway seeds from fetched data on first edit, and the save maps over  — render, edit, and save share one source. NOTE: prod admin had also been stale because recent deploys aborted on the main-API crash before the admin containers restarted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 08:15:16 +03:30

1075 lines
38 KiB
TypeScript

"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 { cn } from "@/lib/utils";
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";
// iOS-style toggle switch used throughout this file
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return (
<button
type="button"
role="switch"
dir="ltr"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-[#0F6E56]" : "bg-muted-foreground/30"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out",
checked ? "translate-x-5" : "translate-x-0"
)}
/>
</button>
);
}
// Styled single-select indicator (replaces raw <input type="radio">).
function RadioDot({
selected,
onSelect,
disabled,
}: {
selected: boolean;
onSelect: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
role="radio"
aria-checked={selected}
disabled={disabled}
onClick={onSelect}
className={cn(
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
selected ? "border-[#0F6E56]" : "border-muted-foreground/40 hover:border-muted-foreground/70"
)}
>
{selected ? <span className="h-2.5 w-2.5 rounded-full bg-[#0F6E56]" /> : null}
</button>
);
}
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>
);
}
function PlanCard({ plan, onSave }: { plan: AdminPlan; onSave: (p: AdminPlan) => void }) {
const t = useTranslations("admin.plans");
const [price, setPrice] = useState(plan.monthlyPriceToman);
const [maxOrders, setMaxOrders] = useState(plan.limits.maxOrdersPerDay);
// Sync server values if they change (e.g. after successful save + refetch)
useEffect(() => { setPrice(plan.monthlyPriceToman); }, [plan.monthlyPriceToman]);
useEffect(() => { setMaxOrders(plan.limits.maxOrdersPerDay); }, [plan.limits.maxOrdersPerDay]);
const flush = () =>
onSave({ ...plan, monthlyPriceToman: price, limits: { ...plan.limits, maxOrdersPerDay: maxOrders } });
return (
<Card 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={price}
onChange={(e) => setPrice(Number(e.target.value))}
onBlur={flush}
/>
</label>
<label className="text-sm">
{t("maxOrders")}
<Input
type="number"
className="mt-1"
value={maxOrders}
onChange={(e) => setMaxOrders(Number(e.target.value))}
onBlur={flush}
/>
</label>
</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) => (
<PlanCard key={plan.tier} plan={plan} onSave={(p) => save.mutate(p)} />
))}
</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) => {
const isBool = s.value === "true" || s.value === "false";
return (
<Card key={s.id} className="rounded-xl border border-border/80 p-4">
<div className={isBool ? "flex items-center justify-between gap-3" : undefined}>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{s.key}</p>
{s.descriptionFa ? (
<p className="text-[11px] text-muted-foreground">{s.descriptionFa}</p>
) : null}
</div>
{isBool ? (
<Toggle
checked={s.value === "true"}
onChange={(v) => save.mutate({ key: s.key, value: String(v) })}
disabled={save.isPending}
/>
) : (
<Input
className="mt-2"
defaultValue={s.value}
onBlur={(e) => save.mutate({ key: s.key, value: e.target.value })}
/>
)}
</div>
</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 list = gateways.length > 0 ? gateways : data?.paymentGateways ?? [];
const save = useMutation({
mutationFn: () =>
adminPut<PlatformIntegrations>("/api/admin/integrations", {
activePaymentGateway: activeGateway,
// Save from `list` (what's rendered/edited), not `gateways` — if the
// gateways state hasn't hydrated, `list` falls back to the fetched data,
// and edits go through updateGateway which seeds it. This keeps the
// rendered, edited, and saved arrays the same source (was dropping
// edits like the Zarinpal merchantId when gateways was empty).
paymentGateways: list.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) => {
// Seed from fetched data on the first edit so an edit is never dropped
// because the state hadn't hydrated yet.
const base = prev.length > 0 ? prev : data?.paymentGateways?.map((g) => ({ ...g })) ?? [];
return base.map((g) => (g.id === id ? { ...g, ...patch } : g));
});
};
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">
<RadioDot
selected={activeGateway === g.id}
onSelect={() => setActiveGateway(g.id)}
/>
<span className="font-medium">{g.displayNameFa}</span>
{activeGateway === g.id ? (
<Badge className="bg-[#E1F5EE] text-[#0F6E56]">{t("active")}</Badge>
) : null}
</div>
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={g.isEnabled}
onChange={(v) => updateGateway(g.id, { isEnabled: v })}
/>
<span>{t("enabled")}</span>
</div>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Toggle
checked={g.sandbox}
onChange={(v) => updateGateway(g.id, { sandbox: v })}
/>
<span>{t("sandbox")}</span>
</div>
{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">
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={kavenegar.isEnabled}
onChange={(v) => setKavenegar((k) => ({ ...k, isEnabled: v }))}
/>
<span>{t("enabled")}</span>
</div>
<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>
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={openAi.isEnabled}
onChange={(v) => setOpenAi((o) => ({ ...o, isEnabled: v }))}
/>
<span>{t("enabled")}</span>
</div>
<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")}
<select
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
dir="ltr"
value={openAi.model}
onChange={(e) => setOpenAi((o) => ({ ...o, model: e.target.value }))}
>
<option value="gpt-4o-mini">gpt-4o-mini (fast, cheap)</option>
<option value="gpt-4o">gpt-4o (best quality)</option>
<option value="gpt-4-turbo">gpt-4-turbo</option>
<option value="gpt-4">gpt-4</option>
<option value="gpt-3.5-turbo">gpt-3.5-turbo (legacy)</option>
</select>
</label>
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={openAi.coffeeAdvisorEnabled}
onChange={(v) => setOpenAi((o) => ({ ...o, coffeeAdvisorEnabled: v }))}
/>
<span>{t("coffeeAdvisorEnabled")}</span>
</div>
</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>
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={meshy.isEnabled}
onChange={(v) => setMeshy((m) => ({ ...m, isEnabled: v }))}
/>
<span>{t("enabled")}</span>
</div>
<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>
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={meshy.menu3dEnabled}
onChange={(v) => setMeshy((m) => ({ ...m, menu3dEnabled: v }))}
/>
<span>{t("menu3dEnabled")}</span>
</div>
</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>
);
}