web2-client/src/pages/admin/sections/Stars.tsx

228 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
};