diff --git a/src/pages/admin/index.tsx b/src/pages/admin/index.tsx index 2261c1f..491c442 100644 --- a/src/pages/admin/index.tsx +++ b/src/pages/admin/index.tsx @@ -15,9 +15,12 @@ import { useAdminOverview, useAdminStatus, useAdminStorage, + useAdminStars, useAdminSyncSetLimits, useAdminSystem, useAdminUploads, + useAdminLicenses, + useAdminUsers, } from "~/shared/services/admin"; import type { AdminUploadsContent, AdminUploadsContentFlags } from "~/shared/services/admin"; import { clearAdminAuth, getAdminAuthSnapshot } from "~/shared/libs/admin-auth"; @@ -42,6 +45,11 @@ const formatBytes = (input?: number | null) => { return `${value.toFixed(value >= 10 || value < 0.1 ? 0 : 1)} ${units[unitIndex]}`; }; +const formatStars = (input?: number | null) => { + const value = Number(input ?? 0); + return `${numberFormatter.format(value)} ⭑`; +}; + const formatDate = (iso?: string | null) => { if (!iso) { return "—"; @@ -121,6 +129,55 @@ const Badge = ({ children, tone = "neutral" }: { children: ReactNode; tone?: Bad ); }; +const PaginationControls = ({ + total, + limit, + page, + onPageChange, +}: { + total: number; + limit: number; + page: number; + onPageChange: (next: number) => void; +}) => { + const safeLimit = Math.max(limit, 1); + const totalPages = Math.max(1, Math.ceil(Math.max(total, 0) / safeLimit)); + const clampedPage = Math.min(Math.max(page, 0), totalPages - 1); + const startIndex = total === 0 ? 0 : clampedPage * safeLimit + 1; + const endIndex = total === 0 ? 0 : Math.min(total, (clampedPage + 1) * safeLimit); + const canGoPrev = clampedPage > 0; + const canGoNext = clampedPage + 1 < totalPages; + + return ( +
+
+ {total === 0 + ? 'Нет записей' + : `Показаны ${numberFormatter.format(startIndex)}–${numberFormatter.format(endIndex)} из ${numberFormatter.format(total)}`} +
+
+ + Стр. {numberFormatter.format(clampedPage + 1)} из {numberFormatter.format(totalPages)} + +
+
+ ); +}; + type FlashMessage = { type: "success" | "error" | "info"; message: string; @@ -132,6 +189,9 @@ const ADMIN_SECTIONS = [ { id: "overview", label: "Обзор" }, { id: "storage", label: "Хранилище" }, { id: "uploads", label: "Загрузки" }, + { id: "users", label: "Пользователи" }, + { id: "licenses", label: "Лицензии" }, + { id: "stars", label: "Платежи Stars" }, { id: "system", label: "Система" }, { id: "blockchain", label: "Блокчейн" }, { id: "nodes", label: "Ноды" }, @@ -166,7 +226,545 @@ type UploadDecoration = { flags: AdminUploadsContentFlags | null; }; -const initialAuthState = getAdminAuthSnapshot().token ? "checking" : "unauthorized"; +const initialAuthState: AuthState = getAdminAuthSnapshot().token ? "checking" : "unauthorized"; + +type UsersSectionProps = { + query: ReturnType; + search: string; + onSearchChange: (value: string) => void; + page: number; + limit: number; + onPageChange: (next: number) => void; + onRefresh: () => void; +}; + +const UsersSection = ({ query, search, onSearchChange, page, limit, onPageChange, onRefresh }: UsersSectionProps) => { + if (query.isLoading && !query.data) { + return
Загрузка…
; + } + + if (!query.data) { + return ( +
+

Выберите вкладку «Пользователи», чтобы загрузить данные.

+
+ ); + } + + const { items, summary, total } = query.data; + const summaryCards = [ + { label: 'Всего пользователей', value: numberFormatter.format(total), helper: `На странице: ${numberFormatter.format(summary.users_returned)}` }, + { label: 'Кошельки (активные/всего)', value: `${numberFormatter.format(summary.wallets_active)} / ${numberFormatter.format(summary.wallets_total)}` }, + { label: 'Оплачено Stars', value: formatStars(summary.stars_amount_paid), helper: `${numberFormatter.format(summary.stars_paid)} платежей` }, + { label: 'Неоплачено', value: formatStars(summary.stars_amount_unpaid), helper: `${numberFormatter.format(summary.stars_unpaid)} счетов` }, + ]; + + return ( +
+ Обновить + + } + > +
+ {summaryCards.map((card) => ( +
+

{card.label}

+

{card.value}

+ {card.helper ?

{card.helper}

: null} +
+ ))} +
+ +
+
+ + onSearchChange(event.target.value)} + placeholder="ID, Telegram, username, адрес" + 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-72" + /> +
+
+ +
+
+ +
+ + + + + + + + + + + + + {items.length === 0 ? ( + + + + ) : ( + items.map((user) => ( + + + + + + + + + )) + )} + +
ПользовательКошелькиStarsЛицензииАктивностьДата
Нет пользователей, удовлетворяющих условиям.
+
{user.username ? `@${user.username}` : 'Без username'}
+
ID {numberFormatter.format(user.id)} · TG {numberFormatter.format(user.telegram_id)}
+ {(user.first_name || user.last_name) ? (
{(user.first_name ?? '')} {(user.last_name ?? '')}
) : null} +
+
Активных {numberFormatter.format(user.wallets.active_count)} / всего {numberFormatter.format(user.wallets.total_count)}
+
{user.wallets.primary_address ?? '—'}
+
+
{formatStars(user.stars.amount_total)}
+
Оплачено: {formatStars(user.stars.amount_paid)}
+
Неоплачено: {formatStars(user.stars.amount_unpaid)}
+
+
Всего {numberFormatter.format(user.licenses.total)}
+
Активных {numberFormatter.format(user.licenses.active)}
+
+ {user.ip_activity.last ? ( +
{user.ip_activity.last.ip ?? '—'} · {formatDate(user.ip_activity.last.seen_at)}
+ ) : ( +
Нет данных
+ )} +
+
Создан: {formatDate(user.created_at)}
+
Последний вход: {formatDate(user.last_use)}
+
+
+ +
+ +
+
+ ); +}; + +type LicensesSectionProps = { + query: ReturnType; + search: string; + onSearchChange: (value: string) => void; + statusFilter: string; + onStatusChange: (value: string) => void; + typeFilter: string; + onTypeChange: (value: string) => void; + licenseTypeFilter: string; + onLicenseTypeChange: (value: string) => void; + page: number; + limit: number; + onPageChange: (next: number) => void; + onRefresh: () => void; +}; + +const LicensesSection = ({ + query, + search, + onSearchChange, + statusFilter, + onStatusChange, + typeFilter, + onTypeChange, + licenseTypeFilter, + onLicenseTypeChange, + page, + limit, + onPageChange, + onRefresh, +}: LicensesSectionProps) => { + if (query.isLoading && !query.data) { + return
Загрузка…
; + } + + if (!query.data) { + return ( +
+

Выберите вкладку «Лицензии», чтобы загрузить данные.

+
+ ); + } + + const { items, counts, total } = query.data; + const statusOptions = Object.keys(counts.status || {}); + const typeOptions = Object.keys(counts.type || {}); + const kindOptions = Object.keys(counts.license_type || {}); + + return ( +
+ Обновить + + } + > +
+
+
+ + onSearchChange(event.target.value)} + placeholder="Адрес, владелец, hash" + 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" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+ + + + + + + + + + + + {items.length === 0 ? ( + + + + ) : ( + items.map((license) => ( + + + + + + + + )) + )} + +
ЛицензияOn-chainПользовательКонтентДата
Ничего не найдено.
+
#{numberFormatter.format(license.id)}
+
{license.type ?? '—'} · {license.status ?? '—'}
+
license_type: {license.license_type ?? '—'}
+
+ {license.onchain_address ? ( + + {license.onchain_address} + + ) : ( +
+ )} +
Владелец: {license.owner_address ?? '—'}
+
+ {license.user ? ( +
ID {numberFormatter.format(license.user.id)} · TG {numberFormatter.format(license.user.telegram_id)}
+ ) : ( +
+ )} +
+ {license.content ? ( + <> +
{license.content.title}
+
{license.content.hash}
+ + ) : ( +
+ )} +
+
Создано: {formatDate(license.created_at)}
+
Обновлено: {formatDate(license.updated_at)}
+
+
+ +
+ +
+
+ ); +}; + +type StarsSectionProps = { + query: ReturnType; + search: string; + onSearchChange: (value: string) => void; + paidFilter: 'all' | 'paid' | 'unpaid'; + onPaidChange: (value: 'all' | 'paid' | 'unpaid') => void; + typeFilter: string; + onTypeChange: (value: string) => void; + page: number; + limit: number; + onPageChange: (next: number) => void; + onRefresh: () => void; +}; + +const StarsSection = ({ + query, + search, + onSearchChange, + paidFilter, + onPaidChange, + typeFilter, + onTypeChange, + page, + limit, + onPageChange, + onRefresh, +}: StarsSectionProps) => { + if (query.isLoading && !query.data) { + return
Загрузка…
; + } + + if (!query.data) { + return ( +
+

Выберите вкладку «Платежи Stars», чтобы загрузить данные.

+
+ ); + } + + const { items, stats, total } = query.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 ( +
+ Обновить + + } + > +
+ {statCards.map((card) => ( +
+

{card.label}

+

{card.value}

+
+ ))} +
+ +
+
+
+ + onSearchChange(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" + /> +
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+ + + + + + + + + + + + {items.length === 0 ? ( + + + + ) : ( + items.map((invoice) => ( + + + + + + + + )) + )} + +
ИнвойсСуммаПользовательКонтентСоздан
Платежей не найдено.
+
#{numberFormatter.format(invoice.id)} · {invoice.type ?? '—'}
+
{invoice.external_id}
+ {invoice.invoice_url ? ( + + Открыть счёт + + ) : null} +
+
{formatStars(invoice.amount)}
+ {invoice.status} +
+ {invoice.user ? ( +
ID {numberFormatter.format(invoice.user.id)} · TG {numberFormatter.format(invoice.user.telegram_id)}
+ ) : ( +
+ )} +
+ {invoice.content ? ( + <> +
{invoice.content.title}
+
{invoice.content.hash}
+ + ) : ( +
+ )} +
{formatDate(invoice.created_at)}
+
+ +
+ +
+
+ ); +}; + export const AdminPage = () => { const queryClient = useQueryClient(); @@ -176,6 +774,25 @@ export const AdminPage = () => { const [flash, setFlash] = useState(null); const [uploadsFilter, setUploadsFilter] = useState("all"); const [uploadsSearch, setUploadsSearch] = useState(""); + const [usersPage, setUsersPage] = useState(0); + const [usersSearch, setUsersSearch] = useState(""); + const [licensesPage, setLicensesPage] = useState(0); + const [licensesSearch, setLicensesSearch] = useState(""); + const [licensesStatusFilter, setLicensesStatusFilter] = useState("all"); + const [licensesTypeFilter, setLicensesTypeFilter] = useState("all"); + const [licensesLicenseTypeFilter, setLicensesLicenseTypeFilter] = useState("all"); + const [starsPage, setStarsPage] = useState(0); + const [starsSearch, setStarsSearch] = useState(""); + const [starsPaidFilter, setStarsPaidFilter] = useState<'all' | 'paid' | 'unpaid'>('all'); + const [starsTypeFilter, setStarsTypeFilter] = useState("all"); + + const normalizedUploadsSearch = uploadsSearch.trim(); + const normalizedUsersSearch = usersSearch.trim(); + const normalizedLicensesSearch = licensesSearch.trim(); + const normalizedStarsSearch = starsSearch.trim(); + const isUploadsFilterActive = uploadsFilter !== "all"; + const hasUploadsSearch = normalizedUploadsSearch.length > 0; + const uploadsScanLimit = isUploadsFilterActive || hasUploadsSearch ? 200 : 100; useEffect(() => { if (!flash) { @@ -193,6 +810,18 @@ export const AdminPage = () => { } }, [authState, queryClient]); + useEffect(() => { + setUsersPage(0); + }, [normalizedUsersSearch]); + + useEffect(() => { + setLicensesPage(0); + }, [normalizedLicensesSearch, licensesStatusFilter, licensesTypeFilter, licensesLicenseTypeFilter]); + + useEffect(() => { + setStarsPage(0); + }, [normalizedStarsSearch, starsPaidFilter, starsTypeFilter]); + const overviewQuery = useAdminOverview({ enabled: authState !== "unauthorized", retry: false, @@ -211,10 +840,6 @@ export const AdminPage = () => { }); const isDataEnabled = authState === "authorized"; - const normalizedUploadsSearch = uploadsSearch.trim(); - const isUploadsFilterActive = uploadsFilter !== "all"; - const hasUploadsSearch = normalizedUploadsSearch.length > 0; - const uploadsScanLimit = isUploadsFilterActive || hasUploadsSearch ? 200 : 100; const storageQuery = useAdminStorage({ enabled: isDataEnabled, @@ -233,6 +858,55 @@ export const AdminPage = () => { keepPreviousData: true, }, ); + const usersLimit = 50; + const usersQuery = useAdminUsers( + { + limit: usersLimit, + offset: usersPage * usersLimit, + search: normalizedUsersSearch || undefined, + }, + { + enabled: isDataEnabled && activeSection === "users", + keepPreviousData: true, + refetchInterval: 60_000, + }, + ); + const licensesLimit = 50; + const licensesQuery = useAdminLicenses( + { + limit: licensesLimit, + offset: licensesPage * licensesLimit, + search: normalizedLicensesSearch || undefined, + type: licensesTypeFilter !== "all" ? licensesTypeFilter : undefined, + status: licensesStatusFilter !== "all" ? licensesStatusFilter : undefined, + license_type: licensesLicenseTypeFilter !== "all" ? licensesLicenseTypeFilter : undefined, + }, + { + enabled: isDataEnabled && activeSection === "licenses", + keepPreviousData: true, + refetchInterval: 60_000, + }, + ); + const starsLimit = 50; + const starsQuery = useAdminStars( + { + limit: starsLimit, + offset: starsPage * starsLimit, + search: normalizedStarsSearch || undefined, + type: starsTypeFilter !== "all" ? starsTypeFilter : undefined, + paid: + starsPaidFilter === 'all' + ? undefined + : starsPaidFilter === 'paid' + ? true + : false, + }, + { + enabled: isDataEnabled && activeSection === "stars", + keepPreviousData: true, + refetchInterval: 60_000, + }, + ); const systemQuery = useAdminSystem({ enabled: isDataEnabled, refetchInterval: 60_000, @@ -1239,6 +1913,76 @@ export const AdminPage = () => { ); }; + + const renderUsers = () => ( + { + setUsersSearch(value); + setUsersPage(0); + }} + page={usersPage} + limit={usersLimit} + onPageChange={setUsersPage} + onRefresh={() => usersQuery.refetch()} + /> + ); + + const renderLicenses = () => ( + { + setLicensesSearch(value); + setLicensesPage(0); + }} + statusFilter={licensesStatusFilter} + onStatusChange={(value) => { + setLicensesStatusFilter(value); + setLicensesPage(0); + }} + typeFilter={licensesTypeFilter} + onTypeChange={(value) => { + setLicensesTypeFilter(value); + setLicensesPage(0); + }} + licenseTypeFilter={licensesLicenseTypeFilter} + onLicenseTypeChange={(value) => { + setLicensesLicenseTypeFilter(value); + setLicensesPage(0); + }} + page={licensesPage} + limit={licensesLimit} + onPageChange={setLicensesPage} + onRefresh={() => licensesQuery.refetch()} + /> + ); + + const renderStars = () => ( + { + setStarsSearch(value); + setStarsPage(0); + }} + paidFilter={starsPaidFilter} + onPaidChange={(value) => { + setStarsPaidFilter(value); + setStarsPage(0); + }} + typeFilter={starsTypeFilter} + onTypeChange={(value) => { + setStarsTypeFilter(value); + setStarsPage(0); + }} + page={starsPage} + limit={starsLimit} + onPageChange={setStarsPage} + onRefresh={() => starsQuery.refetch()} + /> + ); const renderSystem = () => { if (!systemQuery.data) { return systemQuery.isLoading ? ( @@ -1640,6 +2384,9 @@ export const AdminPage = () => { {renderOverview()} {renderStorage()} {renderUploads()} + {renderUsers()} + {renderLicenses()} + {renderStars()} {renderSystem()} {renderBlockchain()} {renderNodes()} diff --git a/src/shared/services/admin/index.ts b/src/shared/services/admin/index.ts index f1d8afd..b94b3b1 100644 --- a/src/shared/services/admin/index.ts +++ b/src/shared/services/admin/index.ts @@ -210,6 +210,255 @@ export type AdminUploadsQueryParams = { type AdminUploadsQueryKey = ['admin', 'uploads', string | null, string | null, number | null, number | null]; +export type AdminUserWalletConnectionSummary = { + primary_address: string | null; + active_count: number; + total_count: number; + last_connected_at: string | null; + connections: Array<{ + id: number; + address: string; + network: string; + invalidated: boolean; + created_at: string | null; + updated_at: string | null; + }>; +}; + +export type AdminUserContentSummary = { + total: number; + onchain: number; + local: number; + disabled: number; +}; + +export type AdminUserLicensesSummary = { + total: number; + active: number; + by_type: Record; + by_status: Record; +}; + +export type AdminUserStarsSummary = { + total: number; + paid: number; + unpaid: number; + amount_total: number; + amount_paid: number; + amount_unpaid: number; +}; + +export type AdminUserIpActivity = { + last: { + ip: string | null; + type: string | null; + seen_at: string | null; + } | null; + unique_ips: number; + recent: Array<{ + ip: string | null; + type: string | null; + seen_at: string | null; + }>; +}; + +export type AdminUserItem = { + id: number; + telegram_id: number; + username: string | null; + first_name: string | null; + last_name: string | null; + lang_code: string | null; + created_at: string | null; + updated_at: string | null; + last_use: string | null; + meta: { + ref_id?: string | null; + referrer_id?: string | null; + }; + wallets: AdminUserWalletConnectionSummary; + content: AdminUserContentSummary; + licenses: AdminUserLicensesSummary; + stars: AdminUserStarsSummary; + ip_activity: AdminUserIpActivity; +}; + +export type AdminUsersSummary = { + users_returned: number; + wallets_total: number; + wallets_active: number; + licenses_total: number; + licenses_active: number; + stars_total: number; + stars_paid: number; + stars_unpaid: number; + stars_amount_total: number; + stars_amount_paid: number; + stars_amount_unpaid: number; + unique_ips_total: number; +}; + +export type AdminUsersResponse = { + total: number; + limit: number; + offset: number; + search?: string | null; + filters?: Record; + has_more: boolean; + items: AdminUserItem[]; + summary: AdminUsersSummary; +}; + +export type AdminUsersQueryParams = { + limit?: number; + offset?: number; + search?: string; +}; + +type AdminUsersQueryKey = ['admin', 'users', string]; + +export type AdminLicenseItem = { + id: number; + type: string | null; + status: string | null; + license_type: number | null; + onchain_address: string | null; + owner_address: string | null; + user: { + id: number; + telegram_id: number; + username: string | null; + first_name: string | null; + last_name: string | null; + } | null; + wallet_connection: { + id: number; + address: string; + network: string; + invalidated: boolean; + created_at: string | null; + updated_at: string | null; + } | null; + content: { + id: number | null; + hash: string | null; + cid: string | null; + title: string | null; + type: string | null; + owner_address: string | null; + onchain_index: number | null; + user_id: number | null; + download_url: string | null; + } | null; + created_at: string | null; + updated_at: string | null; + meta: Record; + links: { + tonviewer: string | null; + content_view: string | null; + }; +}; + +export type AdminLicensesCounts = { + status: Record; + type: Record; + license_type: Record; +}; + +export type AdminLicensesResponse = { + total: number; + limit: number; + offset: number; + search?: string | null; + filters?: Record; + has_more: boolean; + items: AdminLicenseItem[]; + counts: AdminLicensesCounts; +}; + +export type AdminLicensesQueryParams = { + limit?: number; + offset?: number; + search?: string; + type?: string | string[]; + status?: string | string[]; + license_type?: number | string | Array; + user_id?: number; + owner?: string; + address?: string; + content_hash?: string; +}; + +type AdminLicensesQueryKey = ['admin', 'licenses', string]; + +export type AdminStarsInvoiceItem = { + id: number; + external_id: string; + type: string | null; + amount: number; + paid: boolean; + invoice_url: string | null; + created_at: string | null; + status: 'paid' | 'pending'; + user: { + id: number; + telegram_id: number; + username: string | null; + first_name: string | null; + last_name: string | null; + } | null; + content: { + id: number | null; + hash: string | null; + cid: string | null; + title: string | null; + onchain_index: number | null; + owner_address: string | null; + user_id: number | null; + download_url: string | null; + } | null; +}; + +export type AdminStarsStats = { + total: number; + paid: number; + unpaid: number; + amount_total: number; + amount_paid: number; + amount_unpaid: number; + by_type: Record; +}; + +export type AdminStarsResponse = { + total: number; + limit: number; + offset: number; + search?: string | null; + filters?: Record; + has_more: boolean; + items: AdminStarsInvoiceItem[]; + stats: AdminStarsStats; +}; + +export type AdminStarsQueryParams = { + limit?: number; + offset?: number; + search?: string; + type?: string | string[]; + paid?: boolean; + user_id?: number; + content_hash?: string; +}; + +type AdminStarsQueryKey = ['admin', 'stars', string]; + export type AdminSystemResponse = { env: Record; service_config: Array<{ @@ -386,6 +635,144 @@ export const useAdminUploads = ( ); }; +export const useAdminUsers = ( + params?: AdminUsersQueryParams, + options?: QueryOptions, +) => { + const normalizedSearch = params?.search?.trim() || null; + const paramsKeyPayload = { + limit: params?.limit ?? null, + offset: params?.offset ?? null, + search: normalizedSearch, + }; + const paramsKey = JSON.stringify(paramsKeyPayload); + + const queryParams: Record = { + limit: params?.limit, + offset: params?.offset, + search: normalizedSearch ?? undefined, + }; + + return useQuery( + ['admin', 'users', paramsKey], + async () => { + const { data } = await request.get('/admin.users', { + params: queryParams, + }); + return data; + }, + { + ...defaultQueryOptions, + ...options, + }, + ); +}; + +export const useAdminLicenses = ( + params?: AdminLicensesQueryParams, + options?: QueryOptions, +) => { + const normalizedSearch = params?.search?.trim() || null; + const typeValue = Array.isArray(params?.type) + ? params.type.filter(Boolean).join(',') + : params?.type ?? null; + const statusValue = Array.isArray(params?.status) + ? params.status.filter(Boolean).join(',') + : params?.status ?? null; + const licenseTypeValue = Array.isArray(params?.license_type) + ? params.license_type.filter((value) => value !== undefined && value !== null).join(',') + : params?.license_type ?? null; + + const paramsKeyPayload = { + limit: params?.limit ?? null, + offset: params?.offset ?? null, + search: normalizedSearch, + type: typeValue, + status: statusValue, + license_type: licenseTypeValue, + user_id: params?.user_id ?? null, + owner: params?.owner ?? null, + address: params?.address ?? null, + content_hash: params?.content_hash ?? null, + }; + const paramsKey = JSON.stringify(paramsKeyPayload); + + const queryParams: Record = { + limit: params?.limit, + offset: params?.offset, + search: normalizedSearch ?? undefined, + type: typeValue ?? undefined, + status: statusValue ?? undefined, + license_type: licenseTypeValue ?? undefined, + user_id: params?.user_id, + owner: params?.owner ?? undefined, + address: params?.address ?? undefined, + content_hash: params?.content_hash ?? undefined, + }; + + return useQuery( + ['admin', 'licenses', paramsKey], + async () => { + const { data } = await request.get('/admin.licenses', { + params: queryParams, + }); + return data; + }, + { + ...defaultQueryOptions, + ...options, + }, + ); +}; + +export const useAdminStars = ( + params?: AdminStarsQueryParams, + options?: QueryOptions, +) => { + const normalizedSearch = params?.search?.trim() || null; + const typeValue = Array.isArray(params?.type) + ? params.type.filter(Boolean).join(',') + : params?.type ?? null; + + const paramsKeyPayload = { + limit: params?.limit ?? null, + offset: params?.offset ?? null, + search: normalizedSearch, + type: typeValue, + paid: typeof params?.paid === 'boolean' ? params.paid : null, + user_id: params?.user_id ?? null, + content_hash: params?.content_hash ?? null, + }; + const paramsKey = JSON.stringify(paramsKeyPayload); + + const queryParams: Record = { + limit: params?.limit, + offset: params?.offset, + search: normalizedSearch ?? undefined, + type: typeValue ?? undefined, + user_id: params?.user_id, + content_hash: params?.content_hash ?? undefined, + }; + + if (typeof params?.paid === 'boolean') { + queryParams.paid = params.paid ? 1 : 0; + } + + return useQuery( + ['admin', 'stars', paramsKey], + async () => { + const { data } = await request.get('/admin.stars', { + params: queryParams, + }); + return data; + }, + { + ...defaultQueryOptions, + ...options, + }, + ); +}; + export const useAdminSystem = ( options?: QueryOptions, ) => {