Админка: страницы Тикеты + Мониторинг, роль support в навигации
TicketsPage.tsx — список open/closed тикетов + тред сообщений + ответ + закрытие (owner/moderator/support). ObservabilityPage.tsx — вкладки дашбордов Grafana через iframe за auth-гейтом Caddy, URL берётся из GET /api/v1/config (grafana_url), страница просто не показывает дашборды, пока GRAFANA_PUBLIC_URL не задан на сервере. StaffPage.tsx — форма создания support-аккаунта (только Owner). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { Loader2, Server, Users, Wallet, DollarSign, LogOut } from "lucide-react";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Loader2, Server, Users, Wallet, DollarSign, LogOut, LifeBuoy, Activity } from "lucide-react";
|
||||
import { fetchMyRole, logout, type StaffRole } from "./api";
|
||||
|
||||
const STAFF_ROLES = ["owner", "moderator", "partner"];
|
||||
const STAFF_ROLES = ["owner", "moderator", "partner", "support"];
|
||||
|
||||
export default function AdminLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [role, setRole] = useState<StaffRole | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -17,6 +18,11 @@ export default function AdminLayout() {
|
||||
navigate("/admin/login");
|
||||
return;
|
||||
}
|
||||
// Support не видит "Ноды" (индекс-роут /admin) — после логина сразу
|
||||
// на единственный раздел, который ему доступен.
|
||||
if (r.role === "support" && location.pathname === "/admin") {
|
||||
navigate("/admin/tickets", { replace: true });
|
||||
}
|
||||
setRole(r);
|
||||
})
|
||||
.catch(() => navigate("/admin/login"))
|
||||
@@ -66,6 +72,16 @@ export default function AdminLayout() {
|
||||
<Wallet className="w-4 h-4" /> Выводы
|
||||
</NavLink>
|
||||
)}
|
||||
{(role.role === "owner" || role.role === "moderator" || role.role === "support") && (
|
||||
<NavLink to="/admin/tickets" className={navClass}>
|
||||
<LifeBuoy className="w-4 h-4" /> Тикеты
|
||||
</NavLink>
|
||||
)}
|
||||
{(role.role === "owner" || role.role === "moderator") && (
|
||||
<NavLink to="/admin/observability" className={navClass}>
|
||||
<Activity className="w-4 h-4" /> Мониторинг
|
||||
</NavLink>
|
||||
)}
|
||||
{role.role === "partner" && (
|
||||
<NavLink to="/admin/earnings" className={navClass}>
|
||||
<DollarSign className="w-4 h-4" /> Доходы
|
||||
|
||||
@@ -12,6 +12,8 @@ const AdminLayout = lazy(() => import("./AdminLayout"));
|
||||
const StaffPage = lazy(() => import("./StaffPage"));
|
||||
const WithdrawalsPage = lazy(() => import("./WithdrawalsPage"));
|
||||
const EarningsPage = lazy(() => import("./EarningsPage"));
|
||||
const TicketsPage = lazy(() => import("./TicketsPage"));
|
||||
const ObservabilityPage = lazy(() => import("./ObservabilityPage"));
|
||||
|
||||
const DICT = {
|
||||
en: { loading: "Loading protocols..." },
|
||||
@@ -94,6 +96,8 @@ export default function App() {
|
||||
<Route path="staff" element={<StaffPage />} />
|
||||
<Route path="withdrawals" element={<WithdrawalsPage />} />
|
||||
<Route path="earnings" element={<EarningsPage />} />
|
||||
<Route path="tickets" element={<TicketsPage />} />
|
||||
<Route path="observability" element={<ObservabilityPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthHandler>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getPublicConfig } from "./api";
|
||||
|
||||
const DASHBOARDS = [
|
||||
{ uid: "netrunner-backend", title: "Backend" },
|
||||
{ uid: "netrunner-landing", title: "Лендинг" },
|
||||
{ uid: "netrunner-proxy-nodes", title: "Прокси-ноды (ядро)" },
|
||||
] as const;
|
||||
|
||||
export default function ObservabilityPage() {
|
||||
const [grafanaUrl, setGrafanaUrl] = useState<string | null | undefined>(undefined);
|
||||
const [active, setActive] = useState<(typeof DASHBOARDS)[number]["uid"]>(DASHBOARDS[0].uid);
|
||||
|
||||
useEffect(() => {
|
||||
getPublicConfig().then((cfg) => setGrafanaUrl(cfg.grafana_url));
|
||||
}, []);
|
||||
|
||||
if (grafanaUrl === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!grafanaUrl) {
|
||||
return (
|
||||
<div className="p-6 sm:p-10 max-w-4xl mx-auto">
|
||||
<p className="text-center py-20 text-muted-foreground text-sm uppercase tracking-widest">
|
||||
Grafana ещё не настроена (GRAFANA_PUBLIC_URL не задан на сервере).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 sm:p-10 max-w-6xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
|
||||
Мониторинг
|
||||
</h1>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{DASHBOARDS.map((d) => (
|
||||
<button
|
||||
key={d.uid}
|
||||
onClick={() => setActive(d.uid)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors ${
|
||||
active === d.uid
|
||||
? "bg-primary/10 text-primary border border-primary/30"
|
||||
: "bg-white/5 text-muted-foreground hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{d.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ключ на iframe меняет DOM-узел при смене дашборда — проще, чем
|
||||
городить логику перезагрузки src внутри одного и того же iframe. */}
|
||||
<iframe
|
||||
key={active}
|
||||
title={`Grafana: ${active}`}
|
||||
src={`${grafanaUrl}/d/${active}/?kiosk&theme=dark`}
|
||||
className="w-full rounded-2xl border border-white/10"
|
||||
style={{ height: "80vh" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { UserPlus, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
createModerator,
|
||||
createPartner,
|
||||
createSupport,
|
||||
fetchStaffUsers,
|
||||
setNodePermission,
|
||||
type StaffRole,
|
||||
@@ -23,6 +24,9 @@ export default function StaffPage() {
|
||||
const [partnerPassword, setPartnerPassword] = useState("");
|
||||
const [partnerPercent, setPartnerPercent] = useState("10");
|
||||
|
||||
const [supportUsername, setSupportUsername] = useState("");
|
||||
const [supportPassword, setSupportPassword] = useState("");
|
||||
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
@@ -68,6 +72,19 @@ export default function StaffPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSupport = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
await createSupport(supportUsername, supportPassword);
|
||||
setSupportUsername("");
|
||||
setSupportPassword("");
|
||||
await load();
|
||||
} catch {
|
||||
setError("Не удалось создать аккаунт саппорта (логин уже занят?).");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePermission = async (userId: string, current: boolean) => {
|
||||
try {
|
||||
await setNodePermission(userId, !current);
|
||||
@@ -135,6 +152,41 @@ export default function StaffPage() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{role.role === "owner" && (
|
||||
<form
|
||||
onSubmit={handleCreateSupport}
|
||||
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
|
||||
>
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-secondary flex items-center gap-2">
|
||||
<UserPlus className="w-4 h-4" /> Новый саппорт
|
||||
</h2>
|
||||
<input
|
||||
required
|
||||
placeholder="Логин"
|
||||
value={supportUsername}
|
||||
onChange={(e) => setSupportUsername(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
|
||||
/>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
value={supportPassword}
|
||||
onChange={(e) => setSupportPassword(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Видит только раздел "Тикеты" — доступа к нодам/staff/выводам нет.
|
||||
</p>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/30 font-bold uppercase tracking-widest text-xs py-2.5 rounded-lg transition-all"
|
||||
>
|
||||
Создать
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleCreatePartner}
|
||||
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCw, Send, CheckCircle2, MessageSquare } from "lucide-react";
|
||||
import {
|
||||
fetchTickets,
|
||||
fetchTicket,
|
||||
replyToTicket,
|
||||
closeTicket,
|
||||
type SupportTicket,
|
||||
type SupportTicketSummary,
|
||||
type SupportMessage,
|
||||
} from "./api";
|
||||
|
||||
type StatusFilter = "open" | "closed";
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [filter, setFilter] = useState<StatusFilter>("open");
|
||||
const [list, setList] = useState<SupportTicketSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [ticket, setTicket] = useState<SupportTicket | null>(null);
|
||||
const [messages, setMessages] = useState<SupportMessage[]>([]);
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const loadList = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setList(await fetchTickets(filter));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadList();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filter]);
|
||||
|
||||
const loadThread = async (id: string) => {
|
||||
setSelectedId(id);
|
||||
try {
|
||||
const { ticket, messages } = await fetchTicket(id);
|
||||
setTicket(ticket);
|
||||
setMessages(messages);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedId || !replyText.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await replyToTicket(selectedId, replyText);
|
||||
setReplyText("");
|
||||
await loadThread(selectedId);
|
||||
await loadList();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
if (!selectedId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await closeTicket(selectedId);
|
||||
setSelectedId(null);
|
||||
setTicket(null);
|
||||
setMessages([]);
|
||||
await loadList();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 sm:p-10 max-w-6xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
|
||||
Тикеты
|
||||
</h1>
|
||||
<button
|
||||
onClick={loadList}
|
||||
className="p-2 bg-white/5 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 text-secondary ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{(["open", "closed"] as StatusFilter[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => {
|
||||
setFilter(s);
|
||||
setSelectedId(null);
|
||||
setTicket(null);
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors ${
|
||||
filter === s
|
||||
? "bg-primary/10 text-primary border border-primary/30"
|
||||
: "bg-white/5 text-muted-foreground hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{s === "open" ? "Открытые" : "Закрытые"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)] gap-6">
|
||||
<div className="space-y-3">
|
||||
{list.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => loadThread(t.id)}
|
||||
className={`w-full text-left bg-black/60 border rounded-xl p-4 transition-colors ${
|
||||
selectedId === t.id
|
||||
? "border-primary/40"
|
||||
: "border-white/5 hover:border-white/10"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-bold text-sm">
|
||||
@{t.tg_username || t.tg_id || "аноним"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/60 font-mono">
|
||||
{new Date(t.updated_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{t.last_message_preview && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{t.last_message_preview}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground/60 mt-1">
|
||||
{t.message_count} сообщ.
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
{list.length === 0 && !loading && (
|
||||
<p className="text-center py-10 text-muted-foreground text-sm uppercase tracking-widest">
|
||||
Тикетов нет
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-black/40 border border-white/10 rounded-2xl p-6 flex flex-col min-h-[400px]">
|
||||
{!ticket ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||
<MessageSquare className="w-5 h-5 mr-2" /> Выберите тикет слева
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
Тикет {ticket.status === "open" ? "открыт" : "закрыт"}
|
||||
</span>
|
||||
{ticket.status === "open" && (
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 hover:bg-green-500/20 text-green-500 border border-green-500/20 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors disabled:opacity-40"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> Закрыть
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto max-h-[420px] pr-1">
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`max-w-[85%] rounded-xl p-3 text-sm ${
|
||||
m.sender_role === "staff"
|
||||
? "ml-auto bg-primary/10 border border-primary/20"
|
||||
: "bg-white/5 border border-white/10"
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{m.body}</p>
|
||||
<p className="text-[10px] text-muted-foreground/60 font-mono mt-1">
|
||||
{new Date(m.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{ticket.status === "open" && (
|
||||
<form onSubmit={handleReply} className="mt-4 flex items-end gap-2">
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="Ответ..."
|
||||
rows={2}
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary resize-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !replyText.trim()}
|
||||
className="p-2.5 bg-primary/10 hover:bg-primary/20 text-primary border border-primary/30 rounded-lg transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+82
-5
@@ -1,6 +1,13 @@
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1";
|
||||
|
||||
let configPromise: Promise<{ bot_username: string }> | null = null;
|
||||
interface PublicConfig {
|
||||
bot_username: string;
|
||||
/// URL Grafana за auth-гейтом Caddy — `null`, пока GRAFANA_PUBLIC_URL не
|
||||
/// задан на сервере (см. ObservabilityPage.tsx).
|
||||
grafana_url: string | null;
|
||||
}
|
||||
|
||||
let configPromise: Promise<PublicConfig> | null = null;
|
||||
|
||||
/// Юзернейм бота — намеренно не `import.meta.env.VITE_BOT_USERNAME`: этот SPA
|
||||
/// собирается один раз в CI, и один и тот же статический бандл раздаётся и
|
||||
@@ -8,12 +15,13 @@ let configPromise: Promise<{ bot_username: string }> | null = null;
|
||||
/// не различалось бы между стендами (ровно так и вышло на практике: dev видел
|
||||
/// прод-бота, у которого в BotFather другой домен — виджет логина отвечал
|
||||
/// "Bot domain invalid"). `/api/v1/config` читает env на сервере заново на
|
||||
/// каждый запрос, поэтому корректно отдаёт "свой" бот для каждого стенда.
|
||||
export function getPublicConfig(): Promise<{ bot_username: string }> {
|
||||
/// каждый запрос, поэтому корректно отдаёт "свой" бот (и Grafana URL) для
|
||||
/// каждого стенда.
|
||||
export function getPublicConfig(): Promise<PublicConfig> {
|
||||
if (!configPromise) {
|
||||
configPromise = fetch(`${API_BASE}/config`)
|
||||
.then((res) => res.json())
|
||||
.catch(() => ({ bot_username: "ntrnr_vpn_bot" }));
|
||||
.catch(() => ({ bot_username: "ntrnr_vpn_bot", grafana_url: null }));
|
||||
}
|
||||
return configPromise;
|
||||
}
|
||||
@@ -178,7 +186,7 @@ export const adminLogin = async (username: string, password: string) => {
|
||||
};
|
||||
|
||||
export interface StaffRole {
|
||||
role: "owner" | "moderator" | "partner" | "user" | "admin";
|
||||
role: "owner" | "moderator" | "partner" | "support" | "user" | "admin";
|
||||
can_manage_nodes: boolean;
|
||||
username: string | null;
|
||||
partner_code: string | null;
|
||||
@@ -206,6 +214,14 @@ export const createModerator = async (
|
||||
return parseOrThrow(res, "Failed to create moderator");
|
||||
};
|
||||
|
||||
export const createSupport = async (username: string, password: string) => {
|
||||
const res = await apiFetch("/admin/staff/support", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return parseOrThrow(res, "Failed to create support account");
|
||||
};
|
||||
|
||||
export const createPartner = async (
|
||||
username: string,
|
||||
password: string,
|
||||
@@ -303,6 +319,67 @@ export const requestWithdrawal = async (
|
||||
return parseOrThrow(res, "Failed to submit withdrawal request");
|
||||
};
|
||||
|
||||
// --- Тикеты техподдержки (Owner/Moderator/Support) ---
|
||||
|
||||
export interface SupportTicketSummary {
|
||||
id: string;
|
||||
user_id: string;
|
||||
tg_id: number | null;
|
||||
tg_username: string | null;
|
||||
status: "open" | "closed";
|
||||
message_count: number;
|
||||
last_message_preview: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SupportTicket {
|
||||
id: string;
|
||||
user_id: string;
|
||||
status: "open" | "closed";
|
||||
assigned_to: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SupportMessage {
|
||||
id: string;
|
||||
ticket_id: string;
|
||||
sender_role: "user" | "staff";
|
||||
sender_user_id: string | null;
|
||||
body: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const fetchTickets = async (
|
||||
status: "open" | "closed" | "all" = "open",
|
||||
): Promise<SupportTicketSummary[]> => {
|
||||
const res = await apiFetch(`/admin/support/tickets?status=${status}`);
|
||||
return parseOrThrow(res, "Failed to fetch tickets");
|
||||
};
|
||||
|
||||
export const fetchTicket = async (
|
||||
id: string,
|
||||
): Promise<{ ticket: SupportTicket; messages: SupportMessage[] }> => {
|
||||
const res = await apiFetch(`/admin/support/tickets/${id}`);
|
||||
return parseOrThrow(res, "Failed to fetch ticket");
|
||||
};
|
||||
|
||||
export const replyToTicket = async (id: string, text: string) => {
|
||||
const res = await apiFetch(`/admin/support/tickets/${id}/reply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
return parseOrThrow(res, "Failed to send reply");
|
||||
};
|
||||
|
||||
export const closeTicket = async (id: string) => {
|
||||
const res = await apiFetch(`/admin/support/tickets/${id}/close`, {
|
||||
method: "PATCH",
|
||||
});
|
||||
return parseOrThrow(res, "Failed to close ticket");
|
||||
};
|
||||
|
||||
export const logout = async () => {
|
||||
await fetch(`${API_BASE}/auth/logout`, {
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user