"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 = { 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(null); const [editing, setEditing] = useState(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(""); const [status, setStatus] = useState<"" | RegistrationInvite["status"]>(""); useEffect(() => { setPage(1); }, [q, status]); const { data, isLoading } = useQuery({ queryKey: ["admin", "invites", q, status, page], queryFn: () => { const params = new URLSearchParams(buildPageQuery(page)); if (q.trim()) params.set("q", q.trim()); if (status) params.set("status", status); return http.get>(`/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 (
ساخت لینک جدید

ظرفیت، برچسب و مهلت اختیاری — لینک را بعد از ساخت کپی کنید.

setMaxUses(Number(e.target.value) || 1)} dir="ltr" className="text-left" />
setLabel(e.target.value)} placeholder="مثلاً دوره بهار" />
setExpiresInDays(e.target.value)} dir="ltr" className="text-left" placeholder="—" />
لینک‌های عضویت

مدیریت، کپی و توقف لینک‌های ساخته‌شده.

{( [ ["", "همه"], ["active", "فعال"], ["paused", "متوقف"], ["exhausted", "تکمیل‌شده"], ["revoked", "لغو شده"], ] as const ).map(([value, label]) => ( ))}
setQ(e.target.value)} className="max-w-xs" /> {(q || status) && ( )}
{isLoading && (
)} {!isLoading && invites.length === 0 && (

{q.trim() || status ? "نتیجه‌ای یافت نشد." : "هنوز لینک عضویتی ساخته نشده است."}

)}
{invites.map((inv) => (

{inv.label || `لینک #${formatFaNumber(inv.id)}`}

{STATUS_LABEL[inv.status]}

استفاده: {formatFaNumber(inv.usedCount)} / {formatFaNumber(inv.maxUses)} {inv.status === "active" || inv.status === "paused" ? ` · باقی‌مانده: ${formatFaNumber(inv.remaining)}` : null}

{inv.expiresAt && (

انقضا: {new Date(inv.expiresAt).toLocaleString("fa-IR")}

)} {inv.token && (

{inviteDisplayURL(inv)}

)}
{(inv.status === "active" || inv.status === "paused") && ( )} {inv.status === "active" && ( )} {inv.status === "paused" && ( )} {(inv.status === "active" || inv.status === "paused") && ( )}
))}
!o && setEditing(null)}> ویرایش لینک عضویت ظرفیت و برچسب را تغییر دهید. ظرفیت نمی‌تواند کمتر از تعداد استفاده‌شده باشد. {editing && (

استفاده‌شده: {formatFaNumber(editing.usedCount)} از {formatFaNumber(editing.maxUses)}

setEditLabel(e.target.value)} placeholder="مثلاً دوره بهار" />
setEditMaxUses(Number(e.target.value) || 1)} dir="ltr" className="text-left" />
{ setEditExpiresInDays(e.target.value); if (e.target.value.trim()) setEditClearExpires(false); }} dir="ltr" className="text-left" placeholder={editing.expiresAt ? "بدون تغییر" : "—"} disabled={editClearExpires} />
{editing.expiresAt && ( )}
)}
); }