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
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>
1075 lines
38 KiB
TypeScript
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>
|
|
);
|
|
}
|