Files
pargar/frontend/src/components/admin/invites-panel.tsx
T

511 lines
18 KiB
TypeScript
Raw Normal View History

"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { Copy, Loader2, Plus, Ban, Pause, Play, Pencil } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import type { RegistrationInvite } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { toast } from "@/components/providers";
import { formatFaNumber } from "@/lib/utils";
import {
DEFAULT_PAGE_SIZE,
ListPagination,
buildPageQuery,
type Paginated,
} from "@/components/admin/list-pagination";
import { ListToolbar } from "@/components/admin/list-toolbar";
import { validateInviteLabel } from "@/lib/validation";
const STATUS_LABEL: Record<RegistrationInvite["status"], string> = {
active: "فعال",
paused: "متوقف موقت",
revoked: "لغو شده",
exhausted: "تکمیل شده",
};
function inviteDisplayURL(inv: RegistrationInvite): string {
if (inv.url) return inv.url;
if (inv.token && typeof window !== "undefined") {
return `${window.location.origin}/register/invite/${inv.token}`;
}
return "";
}
function statusBadgeVariant(
status: RegistrationInvite["status"],
): "secondary" | "destructive" | "outline" {
if (status === "active") return "secondary";
if (status === "revoked") return "destructive";
return "outline";
}
export function InvitesPanel() {
const qc = useQueryClient();
const [maxUses, setMaxUses] = useState(10);
const [label, setLabel] = useState("");
const [expiresInDays, setExpiresInDays] = useState("");
const [busy, setBusy] = useState(false);
const [actionBusy, setActionBusy] = useState<number | null>(null);
const [editing, setEditing] = useState<RegistrationInvite | null>(null);
const [editLabel, setEditLabel] = useState("");
const [editMaxUses, setEditMaxUses] = useState(1);
const [editExpiresInDays, setEditExpiresInDays] = useState("");
const [editClearExpires, setEditClearExpires] = useState(false);
const [editBusy, setEditBusy] = useState(false);
const [page, setPage] = useState(1);
const [q, setQ] = useState("");
useEffect(() => {
setPage(1);
}, [q]);
const { data, isLoading } = useQuery({
queryKey: ["admin", "invites", q, page],
queryFn: () => {
const params = new URLSearchParams(buildPageQuery(page));
if (q.trim()) params.set("q", q.trim());
return http.get<Paginated<RegistrationInvite>>(`/api/admin/invites?${params.toString()}`);
},
});
const invites = useMemo(() => data?.items ?? [], [data]);
const total = data?.total ?? 0;
const pageSize = data?.pageSize ?? DEFAULT_PAGE_SIZE;
const create = async () => {
if (maxUses < 1 || maxUses > 10_000) {
toast.error("ظرفیت باید بین ۱ تا ۱۰۰۰۰ باشد");
return;
}
const labelErr = validateInviteLabel(label);
if (labelErr) {
toast.error(labelErr);
return;
}
setBusy(true);
try {
const body: { maxUses: number; label?: string; expiresInDays?: number } = {
maxUses,
};
if (label.trim()) body.label = label.trim();
const days = Number(expiresInDays);
if (expiresInDays.trim() && Number.isFinite(days) && days > 0) {
body.expiresInDays = Math.floor(days);
}
const res = await http.post<{ invite: RegistrationInvite }>("/api/admin/invites", body);
const url = inviteDisplayURL(res.invite);
if (url) {
try {
await navigator.clipboard.writeText(url);
toast.success("لینک ساخته و کپی شد");
} catch {
toast.success("لینک عضویت ساخته شد");
}
} else {
toast.success("لینک عضویت ساخته شد");
}
setLabel("");
setExpiresInDays("");
setPage(1);
qc.invalidateQueries({ queryKey: ["admin", "invites"] });
} catch (e) {
toast.error(toUserError(e));
} finally {
setBusy(false);
}
};
const copy = async (inv: RegistrationInvite) => {
const url = inviteDisplayURL(inv);
if (!url) {
toast.error("آدرس لینک در دسترس نیست");
return;
}
try {
await navigator.clipboard.writeText(url);
toast.success("لینک کپی شد");
} catch {
toast.error("کپی ممکن نشد");
}
};
const pause = async (id: number) => {
setActionBusy(id);
try {
await http.post(`/api/admin/invites/${id}/pause`, {});
toast.success("لینک موقتاً متوقف شد");
qc.invalidateQueries({ queryKey: ["admin", "invites"] });
} catch (e) {
toast.error(toUserError(e));
} finally {
setActionBusy(null);
}
};
const resume = async (id: number) => {
setActionBusy(id);
try {
await http.post(`/api/admin/invites/${id}/resume`, {});
toast.success("لینک دوباره فعال شد");
qc.invalidateQueries({ queryKey: ["admin", "invites"] });
} catch (e) {
toast.error(toUserError(e));
} finally {
setActionBusy(null);
}
};
const revoke = async (id: number) => {
setActionBusy(id);
try {
await http.post(`/api/admin/invites/${id}/revoke`, {});
toast.success("لینک لغو شد");
qc.invalidateQueries({ queryKey: ["admin", "invites"] });
} catch (e) {
toast.error(toUserError(e));
} finally {
setActionBusy(null);
}
};
const openEdit = (inv: RegistrationInvite) => {
setEditing(inv);
setEditLabel(inv.label ?? "");
setEditMaxUses(inv.maxUses);
setEditExpiresInDays("");
setEditClearExpires(false);
};
const saveEdit = async () => {
if (!editing) return;
if (editMaxUses < editing.usedCount) {
toast.error(`ظرفیت نمی‌تواند کمتر از ${formatFaNumber(editing.usedCount)} (استفاده‌شده) باشد`);
return;
}
if (editMaxUses < 1 || editMaxUses > 10_000) {
toast.error("ظرفیت باید بین ۱ تا ۱۰۰۰۰ باشد");
return;
}
const labelErr = validateInviteLabel(editLabel);
if (labelErr) {
toast.error(labelErr);
return;
}
setEditBusy(true);
try {
const body: {
label: string;
maxUses: number;
expiresInDays?: number;
clearExpires?: boolean;
} = {
label: editLabel.trim(),
maxUses: editMaxUses,
};
if (editClearExpires) {
body.clearExpires = true;
} else {
const days = Number(editExpiresInDays);
if (editExpiresInDays.trim() && Number.isFinite(days) && days > 0) {
body.expiresInDays = Math.floor(days);
}
}
await http.put(`/api/admin/invites/${editing.id}`, body);
toast.success("لینک به‌روز شد");
setEditing(null);
qc.invalidateQueries({ queryKey: ["admin", "invites"] });
} catch (e) {
toast.error(toUserError(e));
} finally {
setEditBusy(false);
}
};
return (
<div className="space-y-5">
<Card>
<CardHeader className="space-y-1 pb-3">
<CardTitle className="text-base">ساخت لینک جدید</CardTitle>
<p className="text-xs text-muted-foreground">
ظرفیت، برچسب و مهلت اختیاری — لینک را بعد از ساخت کپی کنید.
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="inv-max">ظرفیت (نفر)</Label>
<Input
id="inv-max"
type="number"
min={1}
max={10000}
value={maxUses}
onChange={(e) => setMaxUses(Number(e.target.value) || 1)}
dir="ltr"
className="text-left"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="inv-label">برچسب (اختیاری)</Label>
<Input
id="inv-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="مثلاً دوره بهار"
/>
</div>
<div className="space-y-2">
<Label htmlFor="inv-exp">مهلت روز (اختیاری)</Label>
<Input
id="inv-exp"
type="number"
min={1}
value={expiresInDays}
onChange={(e) => setExpiresInDays(e.target.value)}
dir="ltr"
className="text-left"
placeholder="—"
/>
</div>
</div>
<Button type="button" variant="gradient" disabled={busy} onClick={() => void create()}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
ساخت لینک
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="space-y-1 pb-3">
<CardTitle className="text-base">لینک‌های عضویت</CardTitle>
<p className="text-xs text-muted-foreground">مدیریت، کپی و توقف لینک‌های ساخته‌شده.</p>
</CardHeader>
<CardContent className="space-y-4">
<ListToolbar>
<Input
placeholder="جستجو با برچسب…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="max-w-xs"
/>
{q && (
<Button type="button" size="sm" variant="ghost" onClick={() => setQ("")}>
پاک کردن
</Button>
)}
</ListToolbar>
{isLoading && (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading && invites.length === 0 && (
<p className="py-6 text-center text-sm text-muted-foreground">
{q.trim() ? "نتیجه‌ای یافت نشد." : "هنوز لینک عضویتی ساخته نشده است."}
</p>
)}
<div className="space-y-2">
{invites.map((inv) => (
<div
key={inv.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border px-3 py-3"
>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium">
{inv.label || `لینک #${formatFaNumber(inv.id)}`}
</p>
<Badge variant={statusBadgeVariant(inv.status)}>{STATUS_LABEL[inv.status]}</Badge>
</div>
<p className="text-xs text-muted-foreground">
استفاده: {formatFaNumber(inv.usedCount)} / {formatFaNumber(inv.maxUses)}
{inv.status === "active" || inv.status === "paused"
? ` · باقی‌مانده: ${formatFaNumber(inv.remaining)}`
: null}
</p>
{inv.expiresAt && (
<p className="text-[11px] text-muted-foreground">
انقضا: {new Date(inv.expiresAt).toLocaleString("fa-IR")}
</p>
)}
{inv.token && (
<p className="truncate font-mono text-[11px] text-muted-foreground" dir="ltr">
{inviteDisplayURL(inv)}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={!inv.token && !inv.url}
onClick={() => void copy(inv)}
>
<Copy className="h-3.5 w-3.5" />
کپی
</Button>
{(inv.status === "active" || inv.status === "paused") && (
<Button
type="button"
size="sm"
variant="outline"
disabled={actionBusy === inv.id}
onClick={() => openEdit(inv)}
>
<Pencil className="h-3.5 w-3.5" />
ویرایش
</Button>
)}
{inv.status === "active" && (
<Button
type="button"
size="sm"
variant="secondary"
disabled={actionBusy === inv.id}
onClick={() => void pause(inv.id)}
>
{actionBusy === inv.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Pause className="h-3.5 w-3.5" />
)}
توقف موقت
</Button>
)}
{inv.status === "paused" && (
<Button
type="button"
size="sm"
variant="secondary"
disabled={actionBusy === inv.id}
onClick={() => void resume(inv.id)}
>
{actionBusy === inv.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Play className="h-3.5 w-3.5" />
)}
ازسرگیری
</Button>
)}
{(inv.status === "active" || inv.status === "paused") && (
<Button
type="button"
size="sm"
variant="destructive"
disabled={actionBusy === inv.id}
onClick={() => void revoke(inv.id)}
>
{actionBusy === inv.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Ban className="h-3.5 w-3.5" />
)}
لغو
</Button>
)}
</div>
</div>
))}
</div>
<ListPagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} />
</CardContent>
</Card>
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
<DialogContent className="max-w-md sm:rounded-2xl">
<DialogHeader className="text-start">
<DialogTitle>ویرایش لینک عضویت</DialogTitle>
<DialogDescription>
ظرفیت و برچسب را تغییر دهید. ظرفیت نمی‌تواند کمتر از تعداد استفاده‌شده باشد.
</DialogDescription>
</DialogHeader>
{editing && (
<div className="space-y-3 py-2">
<p className="text-xs text-muted-foreground">
استفاده‌شده: {formatFaNumber(editing.usedCount)} از {formatFaNumber(editing.maxUses)}
</p>
<div className="space-y-1.5">
<Label htmlFor="edit-label">برچسب</Label>
<Input
id="edit-label"
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
placeholder="مثلاً دوره بهار"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="edit-max">ظرفیت (نفر)</Label>
<Input
id="edit-max"
type="number"
min={editing.usedCount || 1}
max={10000}
value={editMaxUses}
onChange={(e) => setEditMaxUses(Number(e.target.value) || 1)}
dir="ltr"
className="text-left"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="edit-exp">مهلت جدید (روز از الان — اختیاری)</Label>
<Input
id="edit-exp"
type="number"
min={1}
value={editExpiresInDays}
onChange={(e) => {
setEditExpiresInDays(e.target.value);
if (e.target.value.trim()) setEditClearExpires(false);
}}
dir="ltr"
className="text-left"
placeholder={editing.expiresAt ? "بدون تغییر" : "—"}
disabled={editClearExpires}
/>
</div>
{editing.expiresAt && (
<label className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span>حذف مهلت انقضا</span>
<Switch
checked={editClearExpires}
onCheckedChange={(v) => {
setEditClearExpires(v);
if (v) setEditExpiresInDays("");
}}
aria-label="حذف مهلت انقضا"
/>
</label>
)}
</div>
)}
<DialogFooter className="gap-2 sm:space-x-0">
<Button type="button" variant="outline" onClick={() => setEditing(null)} disabled={editBusy}>
انصراف
</Button>
<Button type="button" variant="gradient" disabled={editBusy} onClick={() => void saveEdit()}>
{editBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
ذخیره
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}