diff --git a/frontend/apps/artcraft-dashboard/src/App.tsx b/frontend/apps/artcraft-dashboard/src/App.tsx index 3fcd83ea00..4054c2e898 100644 --- a/frontend/apps/artcraft-dashboard/src/App.tsx +++ b/frontend/apps/artcraft-dashboard/src/App.tsx @@ -23,6 +23,7 @@ import { JobInfo } from "@/pages/JobInfo"; import { JobTokenSearch } from "@/pages/JobTokenSearch"; import { DebugLogs } from "@/pages/DebugLogs"; import { DebugLogsSearch } from "@/pages/DebugLogsSearch"; +import { SpendEvents } from "@/pages/SpendEvents"; import { NotFoundPage } from "@/pages/NotFound"; import { Spinner } from "./components/ui/spinner"; import { ThemeProvider } from "@/components/theme-provider"; @@ -62,6 +63,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts b/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts index 23e94f082d..098da5c11c 100644 --- a/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts +++ b/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts @@ -84,7 +84,65 @@ export interface ModeratorUserLookupResponse { username_is_not_customized: boolean; } +export interface UserSpendEvent { + token: string; + payments_namespace: string; + maybe_user_token: string | null; + maybe_username: string | null; + maybe_display_name: string | null; + maybe_email_gravatar_hash: string | null; + event_type: string; + amount_usd_cents: number; + maybe_credits_granted: number | null; + payment_source: string; + maybe_source_object_id: string | null; + maybe_stripe_invoice_id: string | null; + maybe_stripe_payment_intent_id: string | null; + maybe_stripe_charge_id: string | null; + maybe_stripe_customer_id: string | null; + is_production: boolean; + payment_occurred_at: string; + created_at: string; +} + export class ModerationApi extends ApiManager { + // Spend events list (offset-paginated, newest payment first). + public async ListUserSpendEvents( + offset?: number | null, + payments_namespace?: string, + ): Promise< + ApiResponse<{ + events: UserSpendEvent[]; + next_offset: number | null; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_spend_events/list`; + return await this.get<{ + success: boolean; + events: UserSpendEvent[]; + maybe_next_offset: number | null; + error_message?: string; + }>({ + endpoint, + query: { + offset: offset ?? undefined, + payments_namespace: payments_namespace ?? undefined, + }, + }) + .then((response) => ({ + success: response.success, + data: { + events: response.events || [], + next_offset: response.maybe_next_offset ?? null, + }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + public async UserLookup( search: string, ): Promise> { diff --git a/frontend/apps/artcraft-dashboard/src/components/app-sidebar.tsx b/frontend/apps/artcraft-dashboard/src/components/app-sidebar.tsx index bbb3a7bd13..d25b5fd4ec 100644 --- a/frontend/apps/artcraft-dashboard/src/components/app-sidebar.tsx +++ b/frontend/apps/artcraft-dashboard/src/components/app-sidebar.tsx @@ -10,6 +10,7 @@ import { IconFlag, IconKey, IconClipboardList, + IconCoin, IconMail, IconBellRinging, IconFileAi, @@ -78,6 +79,11 @@ const data = { url: "/subscriber-signups", icon: IconCreditCard, }, + { + title: "Spend Events", + url: "/spend-events", + icon: IconCoin, + }, { title: "Impersonation Logs", url: "/impersonation", diff --git a/frontend/apps/artcraft-dashboard/src/pages/SpendEvents.tsx b/frontend/apps/artcraft-dashboard/src/pages/SpendEvents.tsx new file mode 100644 index 0000000000..9c07d8dbac --- /dev/null +++ b/frontend/apps/artcraft-dashboard/src/pages/SpendEvents.tsx @@ -0,0 +1,329 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { Link } from "react-router-dom"; +import { ModerationApi } from "@/api/ModerationApi"; +import type { UserSpendEvent } from "@/api/ModerationApi"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +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, + IconCoin, + IconRefresh, + IconLoader2, +} from "@tabler/icons-react"; +import { usePageTitle } from "@/hooks/usePageTitle"; + +const COLUMN_COUNT = 8; + +const usdFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", +}); + +function formatUsd(cents: number): string { + return usdFormatter.format(cents / 100); +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function stripeRef(event: UserSpendEvent): string | null { + return ( + event.maybe_stripe_payment_intent_id || + event.maybe_stripe_invoice_id || + event.maybe_stripe_charge_id || + event.maybe_source_object_id || + null + ); +} + +export function SpendEvents() { + usePageTitle("Spend Events"); + const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [nextOffset, setNextOffset] = useState(null); + + const cancelledRef = useRef(false); + const observerRef = useRef(null); + const sentinelRef = useRef(null); + + const loadData = async (offset?: number | null, append = false) => { + if (!append) { + setIsLoading(true); + setError(null); + } else { + setIsLoadingMore(true); + } + + try { + const modApi = new ModerationApi(); + const resp = await modApi.ListUserSpendEvents(offset); + + if (cancelledRef.current) return; + + if (resp.success && resp.data) { + setEvents((prev) => + append ? [...prev, ...resp.data!.events] : resp.data!.events, + ); + setNextOffset(resp.data.next_offset); + } else { + setError(resp.errorMessage || "Failed to load spend events"); + } + } catch (err: any) { + if (!cancelledRef.current) + setError(err.message || "Failed to load spend events"); + } finally { + if (!cancelledRef.current) { + setIsLoading(false); + setIsLoadingMore(false); + } + } + }; + + const handleRefresh = () => { + setEvents([]); + setNextOffset(null); + loadData(); + }; + + useEffect(() => { + cancelledRef.current = false; + loadData(); + return () => { + cancelledRef.current = true; + }; + }, []); + + const loadMore = useCallback(() => { + if (!isLoadingMore && nextOffset != null) { + loadData(nextOffset, true); + } + }, [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 ( +
+
+
+

+ + Spend Events +

+

+ Every payment, refund, and credit grant, newest first +

+
+ +
+ + {error && ( + + + {error} + + )} + +
+
+ {isLoading ? ( + + + + When + User + Type + Amount + Credits + Source + Stripe Ref + Env + + + + {Array.from({ length: 12 }).map((_, i) => ( + + {Array.from({ length: COLUMN_COUNT }).map((__, j) => ( + + + + ))} + + ))} + +
+ ) : events.length === 0 ? ( +
+ +

No spend events yet.

+
+ ) : ( + + + + When + User + Type + Amount + Credits + Source + Stripe Ref + Env + + + + {events.map((event) => { + const ref = stripeRef(event); + const isRefund = event.amount_usd_cents < 0; + return ( + + + {formatDate(event.payment_occurred_at)} + + + {event.maybe_username ? ( + + @{event.maybe_username} + + ) : ( + — + )} + {event.maybe_display_name && ( + + {event.maybe_display_name} + + )} + + + + {event.event_type} + + + + {formatUsd(event.amount_usd_cents)} + + + {event.maybe_credits_granted != null ? ( + event.maybe_credits_granted.toLocaleString() + ) : ( + — + )} + + + + {event.payment_source} + + + + {ref ? ( + + {ref} + + ) : ( + — + )} + + + {event.is_production ? ( + + prod + + ) : ( + + test + + )} + + + ); + })} + {nextOffset != null && ( + + + {isLoadingMore ? ( +
+ + Loading more... +
+ ) : ( + + Scroll for more + + )} +
+
+ )} +
+
+ )} +
+
+
+ ); +}