Compare commits

...
Author SHA1 Message Date
Brandon Thomas 9faa8521cd codegen 2026-07-03 04:51:17 -04:00
Brandon Thomas a71d2ebde2 new admin endpoint 2026-07-03 04:49:43 -04:00
Brandon Thomas b91ea1cdc3 tweak colors 2026-07-03 04:33:48 -04:00
16 changed files with 790 additions and 1 deletions
@@ -0,0 +1,113 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n e.maybe_user_token AS `user_token!: UserToken`,\n u.username AS `username!`,\n u.display_name AS `display_name!`,\n u.email_gravatar_hash AS `email_gravatar_hash!`,\n CAST(SUM(CASE WHEN e.amount_usd_cents > 0 THEN e.amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `gross_spend_usd_cents!: u64`,\n CAST(SUM(CASE WHEN e.amount_usd_cents < 0 THEN -e.amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `refund_usd_cents!: u64`,\n CAST(SUM(e.amount_usd_cents) AS SIGNED) AS `net_spend_usd_cents!: i64`,\n CAST(SUM(CASE WHEN e.amount_usd_cents > 0 THEN 1 ELSE 0 END) AS UNSIGNED) AS `payment_count!: u64`,\n CAST(COALESCE(SUM(e.maybe_credits_granted), 0) AS UNSIGNED) AS `credits_granted!: u64`\nFROM user_spend_events e\nJOIN users u ON u.token = e.maybe_user_token\nWHERE e.maybe_user_token IS NOT NULL\n AND e.is_production = TRUE\n AND e.payment_occurred_at >= ?\n AND (? IS NULL OR e.payments_namespace = ?)\nGROUP BY e.maybe_user_token, u.username, u.display_name, u.email_gravatar_hash\nORDER BY SUM(e.amount_usd_cents) DESC, e.maybe_user_token ASC\nLIMIT ? OFFSET ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_token!: UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "username!",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "display_name!",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "email_gravatar_hash!",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "gross_spend_usd_cents!: u64",
"type_info": {
"type": "LongLong",
"flags": "UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 5,
"name": "refund_usd_cents!: u64",
"type_info": {
"type": "LongLong",
"flags": "UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 6,
"name": "net_spend_usd_cents!: i64",
"type_info": {
"type": "LongLong",
"flags": "BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 7,
"name": "payment_count!: u64",
"type_info": {
"type": "LongLong",
"flags": "UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 8,
"name": "credits_granted!: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
}
],
"parameters": {
"Right": 5
},
"nullable": [
true,
false,
false,
false,
true,
true,
true,
true,
false
]
},
"hash": "c2ee41eb6bc1a6a551411998155423142411010f298f0815eacc7d9c3138bdd8"
}
@@ -1,5 +1,6 @@
pub mod alerts;
pub mod debug_logs;
pub mod top_spenders;
pub mod jobs;
pub mod user;
pub mod user_referrals;
@@ -0,0 +1,82 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::users::UserToken;
use utoipa::{IntoParams, ToSchema};
pub const MODERATOR_LIST_TOP_SPENDERS_PATH: &str =
"/v1/moderation/top_spenders/list";
/// Rolling aggregation window for the top spenders list.
#[derive(Deserialize, Serialize, Clone, Copy, ToSchema)]
pub enum TopSpendersWindow {
#[serde(rename = "24h")]
TwentyFourHours,
#[serde(rename = "7d")]
SevenDays,
#[serde(rename = "14d")]
FourteenDays,
#[serde(rename = "30d")]
ThirtyDays,
#[serde(rename = "60d")]
SixtyDays,
#[serde(rename = "90d")]
NinetyDays,
#[serde(rename = "180d")]
OneHundredEightyDays,
#[serde(rename = "365d")]
ThreeHundredSixtyFiveDays,
}
impl TopSpendersWindow {
/// Window length in hours.
pub fn as_hours(self) -> i64 {
match self {
TopSpendersWindow::TwentyFourHours => 24,
TopSpendersWindow::SevenDays => 7 * 24,
TopSpendersWindow::FourteenDays => 14 * 24,
TopSpendersWindow::ThirtyDays => 30 * 24,
TopSpendersWindow::SixtyDays => 60 * 24,
TopSpendersWindow::NinetyDays => 90 * 24,
TopSpendersWindow::OneHundredEightyDays => 180 * 24,
TopSpendersWindow::ThreeHundredSixtyFiveDays => 365 * 24,
}
}
}
#[derive(Deserialize, IntoParams)]
pub struct ModeratorListTopSpendersQueryParams {
/// Aggregation window: `24h`, `7d`, `14d`, `30d` (default), `60d`, `90d`,
/// `180d`, or `365d`.
pub window: Option<TopSpendersWindow>,
/// Row offset for pagination (page size is fixed at 100). Pass `next_offset` from the previous page.
pub offset: Option<u64>,
/// Optional namespace filter (e.g. `artcraft`). Omit to return all namespaces.
pub payments_namespace: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorListTopSpendersResponse {
pub success: bool,
/// The window the results were aggregated over (echoed back, post-default).
pub window: TopSpendersWindow,
pub spenders: Vec<TopSpenderEntry>,
/// Offset for the next page, or `None` if this was the last page.
pub maybe_next_offset: Option<u64>,
}
#[derive(Serialize, ToSchema)]
pub struct TopSpenderEntry {
pub user_token: UserToken,
pub username: String,
pub display_name: String,
pub email_gravatar_hash: String,
/// Sum of positive amounts in the window.
pub gross_spend_usd_cents: u64,
/// Refund/chargeback magnitude in the window (positive number).
pub refund_usd_cents: u64,
/// Gross minus refunds. Can be negative if refunds outweigh purchases.
pub net_spend_usd_cents: i64,
/// Number of positive money events in the window.
pub payment_count: u64,
/// Credits granted in the window.
pub credits_granted: u64,
}
@@ -0,0 +1 @@
pub mod list;
@@ -0,0 +1,78 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::{Executor, MySql};
use tokens::tokens::users::UserToken;
/// One user's aggregated spend within the requested window, joined to their
/// display info. Only attributed (non-null user), production events count.
pub struct TopSpenderForWindow {
pub user_token: UserToken,
pub username: String,
pub display_name: String,
pub email_gravatar_hash: String,
pub gross_spend_usd_cents: u64,
pub refund_usd_cents: u64,
pub net_spend_usd_cents: i64,
pub payment_count: u64,
pub credits_granted: u64,
}
pub struct ListTopSpendersForWindowArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
/// Inclusive start of the aggregation window.
pub window_start: DateTime<Utc>,
/// Optional namespace filter. `None` aggregates across all namespaces.
pub maybe_payments_namespace: Option<&'e str>,
pub limit: i64,
pub offset: i64,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Top spenders since `window_start`, sorted by net spend descending.
/// Aggregates `user_spend_events` directly (exact rolling windows, unlike the
/// daily rollups) — the table is payment-scale, so this stays cheap.
pub async fn list_top_spenders_for_window<'e, 'c: 'e, E>(
args: ListTopSpendersForWindowArgs<'e, 'c, E>,
) -> Result<Vec<TopSpenderForWindow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let rows = sqlx::query_as!(
TopSpenderForWindow,
r#"
SELECT
e.maybe_user_token AS `user_token!: UserToken`,
u.username AS `username!`,
u.display_name AS `display_name!`,
u.email_gravatar_hash AS `email_gravatar_hash!`,
CAST(SUM(CASE WHEN e.amount_usd_cents > 0 THEN e.amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `gross_spend_usd_cents!: u64`,
CAST(SUM(CASE WHEN e.amount_usd_cents < 0 THEN -e.amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `refund_usd_cents!: u64`,
CAST(SUM(e.amount_usd_cents) AS SIGNED) AS `net_spend_usd_cents!: i64`,
CAST(SUM(CASE WHEN e.amount_usd_cents > 0 THEN 1 ELSE 0 END) AS UNSIGNED) AS `payment_count!: u64`,
CAST(COALESCE(SUM(e.maybe_credits_granted), 0) AS UNSIGNED) AS `credits_granted!: u64`
FROM user_spend_events e
JOIN users u ON u.token = e.maybe_user_token
WHERE e.maybe_user_token IS NOT NULL
AND e.is_production = TRUE
AND e.payment_occurred_at >= ?
AND (? IS NULL OR e.payments_namespace = ?)
GROUP BY e.maybe_user_token, u.username, u.display_name, u.email_gravatar_hash
ORDER BY SUM(e.amount_usd_cents) DESC, e.maybe_user_token ASC
LIMIT ? OFFSET ?
"#,
args.window_start,
args.maybe_payments_namespace,
args.maybe_payments_namespace,
args.limit,
args.offset,
)
.fetch_all(args.mysql_executor)
.await?;
Ok(rows)
}
@@ -1,3 +1,4 @@
pub mod list_top_spenders_for_window;
pub mod list_user_spend_events_for_moderation;
pub mod backfill_get_user_token_by_email;
pub mod backfill_list_artcraft_customer_links;
@@ -137,6 +137,7 @@ use artcraft_api_defs::moderation::user_spend_summaries::get_summary::*;
use artcraft_api_defs::moderation::user_spend_summaries::reengagement_list::*;
use artcraft_api_defs::moderation::user_spend_summaries::top_users_list::*;
use artcraft_api_defs::moderation::user_daily_spends::user_daily_spends_list::*;
use artcraft_api_defs::moderation::top_spenders::list::*;
use artcraft_api_defs::moderation::user_spend_events::list::*;
use artcraft_api_defs::folders::subfolder::*;
use artcraft_api_defs::moderation::user_referrals::list_global_user_referrals::*;
@@ -396,6 +397,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::moderation::user_spend_summaries::moderator_reengagement_list_handler::moderator_reengagement_list_handler,
crate::http_server::endpoints::moderation::user_spend_summaries::moderator_top_users_list_handler::moderator_top_users_list_handler,
crate::http_server::endpoints::moderation::user_daily_spends::moderator_user_daily_spends_handler::moderator_user_daily_spends_handler,
crate::http_server::endpoints::moderation::top_spenders::moderator_list_top_spenders_handler::moderator_list_top_spenders_handler,
crate::http_server::endpoints::moderation::user_spend_events::moderator_list_user_spend_events_handler::moderator_list_user_spend_events_handler,
billing_component::stripe::http_endpoints::checkout::create::stripe_create_checkout_session_json_handler::stripe_create_checkout_session_json_handler,
crate::http_server::endpoints::billing_fakeyou::list_active_user_subscriptions_handler::list_active_user_subscriptions_handler,
@@ -656,6 +658,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
TopUserEntry,
TopUsersWindow,
ModeratorUserDailySpendsResponse,
ModeratorListTopSpendersResponse,
TopSpenderEntry,
TopSpendersWindow,
ModeratorListUserSpendEventsResponse,
UserSpendEventListEntry,
UserDailySpendEntry,
@@ -14,6 +14,7 @@ pub mod user_stripe_data;
pub mod wallet_ledger_entries;
pub mod wallets;
pub mod user_daily_spends;
pub mod top_spenders;
pub mod user_spend_events;
pub mod user_spend_summaries;
@@ -0,0 +1 @@
pub mod moderator_list_top_spenders_handler;
@@ -0,0 +1,93 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Query};
use actix_web::{web, HttpRequest};
use chrono::{Duration, Utc};
use log::warn;
use artcraft_api_defs::moderation::top_spenders::list::{
ModeratorListTopSpendersQueryParams, ModeratorListTopSpendersResponse, TopSpenderEntry, TopSpendersWindow,
};
use mysql_queries::queries::user_spend_events::list_top_spenders_for_window::{
list_top_spenders_for_window, ListTopSpendersForWindowArgs,
};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::user_lookup::user_session::require_moderator::require_moderator;
use crate::state::server_state::ServerState;
const PAGE_SIZE: i64 = 100;
const DEFAULT_WINDOW: TopSpendersWindow = TopSpendersWindow::ThirtyDays;
/// Top spenders aggregated over a rolling time window, biggest net spend first.
/// Moderators only.
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/top_spenders/list",
params(
ModeratorListTopSpendersQueryParams,
),
responses(
(status = 200, description = "Success", body = ModeratorListTopSpendersResponse),
(status = 400, description = "Invalid window"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn moderator_list_top_spenders_handler(
http_request: HttpRequest,
query: Query<ModeratorListTopSpendersQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorListTopSpendersResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let window = query.window.unwrap_or(DEFAULT_WINDOW);
let window_duration = Duration::hours(window.as_hours());
let offset = query.offset.unwrap_or(0);
let rows = list_top_spenders_for_window(ListTopSpendersForWindowArgs {
window_start: Utc::now() - window_duration,
maybe_payments_namespace: query.payments_namespace.as_deref(),
limit: PAGE_SIZE,
offset: offset.min(i64::MAX as u64) as i64,
mysql_executor: &server_state.mysql_pool,
phantom: PhantomData,
})
.await
.map_err(|err| {
warn!("Failed to list top spenders: {:?}", err);
CommonWebError::from_error(err)
})?;
let maybe_next_offset = if rows.len() as i64 == PAGE_SIZE {
Some(offset + PAGE_SIZE as u64)
} else {
None
};
let spenders = rows
.into_iter()
.map(|r| TopSpenderEntry {
user_token: r.user_token,
username: r.username,
display_name: r.display_name,
email_gravatar_hash: r.email_gravatar_hash,
gross_spend_usd_cents: r.gross_spend_usd_cents,
refund_usd_cents: r.refund_usd_cents,
net_spend_usd_cents: r.net_spend_usd_cents,
payment_count: r.payment_count,
credits_granted: r.credits_granted,
})
.collect();
Ok(Json(ModeratorListTopSpendersResponse {
success: true,
window,
spenders,
maybe_next_offset,
}))
}
@@ -53,6 +53,7 @@ use crate::http_server::endpoints::moderation::user_spend_summaries::moderator_g
use crate::http_server::endpoints::moderation::user_spend_summaries::moderator_reengagement_list_handler::moderator_reengagement_list_handler;
use crate::http_server::endpoints::moderation::user_spend_summaries::moderator_top_users_list_handler::moderator_top_users_list_handler;
use crate::http_server::endpoints::moderation::user_daily_spends::moderator_user_daily_spends_handler::moderator_user_daily_spends_handler;
use crate::http_server::endpoints::moderation::top_spenders::moderator_list_top_spenders_handler::moderator_list_top_spenders_handler;
use crate::http_server::endpoints::moderation::user_spend_events::moderator_list_user_spend_events_handler::moderator_list_user_spend_events_handler;
pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
@@ -81,6 +82,12 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/top_spenders")
.service(web::resource("/list")
.route(web::get().to(moderator_list_top_spenders_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/user_spend_events")
.service(web::resource("/list")
.route(web::get().to(moderator_list_user_spend_events_handler))
@@ -26,6 +26,7 @@ import { DebugLogsAll } from "@/pages/DebugLogsAll";
import { UserDebugLogs } from "@/pages/UserDebugLogs";
import { DebugLogsSearch } from "@/pages/DebugLogsSearch";
import { SpendEvents } from "@/pages/SpendEvents";
import { TopSpenders } from "@/pages/TopSpenders";
import { ReengagementList } from "@/pages/ReengagementList";
import { UserSpendSummary } from "@/pages/UserSpendSummary";
import { UserSpendHistory } from "@/pages/UserSpendHistory";
@@ -70,6 +71,7 @@ function AppRoutes() {
<Route path="stripe-lookup" element={<StripeLookup />} />
<Route path="user-signups" element={<UserSignups />} />
<Route path="subscriber-signups" element={<SubscriberSignups />} />
<Route path="top-spenders" element={<TopSpenders />} />
<Route path="spend-events" element={<SpendEvents />} />
<Route path="reengagement-list" element={<ReengagementList />} />
<Route path="impersonation" element={<Impersonation />} />
@@ -85,6 +85,18 @@ export interface ModeratorUserLookupResponse {
username_is_not_customized: boolean;
}
export interface TopSpender {
user_token: string;
username: string;
display_name: string;
email_gravatar_hash: string;
gross_spend_usd_cents: number;
refund_usd_cents: number;
net_spend_usd_cents: number;
payment_count: number;
credits_granted: number;
}
export interface UserSpendEvent {
token: string;
payments_namespace: string;
@@ -264,6 +276,43 @@ export class ModerationApi extends ApiManager {
.catch((err) => ({ success: false, errorMessage: err.message }));
}
// Top spenders over a rolling window (offset-paginated, biggest net spend first).
public async ListTopSpenders(
window: string,
offset?: number | null,
): Promise<
ApiResponse<{
spenders: TopSpender[];
next_offset: number | null;
}>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/top_spenders/list`;
return await this.get<{
success: boolean;
spenders: TopSpender[];
maybe_next_offset: number | null;
error_message?: string;
}>({
endpoint,
query: {
window,
offset: offset ?? undefined,
},
})
.then((response) => ({
success: response.success,
data: {
spenders: response.spenders || [],
next_offset: response.maybe_next_offset ?? null,
},
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
// Spend events list (offset-paginated, newest payment first).
public async ListUserSpendEvents(
offset?: number | null,
@@ -1,5 +1,6 @@
import * as React from "react";
import {
IconTrophy,
IconBrandStripe,
IconBriefcase,
IconBug,
@@ -63,6 +64,7 @@ const data = {
{
label: "Business Growth",
items: [
{ title: "Top Spenders", url: "/top-spenders", icon: IconTrophy },
{ title: "Spend Events", url: "/spend-events", icon: IconCoin },
{ title: "Reengagement List", url: "/reengagement-list", icon: IconHeartHandshake },
],
@@ -27,6 +27,25 @@ import { usePageTitle } from "@/hooks/usePageTitle";
const COLUMN_COUNT = 8;
// Distinct pill colors per payment event type (see enums payment_event_type.rs).
// Subscription lifecycle stays in the cool blue/green family; money-out events
// are warm (rose/red); staff actions are fuchsia so they stand out.
const EVENT_TYPE_BADGE_CLASSES: Record<string, string> = {
subscription_initial: "bg-green-500/10 text-green-300",
subscription_renewal: "bg-lime-500/10 text-lime-300",
subscription_proration_upgrade: "bg-cyan-500/10 text-cyan-300",
subscription_proration_downgrade: "bg-orange-500/10 text-orange-300",
subscription_monthly_refill: "bg-teal-500/10 text-teal-300",
credit_pack_purchase: "bg-indigo-500/10 text-indigo-300",
refund: "bg-rose-500/10 text-rose-300",
chargeback: "bg-red-500/15 text-red-400",
manual_adjustment: "bg-fuchsia-500/10 text-fuchsia-300",
};
// Neutral fallback for unknown/future event types, so indigo stays unique to
// credit_pack_purchase.
const DEFAULT_EVENT_TYPE_BADGE_CLASSES = "bg-slate-500/10 text-slate-300";
const usdFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
@@ -263,7 +282,10 @@ export function SpendEvents() {
<TableCell className="text-xs">
<Badge
variant="secondary"
className="text-[10px] font-mono bg-indigo-500/10 text-indigo-300 border-transparent"
className={`text-[10px] font-mono border-transparent ${
EVENT_TYPE_BADGE_CLASSES[event.event_type] ??
DEFAULT_EVENT_TYPE_BADGE_CLASSES
}`}
>
{event.event_type}
</Badge>
@@ -0,0 +1,331 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Link } from "react-router-dom";
import { ModerationApi } from "@/api/ModerationApi";
import type { TopSpender } from "@/api/ModerationApi";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import { useTableHeight } from "@/hooks/useTableHeight";
import {
IconAlertCircle,
IconChartLine,
IconReceipt2,
IconRefresh,
IconLoader2,
IconTrophy,
} from "@tabler/icons-react";
import { usePageTitle } from "@/hooks/usePageTitle";
const COLUMN_COUNT = 7;
const WINDOWS = [
{ value: "24h", label: "24 hours" },
{ value: "7d", label: "7 days" },
{ value: "14d", label: "14 days" },
{ value: "30d", label: "30 days" },
{ value: "60d", label: "60 days" },
{ value: "90d", label: "90 days" },
{ value: "180d", label: "180 days" },
{ value: "365d", label: "365 days" },
];
const DEFAULT_WINDOW = "30d";
const usdFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
function formatUsd(cents: number): string {
return usdFormatter.format(cents / 100);
}
export function TopSpenders() {
usePageTitle("Top Spenders");
const [spenders, setSpenders] = useState<TopSpender[]>([]);
const [window, setWindow] = useState(DEFAULT_WINDOW);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nextOffset, setNextOffset] = useState<number | null>(null);
const cancelledRef = useRef(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelRef = useRef<HTMLTableRowElement | null>(null);
const loadData = async (
timeWindow: string,
offset?: number | null,
append = false,
) => {
if (!append) {
setIsLoading(true);
setError(null);
} else {
setIsLoadingMore(true);
}
try {
const modApi = new ModerationApi();
const resp = await modApi.ListTopSpenders(timeWindow, offset);
if (cancelledRef.current) return;
if (resp.success && resp.data) {
setSpenders((prev) =>
append ? [...prev, ...resp.data!.spenders] : resp.data!.spenders,
);
setNextOffset(resp.data.next_offset);
} else {
setError(resp.errorMessage || "Failed to load top spenders");
}
} catch (err: any) {
if (!cancelledRef.current)
setError(err.message || "Failed to load top spenders");
} finally {
if (!cancelledRef.current) {
setIsLoading(false);
setIsLoadingMore(false);
}
}
};
const handleWindowChange = (value: string) => {
setWindow(value);
setSpenders([]);
setNextOffset(null);
loadData(value);
};
const handleRefresh = () => {
setSpenders([]);
setNextOffset(null);
loadData(window);
};
useEffect(() => {
cancelledRef.current = false;
loadData(DEFAULT_WINDOW);
return () => {
cancelledRef.current = true;
};
}, []);
const loadMore = useCallback(() => {
if (!isLoadingMore && nextOffset != null) {
loadData(window, nextOffset, true);
}
}, [window, nextOffset, isLoadingMore]);
useEffect(() => {
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore();
},
{ threshold: 0.1 },
);
if (sentinelRef.current) {
observerRef.current.observe(sentinelRef.current);
}
return () => observerRef.current?.disconnect();
}, [loadMore]);
const { ref: tableRef, height: tableHeight } = useTableHeight();
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<div className="flex-1 flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconTrophy className="size-6 text-muted-foreground" />
Top Spenders
</h1>
<p className="text-muted-foreground">
Users ranked by net spend over the selected window
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isLoading}
>
<IconRefresh className={`size-4 ${isLoading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{WINDOWS.map((w) => (
<Button
key={w.value}
variant={window === w.value ? "default" : "outline"}
size="sm"
className="h-7 px-2.5 text-xs"
onClick={() => handleWindowChange(w.value)}
disabled={isLoading && window === w.value}
>
{w.label}
</Button>
))}
</div>
{error && (
<Alert variant="destructive" className="max-w-xl">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex flex-col gap-4 flex-1 min-h-0">
<div ref={tableRef}>
{isLoading ? (
<Table containerClassName="rounded-xl border bg-card shadow-sm overflow-hidden">
<TableHeader>
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs w-12">#</TableHead>
<TableHead className="text-xs">User</TableHead>
<TableHead className="text-xs text-right">Net Spend</TableHead>
<TableHead className="text-xs text-right">Gross</TableHead>
<TableHead className="text-xs text-right">Refunds</TableHead>
<TableHead className="text-xs text-right">Payments</TableHead>
<TableHead className="text-xs text-right">Credits</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 12 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: COLUMN_COUNT }).map((__, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-20" />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
) : spenders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
<IconTrophy className="size-10 text-muted-foreground mb-4 opacity-20" />
<p className="text-muted-foreground">
No spend in this window.
</p>
</div>
) : (
<Table
containerClassName="rounded-xl border bg-card shadow-sm min-h-[200px]"
containerStyle={{ maxHeight: tableHeight ?? "60vh" }}
>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs w-12">#</TableHead>
<TableHead className="text-xs">User</TableHead>
<TableHead className="text-xs text-right">Net Spend</TableHead>
<TableHead className="text-xs text-right">Gross</TableHead>
<TableHead className="text-xs text-right">Refunds</TableHead>
<TableHead className="text-xs text-right">Payments</TableHead>
<TableHead className="text-xs text-right">Credits</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{spenders.map((spender, index) => (
<TableRow key={spender.user_token} className="group">
<TableCell className="text-sm text-muted-foreground tabular-nums">
{index + 1}
</TableCell>
<TableCell className="text-sm font-medium">
<div className="flex items-center gap-2">
<Link
to={`/user/profile/${spender.username}`}
className="hover:underline text-foreground"
>
@{spender.username}
</Link>
<span className="flex items-center gap-1.5">
<Link
to={`/user/spend-summary/${spender.username}`}
className="text-muted-foreground hover:text-foreground"
title="Spend summary"
>
<IconChartLine className="size-4" />
</Link>
<span className="text-muted-foreground/30">|</span>
<Link
to={`/user/spend-history/${spender.username}`}
className="text-muted-foreground hover:text-foreground"
title="Spend history"
>
<IconReceipt2 className="size-4" />
</Link>
</span>
</div>
</TableCell>
<TableCell
className={`text-sm text-right tabular-nums whitespace-nowrap font-medium ${
spender.net_spend_usd_cents < 0
? "text-red-400"
: "text-emerald-400"
}`}
>
{formatUsd(spender.net_spend_usd_cents)}
</TableCell>
<TableCell className="text-sm text-right tabular-nums text-muted-foreground whitespace-nowrap">
{formatUsd(spender.gross_spend_usd_cents)}
</TableCell>
<TableCell className="text-sm text-right tabular-nums whitespace-nowrap">
{spender.refund_usd_cents > 0 ? (
<span className="text-red-400">
{formatUsd(spender.refund_usd_cents)}
</span>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell className="text-sm text-right tabular-nums text-muted-foreground">
{spender.payment_count.toLocaleString()}
</TableCell>
<TableCell className="text-sm text-right tabular-nums text-muted-foreground">
{spender.credits_granted > 0 ? (
spender.credits_granted.toLocaleString()
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
</TableRow>
))}
{nextOffset != null && (
<TableRow ref={sentinelRef}>
<TableCell colSpan={COLUMN_COUNT} className="text-center py-4">
{isLoadingMore ? (
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<IconLoader2 className="size-4 animate-spin" />
Loading more...
</div>
) : (
<span className="text-muted-foreground/50">
Scroll for more
</span>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
</div>
</div>
);
}