diff --git a/frontend/apps/artcraft-dashboard/src/components/UserBillingSection.tsx b/frontend/apps/artcraft-dashboard/src/components/UserBillingSection.tsx new file mode 100644 index 0000000000..ac5dbbf6d3 --- /dev/null +++ b/frontend/apps/artcraft-dashboard/src/components/UserBillingSection.tsx @@ -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 = { + 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([]); + const [isLoading, setIsLoading] = useState(true); + const [copiedStates, setCopiedStates] = useState>({}); + + 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, entry) => { + const ids = map.get(entry.payments_namespace) ?? new Set(); + 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 ( +
+

+ + Billing +

+
+
+ + Stripe Customers + + {isLoading ? ( + + ) : stripeCustomers.length > 0 ? ( +
+ {stripeCustomers.map(({ id, entries }) => ( +
+
+ + {id} + + + +
+
+ {entries.map((entry) => ( + + {formatNamespace(entry.payments_namespace)} + {" · "} + {STRIPE_SOURCE_LABELS[entry.source]} + + ))} +
+
+ ))} + {mismatchedNamespaces.length > 0 && ( +

+ + Multiple customer IDs in{" "} + {mismatchedNamespaces.map(formatNamespace).join(", ")} +

+ )} +
+ ) : ( + + Not Connected + + )} +
+
+
+ ); +} diff --git a/frontend/apps/artcraft-dashboard/src/pages/UserProfile.tsx b/frontend/apps/artcraft-dashboard/src/pages/UserProfile.tsx index ab0d0239b2..4d18a3bad4 100644 --- a/frontend/apps/artcraft-dashboard/src/pages/UserProfile.tsx +++ b/frontend/apps/artcraft-dashboard/src/pages/UserProfile.tsx @@ -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, @@ -32,7 +28,6 @@ import { IconCoin, IconEye, IconEyeOff, - IconCreditCard, IconCalendar, IconHistory, IconBriefcase, @@ -63,11 +58,6 @@ import { UserFeatureFlagsDialog } from "@/components/UserFeatureFlagsDialog"; import { getJobStatusBadgeProps } from "@/components/JobsTable"; import { usePageTitle } from "@/hooks/usePageTitle"; -const STRIPE_SOURCE_LABELS: Record = { - customer_link: "Customer Link", - subscription: "Subscription", -}; - export function UserProfile() { const { username } = useParams<{ username: string }>(); usePageTitle(username ? `@${username}` : undefined); @@ -80,10 +70,6 @@ export function UserProfile() { const [media, setMedia] = useState([]); const [wallets, setWallets] = useState([]); const [isWalletsLoading, setIsWalletsLoading] = useState(false); - const [stripeCustomerIds, setStripeCustomerIds] = useState< - ModeratorUserStripeCustomerIdEntry[] - >([]); - const [isStripeIdsLoading, setIsStripeIdsLoading] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [showIPs, setShowIPs] = useState(false); @@ -101,34 +87,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, entry) => { - const ids = map.get(entry.payments_namespace) ?? new Set(); - 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 +199,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"); } @@ -951,81 +901,8 @@ export function UserProfile() { - {/* Billing & Subscription Group */} -
-

- - Billing -

-
-
- - Stripe Customers - - {isStripeIdsLoading ? ( - - ) : stripeCustomers.length > 0 ? ( -
- {stripeCustomers.map(({ id, entries }) => ( -
-
- - {id} - - - -
-
- {entries.map((entry) => ( - - {formatNamespace(entry.payments_namespace)} - {" · "} - {STRIPE_SOURCE_LABELS[entry.source]} - - ))} -
-
- ))} - {mismatchedNamespaces.length > 0 && ( -

- - Multiple customer IDs in{" "} - {mismatchedNamespaces.map(formatNamespace).join(", ")} -

- )} -
- ) : ( - - Not Connected - - )} -
-
-
+ {/* Billing (shared component, also on the spend-summary page) */} + {/* Security & System Identifiers Group */}
diff --git a/frontend/apps/artcraft-dashboard/src/pages/UserSpendSummary.tsx b/frontend/apps/artcraft-dashboard/src/pages/UserSpendSummary.tsx index 6059bb73df..82161705b9 100644 --- a/frontend/apps/artcraft-dashboard/src/pages/UserSpendSummary.tsx +++ b/frontend/apps/artcraft-dashboard/src/pages/UserSpendSummary.tsx @@ -38,6 +38,7 @@ import { IconHistory, } 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" }); @@ -370,6 +371,9 @@ export function UserSpendSummary() {

No spend summary for this user.

)} + + {/* Billing (same Stripe block as the profile page) */} + {user && } ); }