228 lines
9.7 KiB
TypeScript
228 lines
9.7 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { useLocation } from "react-router-dom";
|
||
|
||
import { useAdminStars } from "~/shared/services/admin";
|
||
import { Section, PaginationControls, Badge } from "../components";
|
||
import { useAdminContext } from "../context";
|
||
import { formatDate, formatStars, numberFormatter } from "../utils/format";
|
||
|
||
export const AdminStarsPage = () => {
|
||
const { isAuthorized, handleRequestError } = useAdminContext();
|
||
const location = useLocation();
|
||
const initialSearch = useMemo(() => {
|
||
const params = new URLSearchParams(location.search);
|
||
return params.get("search") ?? "";
|
||
}, [location.search]);
|
||
const [search, setSearch] = useState(initialSearch);
|
||
const [paidFilter, setPaidFilter] = useState<"all" | "paid" | "unpaid">("all");
|
||
const [typeFilter, setTypeFilter] = useState("all");
|
||
const [page, setPage] = useState(0);
|
||
|
||
const limit = 50;
|
||
const normalizedSearch = search.trim();
|
||
|
||
const starsQuery = useAdminStars(
|
||
{
|
||
limit,
|
||
offset: page * limit,
|
||
search: normalizedSearch || undefined,
|
||
type: typeFilter !== "all" ? typeFilter : undefined,
|
||
paid: paidFilter === "all" ? undefined : paidFilter === "paid",
|
||
},
|
||
{
|
||
enabled: isAuthorized,
|
||
keepPreviousData: true,
|
||
refetchInterval: 60_000,
|
||
onError: (error) => handleRequestError(error, "Не удалось загрузить платежи"),
|
||
},
|
||
);
|
||
|
||
useEffect(() => {
|
||
setSearch(initialSearch);
|
||
}, [initialSearch]);
|
||
|
||
useEffect(() => {
|
||
setPage(0);
|
||
}, [normalizedSearch, paidFilter, typeFilter]);
|
||
|
||
if (starsQuery.isLoading && !starsQuery.data) {
|
||
return (
|
||
<Section id="stars" title="Платежи Stars" description="Телеграм Stars счета и связанные пользователи">
|
||
Загрузка…
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
if (!starsQuery.data) {
|
||
return (
|
||
<Section id="stars" title="Платежи Stars" description="Телеграм Stars счета и связанные пользователи">
|
||
<p className="text-sm text-slate-400">Выберите вкладку «Платежи Stars», чтобы загрузить данные.</p>
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
const { items, stats, total } = starsQuery.data;
|
||
const typeOptions = Object.keys(stats.by_type || {});
|
||
|
||
const statCards = [
|
||
{ label: "Всего платежей", value: numberFormatter.format(stats.total) },
|
||
{ label: "Оплачено", value: `${numberFormatter.format(stats.paid)} · ${formatStars(stats.amount_paid)}` },
|
||
{ label: "Неоплачено", value: `${numberFormatter.format(stats.unpaid)} · ${formatStars(stats.amount_unpaid)}` },
|
||
];
|
||
|
||
return (
|
||
<Section
|
||
id="stars"
|
||
title="Платежи Stars"
|
||
description="Телеграм Stars счета и связанные пользователи"
|
||
actions={
|
||
<button
|
||
type="button"
|
||
className="rounded-lg bg-slate-800 px-3 py-2 text-xs font-semibold text-slate-200 ring-1 ring-slate-700 transition hover:bg-slate-700"
|
||
onClick={() => starsQuery.refetch()}
|
||
disabled={starsQuery.isFetching}
|
||
>
|
||
{starsQuery.isFetching ? "Обновляем…" : "Обновить"}
|
||
</button>
|
||
}
|
||
>
|
||
<div className="grid gap-4 md:grid-cols-3">
|
||
{statCards.map((card) => (
|
||
<div key={card.label} className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800 shadow-inner shadow-black/40">
|
||
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
|
||
<p className="mt-2 text-lg font-semibold text-slate-100">{card.value}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mt-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Поиск</label>
|
||
<input
|
||
value={search}
|
||
onChange={(event) => setSearch(event.target.value)}
|
||
placeholder="ID, ссылка, контент"
|
||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 md:w-64"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Статус</label>
|
||
<select
|
||
value={paidFilter}
|
||
onChange={(event) => setPaidFilter(event.target.value as "all" | "paid" | "unpaid")}
|
||
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||
>
|
||
<option value="all">Все</option>
|
||
<option value="paid">Оплачено</option>
|
||
<option value="unpaid">Не оплачено</option>
|
||
</select>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Тип</label>
|
||
<select
|
||
value={typeFilter}
|
||
onChange={(event) => setTypeFilter(event.target.value)}
|
||
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||
>
|
||
<option value="all">Все</option>
|
||
{typeOptions.map((option) => (
|
||
<option key={`stars-type-${option}`} value={option}>
|
||
{option}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
className="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800"
|
||
onClick={() => {
|
||
setSearch("");
|
||
setPaidFilter("all");
|
||
setTypeFilter("all");
|
||
}}
|
||
>
|
||
Сбросить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
|
||
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
|
||
<tr>
|
||
<th className="px-3 py-3">Инвойс</th>
|
||
<th className="px-3 py-3">Сумма</th>
|
||
<th className="px-3 py-3">Пользователь</th>
|
||
<th className="px-3 py-3">Контент</th>
|
||
<th className="px-3 py-3">Создан</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-900/60">
|
||
{items.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={5} className="px-3 py-6 text-center text-sm text-slate-400">
|
||
Платежей не найдено.
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
items.map((invoice) => (
|
||
<tr key={`stars-${invoice.id}`} className="hover:bg-slate-900/40">
|
||
<td className="px-3 py-3 align-top">
|
||
<div className="font-semibold text-slate-100">
|
||
#{numberFormatter.format(invoice.id)} · {invoice.type ?? "—"}
|
||
</div>
|
||
<div className="text-xs text-slate-400 break-all">{invoice.external_id}</div>
|
||
{invoice.invoice_url ? (
|
||
<a
|
||
href={invoice.invoice_url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="text-xs text-sky-300 hover:text-sky-200"
|
||
>
|
||
Открыть счёт
|
||
</a>
|
||
) : null}
|
||
</td>
|
||
<td className="px-3 py-3 align-top">
|
||
<div className="font-semibold text-slate-100">{formatStars(invoice.amount)}</div>
|
||
<Badge tone={invoice.paid ? "success" : "warn"}>{invoice.status}</Badge>
|
||
</td>
|
||
<td className="px-3 py-3 align-top">
|
||
{invoice.user ? (
|
||
<div className="text-xs text-slate-300">
|
||
ID {numberFormatter.format(invoice.user.id)} · TG {numberFormatter.format(invoice.user.telegram_id)}
|
||
</div>
|
||
) : (
|
||
<div className="text-xs text-slate-500">—</div>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-3 align-top">
|
||
{invoice.content ? (
|
||
<>
|
||
<div className="text-xs text-slate-300">{invoice.content.title}</div>
|
||
<div className="break-all text-[10px] uppercase tracking-wide text-slate-500">
|
||
{invoice.content.hash}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="text-xs text-slate-500">—</div>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-3 align-top text-xs text-slate-400">{formatDate(invoice.created_at)}</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className="mt-4">
|
||
<PaginationControls total={total} limit={limit} page={page} onPageChange={setPage} />
|
||
</div>
|
||
</Section>
|
||
);
|
||
};
|