payment informatics 4 (#1707)
This commit is contained in:
1 parent
35add74498
commit
b3311d1574
11 files changed
+565
-145
No files matched your search
@@ -138,6 +138,7 @@ RUN SQLX_OFFLINE=true \
|
||||
$HOME/.cargo/bin/cargo build \
|
||||
--release \
|
||||
--bin storyteller-web \
|
||||
--bin user-spend-analytics-job \
|
||||
--bin seedance2-pro-job \
|
||||
--bin worldlabs-job \
|
||||
--bin video-thumbnail-job \
|
||||
@@ -190,6 +191,7 @@ COPY --from=builder /tmp/target/release/analytics-job /
|
||||
COPY --from=builder /tmp/target/release/email-sender-job /
|
||||
COPY --from=builder /tmp/target/release/es-update-job /
|
||||
COPY --from=builder /tmp/target/release/grok-api-job /
|
||||
COPY --from=builder /tmp/target/release/user-spend-analytics-job /
|
||||
|
||||
# Legacy apps:
|
||||
# COPY --from=builder /tmp/target/release/tts-download-job /
|
||||
|
||||
@@ -28,6 +28,8 @@ use crate::job::reengagement_score::reengagement_score;
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
|
||||
const DAYS_PER_WEEK: i64 = 7;
|
||||
/// Emit a progress line every N users during the (long, otherwise silent) summaries pass.
|
||||
const SUMMARY_LOG_EVERY: u64 = 200;
|
||||
|
||||
/// Recompute `user_spend_summaries` for every (user, namespace) with spend
|
||||
/// activity. Window/cadence/score fields are time-relative, so all users are
|
||||
@@ -66,8 +68,10 @@ pub async fn backfill_user_summaries(deps: &JobDependencies) {
|
||||
}
|
||||
|
||||
async fn backfill_namespace(deps: &JobDependencies, namespace: PaymentsNamespace, now: DateTime<Utc>) -> u64 {
|
||||
info!("User-summaries: backfilling namespace '{}'…", namespace.to_str());
|
||||
let mut after_user_token = String::new();
|
||||
let mut upserted = 0u64;
|
||||
let mut processed = 0u64;
|
||||
|
||||
loop {
|
||||
if deps.application_shutdown.get() {
|
||||
@@ -115,12 +119,23 @@ async fn backfill_namespace(deps: &JobDependencies, namespace: PaymentsNamespace
|
||||
tokio::time::sleep(deps.error_recovery).await;
|
||||
}
|
||||
}
|
||||
processed += 1;
|
||||
if processed % SUMMARY_LOG_EVERY == 0 {
|
||||
info!(
|
||||
" user-summaries [{}]: {processed} processed, {upserted} upserted so far…",
|
||||
namespace.to_str(),
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(deps.query_delay).await;
|
||||
}
|
||||
|
||||
after_user_token = user_tokens.last().expect("non-empty page").as_str().to_string();
|
||||
}
|
||||
|
||||
info!(
|
||||
"User-summaries: namespace '{}' done — {processed} processed, {upserted} upserted.",
|
||||
namespace.to_str(),
|
||||
);
|
||||
upserted
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<Route path="user-signups" element={<UserSignups />} />
|
||||
<Route path="subscriber-signups" element={<SubscriberSignups />} />
|
||||
<Route path="spend-events" element={<SpendEvents />} />
|
||||
<Route path="reengagement-list" element={<ReengagementList />} />
|
||||
<Route path="impersonation" element={<Impersonation />} />
|
||||
<Route path="staff-audit-logs" element={<StaffAuditLogs />} />
|
||||
<Route path="send-pager" element={<SendPager />} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ModerationApi,
|
||||
type ModeratorStripeCustomerIdSource,
|
||||
type ModeratorUserStripeCustomerIdEntry,
|
||||
} from "@/api/ModerationApi";
|
||||
import { formatNamespace, stripeCustomerDashboardUrl } from "@/lib/stripe";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconCreditCard,
|
||||
IconExternalLink,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
const STRIPE_SOURCE_LABELS: Record<ModeratorStripeCustomerIdSource, string> = {
|
||||
customer_link: "Customer Link",
|
||||
subscription: "Subscription",
|
||||
};
|
||||
|
||||
/// The user's Stripe billing block (customer IDs across namespaces, their sources
|
||||
/// — customer links + subscriptions — dashboard links, and a drift warning when a
|
||||
/// namespace maps to more than one customer id). Self-contained: fetches its own data.
|
||||
export function UserBillingSection({ userToken }: { userToken: string }) {
|
||||
const [customerIds, setCustomerIds] = useState<ModeratorUserStripeCustomerIdEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
new ModerationApi()
|
||||
.GetUserStripeCustomerIds(userToken)
|
||||
.then((resp) => {
|
||||
if (cancelled) return;
|
||||
if (resp.success && resp.data) setCustomerIds(resp.data.customer_ids);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userToken]);
|
||||
|
||||
const copyToClipboard = (text: string, id: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedStates((prev) => ({ ...prev, [id]: true }));
|
||||
setTimeout(() => {
|
||||
setCopiedStates((prev) => ({ ...prev, [id]: false }));
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
|
||||
// One row per unique customer id, with all the (namespace, source) pairs it was found under.
|
||||
const stripeCustomers = customerIds.reduce<
|
||||
{ id: string; entries: ModeratorUserStripeCustomerIdEntry[] }[]
|
||||
>((acc, entry) => {
|
||||
const group = acc.find((g) => g.id === entry.stripe_customer_id);
|
||||
if (group) {
|
||||
group.entries.push(entry);
|
||||
} else {
|
||||
acc.push({ id: entry.stripe_customer_id, entries: [entry] });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// A namespace mapping to more than one distinct customer id means the
|
||||
// subscription and credits customer drifted apart — worth flagging.
|
||||
const namespaceIdSets = customerIds.reduce<Map<string, Set<string>>>(
|
||||
(map, entry) => {
|
||||
const ids = map.get(entry.payments_namespace) ?? new Set<string>();
|
||||
ids.add(entry.stripe_customer_id);
|
||||
return map.set(entry.payments_namespace, ids);
|
||||
},
|
||||
new Map(),
|
||||
);
|
||||
const mismatchedNamespaces = [...namespaceIdSets.entries()]
|
||||
.filter(([, ids]) => ids.size > 1)
|
||||
.map(([ns]) => ns);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-card text-card-foreground shadow-sm flex flex-col p-5">
|
||||
<h4 className="text-sm font-semibold mb-4 text-foreground/80 uppercase tracking-wider pb-3 border-b border-border/50 flex items-center gap-2 h-11.25">
|
||||
<IconCreditCard className="size-4" />
|
||||
Stripe Customer Data
|
||||
</h4>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Stripe Customers
|
||||
</span>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
) : stripeCustomers.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{stripeCustomers.map(({ id, entries }) => (
|
||||
<div
|
||||
key={id}
|
||||
className="flex flex-col gap-1.5 p-2 bg-secondary/30 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<a
|
||||
href={stripeCustomerDashboardUrl(id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 min-w-0 font-mono text-xs hover:underline"
|
||||
title="Open in Stripe Dashboard"
|
||||
>
|
||||
<span className="truncate">{id}</span>
|
||||
<IconExternalLink className="size-3 shrink-0 text-muted-foreground" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto shrink-0 text-muted-foreground hover:text-foreground transition-colors cursor-pointer p-0.5"
|
||||
onClick={() => copyToClipboard(id, `stripe_${id}`)}
|
||||
title="Copy customer ID"
|
||||
>
|
||||
{copiedStates[`stripe_${id}`] ? (
|
||||
<IconCheck className="size-3 text-emerald-400" />
|
||||
) : (
|
||||
<IconCopy className="size-3 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entries.map((entry) => (
|
||||
<Badge
|
||||
key={`${entry.payments_namespace}_${entry.source}`}
|
||||
variant="secondary"
|
||||
className="text-[10px] py-0 px-1.5"
|
||||
>
|
||||
{formatNamespace(entry.payments_namespace)}
|
||||
{" · "}
|
||||
{STRIPE_SOURCE_LABELS[entry.source]}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{mismatchedNamespaces.length > 0 && (
|
||||
<p className="flex items-center gap-1 text-[11px] text-amber-400">
|
||||
<IconAlertTriangle className="size-3 shrink-0" />
|
||||
Multiple customer IDs in{" "}
|
||||
{mismatchedNamespaces.map(formatNamespace).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground italic">
|
||||
Not Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ReengagementCandidate[]>([]);
|
||||
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 (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 = (
|
||||
<TableRow className="hover:bg-transparent bg-muted/30">
|
||||
<TableHead className="text-xs">User</TableHead>
|
||||
<TableHead className="text-xs text-right">Score</TableHead>
|
||||
<TableHead className="text-xs text-right">Lifetime Net</TableHead>
|
||||
<TableHead className="text-xs">Last Payment</TableHead>
|
||||
<TableHead className="text-xs text-right">Days Since</TableHead>
|
||||
<TableHead className="text-xs text-right">Weeks Since</TableHead>
|
||||
<TableHead className="text-xs">Subscriber</TableHead>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
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">
|
||||
<IconHeartHandshake className="size-6 text-muted-foreground" />
|
||||
Reengagement List
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Lapsed spenders to win back, highest re-engagement score first
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isLoading}>
|
||||
<IconRefresh className={`size-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</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>{headerRow}</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>
|
||||
) : candidates.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
|
||||
<IconHeartHandshake className="size-10 text-muted-foreground mb-4 opacity-20" />
|
||||
<p className="text-muted-foreground">No users to re-engage.</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">{headerRow}</TableHeader>
|
||||
<TableBody>
|
||||
{candidates.map((c) => (
|
||||
<TableRow key={c.user_token} className="group">
|
||||
<TableCell className="text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/user/profile/${c.username}`}
|
||||
className="hover:underline text-foreground"
|
||||
>
|
||||
@{c.username}
|
||||
</Link>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Link
|
||||
to={`/user/spend-summary/${c.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/${c.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 font-semibold tabular-nums ${scoreColor(c.reengagement_score)}`}>
|
||||
{c.reengagement_score}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-right tabular-nums">
|
||||
{formatUsd(c.lifetime_net_spend_usd_cents)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{formatDate(c.maybe_last_payment_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-right tabular-nums text-muted-foreground">
|
||||
{c.maybe_days_since_last_payment != null ? `${c.maybe_days_since_last_payment}d` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-right tabular-nums text-muted-foreground">
|
||||
{c.maybe_weeks_since_last_spend != null ? `${c.maybe_weeks_since_last_spend}w` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{c.is_active_subscriber ? (
|
||||
<Badge variant="outline" className="text-[10px] text-emerald-400 border-emerald-500/30">
|
||||
active
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground/30">—</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>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
import { useTableHeight } from "@/hooks/useTableHeight";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconChartLine,
|
||||
IconCoin,
|
||||
IconReceipt2,
|
||||
IconRefresh,
|
||||
IconLoader2,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -229,20 +231,34 @@ export function SpendEvents() {
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-medium">
|
||||
{event.maybe_username ? (
|
||||
<Link
|
||||
to={`/user/profile/${event.maybe_username}`}
|
||||
className="hover:underline text-foreground"
|
||||
>
|
||||
@{event.maybe_username}
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/user/profile/${event.maybe_username}`}
|
||||
className="hover:underline text-foreground"
|
||||
>
|
||||
@{event.maybe_username}
|
||||
</Link>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Link
|
||||
to={`/user/spend-summary/${event.maybe_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/${event.maybe_username}`}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Spend history"
|
||||
>
|
||||
<IconReceipt2 className="size-4" />
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground/30">—</span>
|
||||
)}
|
||||
{event.maybe_display_name && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{event.maybe_display_name}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<Badge
|
||||
|
||||
@@ -6,12 +6,10 @@ import * as Flags from "country-flag-icons/react/3x2";
|
||||
import {
|
||||
ModerationApi,
|
||||
type ModeratorUserLookupResponse,
|
||||
type ModeratorStripeCustomerIdSource,
|
||||
type ModeratorUserStripeCustomerIdEntry,
|
||||
} from "@/api/ModerationApi";
|
||||
import { formatNamespace, stripeCustomerDashboardUrl } from "@/lib/stripe";
|
||||
import { MediaApi, type MediaInfo } from "@/api/MediaApi";
|
||||
import { MediaCard } from "@/components/MediaCard";
|
||||
import { UserBillingSection } from "@/components/UserBillingSection";
|
||||
import type { Wallet, UserJob } from "@/types";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getIdenticonUrl } from "@/lib/identicon";
|
||||
@@ -19,9 +17,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconExternalLink,
|
||||
IconPhoto,
|
||||
IconInfoCircle,
|
||||
IconCopy,
|
||||
@@ -29,10 +25,11 @@ import {
|
||||
IconMail,
|
||||
IconUser,
|
||||
IconShieldLock,
|
||||
IconChartLine,
|
||||
IconCoin,
|
||||
IconReceipt2,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconCreditCard,
|
||||
IconCalendar,
|
||||
IconHistory,
|
||||
IconBriefcase,
|
||||
@@ -63,11 +60,6 @@ import { UserFeatureFlagsDialog } from "@/components/UserFeatureFlagsDialog";
|
||||
import { getJobStatusBadgeProps } from "@/components/JobsTable";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
|
||||
const STRIPE_SOURCE_LABELS: Record<ModeratorStripeCustomerIdSource, string> = {
|
||||
customer_link: "Customer Link",
|
||||
subscription: "Subscription",
|
||||
};
|
||||
|
||||
export function UserProfile() {
|
||||
const { username } = useParams<{ username: string }>();
|
||||
usePageTitle(username ? `@${username}` : undefined);
|
||||
@@ -80,10 +72,6 @@ export function UserProfile() {
|
||||
const [media, setMedia] = useState<MediaInfo[]>([]);
|
||||
const [wallets, setWallets] = useState<Wallet[]>([]);
|
||||
const [isWalletsLoading, setIsWalletsLoading] = useState(false);
|
||||
const [stripeCustomerIds, setStripeCustomerIds] = useState<
|
||||
ModeratorUserStripeCustomerIdEntry[]
|
||||
>([]);
|
||||
const [isStripeIdsLoading, setIsStripeIdsLoading] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showIPs, setShowIPs] = useState<boolean>(false);
|
||||
@@ -101,34 +89,6 @@ export function UserProfile() {
|
||||
const totalBanked = wallets.reduce((sum, w) => sum + w.banked_credits, 0);
|
||||
const totalCredits = totalMonthly + totalBanked;
|
||||
|
||||
// One row per unique customer id, with all the (namespace, source) pairs
|
||||
// it was found under.
|
||||
const stripeCustomers = stripeCustomerIds.reduce<
|
||||
{ id: string; entries: ModeratorUserStripeCustomerIdEntry[] }[]
|
||||
>((acc, entry) => {
|
||||
const group = acc.find((g) => g.id === entry.stripe_customer_id);
|
||||
if (group) {
|
||||
group.entries.push(entry);
|
||||
} else {
|
||||
acc.push({ id: entry.stripe_customer_id, entries: [entry] });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// A namespace mapping to more than one distinct customer id means the
|
||||
// subscription and credits customer drifted apart — worth flagging.
|
||||
const namespaceIdSets = stripeCustomerIds.reduce<Map<string, Set<string>>>(
|
||||
(map, entry) => {
|
||||
const ids = map.get(entry.payments_namespace) ?? new Set<string>();
|
||||
ids.add(entry.stripe_customer_id);
|
||||
return map.set(entry.payments_namespace, ids);
|
||||
},
|
||||
new Map(),
|
||||
);
|
||||
const mismatchedNamespaces = [...namespaceIdSets.entries()]
|
||||
.filter(([, ids]) => ids.size > 1)
|
||||
.map(([ns]) => ns);
|
||||
|
||||
const createWallet = async () => {
|
||||
if (!user || isCreatingWallet) return;
|
||||
setIsCreatingWallet(true);
|
||||
@@ -241,14 +201,6 @@ export function UserProfile() {
|
||||
setIsJobsLoading(false);
|
||||
});
|
||||
|
||||
setIsStripeIdsLoading(true);
|
||||
modApi.GetUserStripeCustomerIds(fetchedUser.token).then((resp) => {
|
||||
if (cancelled) return;
|
||||
if (resp.success && resp.data) {
|
||||
setStripeCustomerIds(resp.data.customer_ids);
|
||||
}
|
||||
setIsStripeIdsLoading(false);
|
||||
});
|
||||
} else {
|
||||
setError(userResp.errorMessage || "User not found");
|
||||
}
|
||||
@@ -606,13 +558,13 @@ export function UserProfile() {
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={`/user/spend-summary/${username}`}>
|
||||
<IconCoin className="size-4" />
|
||||
<IconChartLine className="size-4" />
|
||||
Spend Summary
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={`/user/spend-history/${username}`}>
|
||||
<IconHistory className="size-4" />
|
||||
<IconReceipt2 className="size-4" />
|
||||
Spend History
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -951,81 +903,8 @@ export function UserProfile() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing & Subscription Group */}
|
||||
<div className="rounded-xl border bg-card text-card-foreground shadow-sm flex flex-col p-5">
|
||||
<h4 className="text-sm font-semibold mb-4 text-foreground/80 uppercase tracking-wider pb-3 border-b border-border/50 flex items-center gap-2 h-11.25">
|
||||
<IconCreditCard className="size-4" />
|
||||
Billing
|
||||
</h4>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Stripe Customers
|
||||
</span>
|
||||
{isStripeIdsLoading ? (
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
) : stripeCustomers.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{stripeCustomers.map(({ id, entries }) => (
|
||||
<div
|
||||
key={id}
|
||||
className="flex flex-col gap-1.5 p-2 bg-secondary/30 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<a
|
||||
href={stripeCustomerDashboardUrl(id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 min-w-0 font-mono text-xs hover:underline"
|
||||
title="Open in Stripe Dashboard"
|
||||
>
|
||||
<span className="truncate">{id}</span>
|
||||
<IconExternalLink className="size-3 shrink-0 text-muted-foreground" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto shrink-0 text-muted-foreground hover:text-foreground transition-colors cursor-pointer p-0.5"
|
||||
onClick={() => copyToClipboard(id, `stripe_${id}`)}
|
||||
title="Copy customer ID"
|
||||
>
|
||||
{copiedStates[`stripe_${id}`] ? (
|
||||
<IconCheck className="size-3 text-emerald-400" />
|
||||
) : (
|
||||
<IconCopy className="size-3 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entries.map((entry) => (
|
||||
<Badge
|
||||
key={`${entry.payments_namespace}_${entry.source}`}
|
||||
variant="secondary"
|
||||
className="text-[10px] py-0 px-1.5"
|
||||
>
|
||||
{formatNamespace(entry.payments_namespace)}
|
||||
{" · "}
|
||||
{STRIPE_SOURCE_LABELS[entry.source]}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{mismatchedNamespaces.length > 0 && (
|
||||
<p className="flex items-center gap-1 text-[11px] text-amber-400">
|
||||
<IconAlertTriangle className="size-3 shrink-0" />
|
||||
Multiple customer IDs in{" "}
|
||||
{mismatchedNamespaces.map(formatNamespace).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground italic">
|
||||
Not Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Billing (shared component, also on the spend-summary page) */}
|
||||
<UserBillingSection userToken={user.token} />
|
||||
|
||||
{/* Security & System Identifiers Group */}
|
||||
<div className="rounded-xl border bg-card text-card-foreground shadow-sm flex flex-col p-5">
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
IconArrowLeft,
|
||||
IconChartLine,
|
||||
IconCoin,
|
||||
IconReceipt2,
|
||||
IconLoader2,
|
||||
} from "@tabler/icons-react";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
@@ -171,7 +172,7 @@ export function UserSpendHistory() {
|
||||
Back to profile
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
|
||||
<IconCoin className="size-6 text-muted-foreground" />
|
||||
<IconReceipt2 className="size-6 text-muted-foreground" />
|
||||
Spend History
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -34,10 +34,12 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconArrowLeft,
|
||||
IconChartLine,
|
||||
IconCoin,
|
||||
IconHistory,
|
||||
IconReceipt2,
|
||||
} from "@tabler/icons-react";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
import { UserBillingSection } from "@/components/UserBillingSection";
|
||||
|
||||
// --- formatting helpers ---
|
||||
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
|
||||
@@ -185,6 +187,18 @@ export function UserSpendSummary() {
|
||||
[chartData],
|
||||
);
|
||||
|
||||
// Fit the Y axis tightly to the currently-displayed data (recomputes on every
|
||||
// period change), with ~10% headroom so peaks never touch/overflow the top.
|
||||
const yDomain = useMemo<[number, number]>(() => {
|
||||
if (chartData.length === 0) return [0, 1];
|
||||
const values = chartData.map((d) => d.net);
|
||||
const dataMax = Math.max(0, ...values);
|
||||
const dataMin = Math.min(0, ...values);
|
||||
const max = dataMax === 0 ? 1 : dataMax * 1.1;
|
||||
const min = dataMin === 0 ? 0 : dataMin * 1.1;
|
||||
return [min, max];
|
||||
}, [chartData]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -229,7 +243,7 @@ export function UserSpendSummary() {
|
||||
Back to profile
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
|
||||
<IconCoin className="size-6 text-muted-foreground" />
|
||||
<IconChartLine className="size-6 text-muted-foreground" />
|
||||
Spend Summary
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
@@ -243,7 +257,7 @@ export function UserSpendSummary() {
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={`/user/spend-history/${username}`}>
|
||||
<IconHistory className="size-4" />
|
||||
<IconReceipt2 className="size-4" />
|
||||
Spend History
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -309,7 +323,8 @@ export function UserSpendSummary() {
|
||||
axisLine={false}
|
||||
width={56}
|
||||
tickMargin={8}
|
||||
domain={["auto", "auto"]}
|
||||
domain={yDomain}
|
||||
allowDataOverflow
|
||||
tickFormatter={(value) => formatUsdCompact(Number(value) * 100)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
@@ -330,10 +345,17 @@ export function UserSpendSummary() {
|
||||
/>
|
||||
<Area
|
||||
dataKey="net"
|
||||
type="natural"
|
||||
type="monotone"
|
||||
fill="url(#fillNet)"
|
||||
stroke="var(--color-net)"
|
||||
strokeWidth={2}
|
||||
dot={{
|
||||
r: 2.5,
|
||||
fill: "var(--color-net)",
|
||||
stroke: "var(--background)",
|
||||
strokeWidth: 1,
|
||||
}}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
@@ -350,6 +372,9 @@ export function UserSpendSummary() {
|
||||
<p className="text-muted-foreground">No spend summary for this user.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing (same Stripe block as the profile page) */}
|
||||
{user && <UserBillingSection userToken={user.token} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user