diff --git a/frontend/apps/artcraft-dashboard/src/App.tsx b/frontend/apps/artcraft-dashboard/src/App.tsx index a0884d332b..9d46f624a4 100644 --- a/frontend/apps/artcraft-dashboard/src/App.tsx +++ b/frontend/apps/artcraft-dashboard/src/App.tsx @@ -24,6 +24,7 @@ import { JobTokenSearch } from "@/pages/JobTokenSearch"; import { DebugLogs } from "@/pages/DebugLogs"; import { DebugLogsSearch } from "@/pages/DebugLogsSearch"; import { SpendEvents } from "@/pages/SpendEvents"; +import { ReengagementList } from "@/pages/ReengagementList"; import { UserSpendSummary } from "@/pages/UserSpendSummary"; import { UserSpendHistory } from "@/pages/UserSpendHistory"; import { NotFoundPage } from "@/pages/NotFound"; @@ -68,6 +69,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts b/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts index 85834842e2..deecb3f0a2 100644 --- a/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts +++ b/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts @@ -161,7 +161,54 @@ export interface UserDailySpend { updated_at: string; } +export interface ReengagementCandidate { + user_token: string; + username: string; + display_name: string; + email_gravatar_hash: string; + reengagement_score: number; + lifetime_net_spend_usd_cents: number; + maybe_last_payment_at: string | null; + maybe_days_since_last_payment: number | null; + maybe_weeks_since_last_spend: number | null; + is_active_subscriber: boolean; +} + export class ModerationApi extends ApiManager { + // Users to re-engage (lapsed spenders), highest re-engagement score first. + public async ListReengagementCandidates( + offset?: number | null, + payments_namespace?: string, + ): Promise< + ApiResponse<{ + candidates: ReengagementCandidate[]; + next_offset: number | null; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_spend_summaries/reengagement_list`; + return await this.get<{ + success: boolean; + candidates: ReengagementCandidate[]; + maybe_next_offset: number | null; + error_message?: string; + }>({ + endpoint, + query: { + offset: offset ?? undefined, + payments_namespace: payments_namespace ?? undefined, + }, + }) + .then((response) => ({ + success: response.success, + data: { + candidates: response.candidates || [], + next_offset: response.maybe_next_offset ?? null, + }, + errorMessage: response.error_message, + })) + .catch((err) => ({ success: false, errorMessage: err.message })); + } + // Full spend summary for one user. public async GetUserSpendSummary( user_token: string, diff --git a/frontend/apps/artcraft-dashboard/src/components/app-sidebar.tsx b/frontend/apps/artcraft-dashboard/src/components/app-sidebar.tsx index d25b5fd4ec..8f53c2d37f 100644 --- a/frontend/apps/artcraft-dashboard/src/components/app-sidebar.tsx +++ b/frontend/apps/artcraft-dashboard/src/components/app-sidebar.tsx @@ -11,6 +11,7 @@ import { IconKey, IconClipboardList, IconCoin, + IconHeartHandshake, IconMail, IconBellRinging, IconFileAi, @@ -84,6 +85,11 @@ const data = { url: "/spend-events", icon: IconCoin, }, + { + title: "Reengagement List", + url: "/reengagement-list", + icon: IconHeartHandshake, + }, { title: "Impersonation Logs", url: "/impersonation", diff --git a/frontend/apps/artcraft-dashboard/src/pages/ReengagementList.tsx b/frontend/apps/artcraft-dashboard/src/pages/ReengagementList.tsx new file mode 100644 index 0000000000..6af246cc0c --- /dev/null +++ b/frontend/apps/artcraft-dashboard/src/pages/ReengagementList.tsx @@ -0,0 +1,266 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { Link } from "react-router-dom"; +import { ModerationApi, type ReengagementCandidate } 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, + IconChartLine, + IconHeartHandshake, + IconReceipt2, + IconRefresh, + IconLoader2, +} from "@tabler/icons-react"; +import { usePageTitle } from "@/hooks/usePageTitle"; + +const COLUMN_COUNT = 7; + +const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); +const formatUsd = (cents: number) => usd.format(cents / 100); + +function formatDate(iso: string | null): string { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function scoreColor(score: number): string { + if (score >= 800) return "text-emerald-400"; + if (score >= 500) return "text-amber-400"; + return "text-muted-foreground"; +} + +export function ReengagementList() { + usePageTitle("Reengagement List"); + const [candidates, setCandidates] = 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.ListReengagementCandidates(offset); + if (cancelledRef.current) return; + if (resp.success && resp.data) { + setCandidates((prev) => + append ? [...prev, ...resp.data!.candidates] : resp.data!.candidates, + ); + setNextOffset(resp.data.next_offset); + } else { + setError(resp.errorMessage || "Failed to load reengagement list"); + } + } catch (err: any) { + if (!cancelledRef.current) + setError(err.message || "Failed to load reengagement list"); + } finally { + if (!cancelledRef.current) { + setIsLoading(false); + setIsLoadingMore(false); + } + } + }; + + const handleRefresh = () => { + setCandidates([]); + 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(); + + const headerRow = ( + + User + Score + Lifetime Net + Last Payment + Days Since + Weeks Since + Subscriber + + ); + + return ( + + + + + + Reengagement List + + + Lapsed spenders to win back, highest re-engagement score first + + + + + Refresh + + + + {error && ( + + + {error} + + )} + + + + {isLoading ? ( + + {headerRow} + + {Array.from({ length: 12 }).map((_, i) => ( + + {Array.from({ length: COLUMN_COUNT }).map((__, j) => ( + + + + ))} + + ))} + + + ) : candidates.length === 0 ? ( + + + No users to re-engage. + + ) : ( + + {headerRow} + + {candidates.map((c) => ( + + + + + @{c.username} + + + + + + | + + + + + + + + {c.reengagement_score} + + + {formatUsd(c.lifetime_net_spend_usd_cents)} + + + {formatDate(c.maybe_last_payment_at)} + + + {c.maybe_days_since_last_payment != null ? `${c.maybe_days_since_last_payment}d` : "—"} + + + {c.maybe_weeks_since_last_spend != null ? `${c.maybe_weeks_since_last_spend}w` : "—"} + + + {c.is_active_subscriber ? ( + + active + + ) : ( + — + )} + + + ))} + {nextOffset != null && ( + + + {isLoadingMore ? ( + + + Loading more... + + ) : ( + Scroll for more + )} + + + )} + + + )} + + + + ); +}
+ Lapsed spenders to win back, highest re-engagement score first +
No users to re-engage.