From 1c5c0ce0ca6e6138dd110f77961f62b620940329 Mon Sep 17 00:00:00 2001 From: Samuel Date: Sun, 27 Sep 2026 00:01:52 +0100 Subject: [PATCH 1/2] feat: add royalty earnings tracker and claim UI to creator dashboard (issue #987) --- .../creator/RoyaltyEarningsSection.tsx | 210 ++++++++++++++++++ .../__tests__/RoyaltyEarningsSection.test.tsx | 207 +++++++++++++++++ src/hooks/useRoyaltyClaim.ts | 42 ++++ src/hooks/useRoyaltyEarnings.ts | 38 ++++ src/lib/queryKeys.ts | 3 + src/pages/CreatorDashboardPage.tsx | 3 + src/services/royaltyEarnings.service.ts | 59 +++++ 7 files changed, 562 insertions(+) create mode 100644 src/components/creator/RoyaltyEarningsSection.tsx create mode 100644 src/components/creator/__tests__/RoyaltyEarningsSection.test.tsx create mode 100644 src/hooks/useRoyaltyClaim.ts create mode 100644 src/hooks/useRoyaltyEarnings.ts create mode 100644 src/services/royaltyEarnings.service.ts diff --git a/src/components/creator/RoyaltyEarningsSection.tsx b/src/components/creator/RoyaltyEarningsSection.tsx new file mode 100644 index 00000000..ee3533a1 --- /dev/null +++ b/src/components/creator/RoyaltyEarningsSection.tsx @@ -0,0 +1,210 @@ +import { UserCheck, Clock, CheckCircle2, ExternalLink } from 'lucide-react'; +import { useRoyaltyEarnings } from '@/hooks/useRoyaltyEarnings'; +import { useClaimRoyaltiesMutation } from '@/hooks/useRoyaltyClaim'; +import { truncateTxHash, buildStellarExpertTxUrl } from '@/constants/stellar'; +import { formatNumber } from '@/utils/numberFormat.utils'; +import { formatRelativeTimeLabel } from '@/utils/time.utils'; +import type { + RoyaltyEarnings, + RoyaltyEarningsEvent, + RoyaltyClaim, +} from '@/services/royaltyEarnings.service'; + +/** + * Royalty earnings section for the creator key management dashboard + * (issue #987): totals card, per-transfer breakdown, claim button, and + * claimed history with transaction hash links. Values refresh on a 60s + * interval via useRoyaltyEarnings. + */ + +const CLAIM_BUTTON_TEST_ID = 'claim-royalties-button'; + +/** Formats an XLM amount (the royalty API's unit) for display. */ +function formatXlmLabel(amount: number): string { + return `${formatNumber(amount, { + minimumFractionDigits: 2, + maximumFractionDigits: 4, + })} XLM`; +} + +function EarningsTotalsCard({ earnings }: { earnings: RoyaltyEarnings }) { + const totals = [ + { label: 'Total Earned', value: earnings.totalEarned, testId: 'royalty-total-earned' }, + { label: 'Pending', value: earnings.pending, testId: 'royalty-pending' }, + { label: 'Claimed', value: earnings.claimed, testId: 'royalty-claimed' }, + ]; + + return ( +
+ {totals.map(({ label, value, testId }) => ( +
+ {label} +

+ {formatXlmLabel(value)} +

+
+ ))} +
+ ); +} + +function TransferBreakdownList({ events }: { events: RoyaltyEarningsEvent[] }) { + if (events.length === 0) { + return ( +
+

No royalty events yet

+

+ Royalties from secondary key transfers will appear here. +

+
+ ); + } + + return ( + + ); +} + +function ClaimedHistoryList({ claims }: { claims: RoyaltyClaim[] }) { + if (claims.length === 0) { + return ( +

+ No claims yet. Claimed payouts will show their transaction hash here. +

+ ); + } + + return ( + + ); +} + +export interface RoyaltyEarningsSectionProps { + creatorId: string; + /** Overrides the earnings hook — tests inject mock data this way. */ + queryFn?: () => Promise; + className?: string; +} + +export function RoyaltyEarningsSection({ + creatorId, + queryFn, + className = '', +}: RoyaltyEarningsSectionProps) { + const { data: earnings, isLoading, isError } = useRoyaltyEarnings(creatorId, { + queryFn, + }); + const claimMutation = useClaimRoyaltiesMutation(creatorId); + + return ( +
+
+
+
+
+
+

+ Royalty Earnings +

+

+ Accumulated royalties from secondary key transfers +

+
+
+ +
+ + {isLoading ? ( +
+ Loading royalty earnings… +
+ ) : isError || !earnings ? ( +
+ We couldn't load your royalty earnings. They refresh every 60 seconds — try + again shortly. +
+ ) : ( +
+ + + +
+ )} +
+ ); +} + +export default RoyaltyEarningsSection; diff --git a/src/components/creator/__tests__/RoyaltyEarningsSection.test.tsx b/src/components/creator/__tests__/RoyaltyEarningsSection.test.tsx new file mode 100644 index 00000000..0d95e0ef --- /dev/null +++ b/src/components/creator/__tests__/RoyaltyEarningsSection.test.tsx @@ -0,0 +1,207 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import RoyaltyEarningsSection from '../RoyaltyEarningsSection'; +import { useClaimRoyaltiesMutation } from '@/hooks/useRoyaltyClaim'; +import { + ROYALTY_EARNINGS_REFETCH_INTERVAL_MS, +} from '@/hooks/useRoyaltyEarnings'; +import type { RoyaltyEarnings } from '@/services/royaltyEarnings.service'; + +vi.mock('@/utils/toast.util', () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +const { mockMutate } = vi.hoisted(() => ({ mockMutate: vi.fn() })); +vi.mock('@/hooks/useRoyaltyClaim', () => ({ + useClaimRoyaltiesMutation: vi.fn(() => ({ + mutate: mockMutate, + isPending: false, + })), +})); + +const NOW = Date.UTC(2026, 8, 26, 12, 0, 0); // 2026-09-26T12:00:00Z + +function makeEarnings( + overrides: Partial = {} +): RoyaltyEarnings { + return { + totalEarned: 45, + pending: 20, + claimed: 25, + events: [ + { + id: 'evt-1', + timestamp: NOW - 60_000, + transferAmount: 100, + royaltyEarned: 2.5, + }, + { + id: 'evt-2', + timestamp: NOW - 3_600_000, + transferAmount: 250, + royaltyEarned: 6.25, + }, + ], + claims: [ + { + id: 'claim-1', + timestamp: NOW - 86_400_000, + amount: 25, + txHash: 'abc123def4567890abcdef1234567890abcdef1234567890abcdef1234567890', + }, + ], + ...overrides, + }; +} + +function makeWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +describe('RoyaltyEarningsSection', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders totals aggregated from the earnings payload', async () => { + render( + makeEarnings()} />, + { wrapper: makeWrapper() } + ); + + await waitFor(() => { + expect(screen.getByTestId('royalty-total-earned')).toHaveTextContent('45.00 XLM'); + }); + expect(screen.getByTestId('royalty-pending')).toHaveTextContent('20.00 XLM'); + expect(screen.getByTestId('royalty-claimed')).toHaveTextContent('25.00 XLM'); + }); + + it('lists every per-transfer royalty event with amount and royalty earned', async () => { + render( + makeEarnings()} />, + { wrapper: makeWrapper() } + ); + + await waitFor(() => { + expect(screen.getAllByTestId('royalty-event-amount')).toHaveLength(2); + }); + expect(screen.getAllByTestId('royalty-event-amount')[0]).toHaveTextContent('100 XLM'); + expect(screen.getAllByTestId('royalty-event-royalty')[0]).toHaveTextContent('+2.5 XLM'); + expect(screen.getAllByTestId('royalty-event-royalty')[1]).toHaveTextContent('+6.25 XLM'); + }); + + it('populates claimed history with transaction hash links', async () => { + render( + makeEarnings()} />, + { wrapper: makeWrapper() } + ); + + await waitFor(() => { + expect(screen.getByTestId('royalty-claims-list')).toBeInTheDocument(); + }); + const link = screen.getByTestId('royalty-claim-tx-link'); + expect(link).toHaveAttribute('href', expect.stringContaining('stellar.expert')); + expect(link).toHaveTextContent('abc123de…4567890'); + }); + + it('submits a claim transaction when the claim button is clicked', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + makeEarnings()} />, + { wrapper: makeWrapper() } + ); + + await waitFor(() => { + expect(screen.getByTestId('claim-royalties-button')).toBeInTheDocument(); + }); + await user.click(screen.getByTestId('claim-royalties-button')); + + expect(mockMutate).toHaveBeenCalledTimes(1); + }); + + it('refreshes earnings every 60 seconds', async () => { + const queryFn = vi.fn(async () => makeEarnings()); + render(, { + wrapper: makeWrapper(), + }); + + await waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + vi.advanceTimersByTime(ROYALTY_EARNINGS_REFETCH_INTERVAL_MS + 1_000); + }); + + await waitFor(() => { + expect(queryFn.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + }); + + it('shows an empty state when there are no royalty events or claims', async () => { + render( + makeEarnings({ events: [], claims: [] })} + />, + { wrapper: makeWrapper() } + ); + + await waitFor(() => { + expect(screen.getByTestId('royalty-events-empty')).toBeInTheDocument(); + }); + expect(screen.getByTestId('royalty-claims-empty')).toBeInTheDocument(); + }); + + it('shows an error state when the earnings query fails', async () => { + render( + { + throw new Error('boom'); + }} + />, + { wrapper: makeWrapper() } + ); + + await waitFor(() => { + expect(screen.getByTestId('royalty-earnings-error')).toBeInTheDocument(); + }); + }); + + it('disables the claim button while a claim is in progress', async () => { + vi.mocked(useClaimRoyaltiesMutation).mockReturnValueOnce({ + mutate: mockMutate, + isPending: true, + } as ReturnType); + + render( + makeEarnings()} />, + { wrapper: makeWrapper() } + ); + + await waitFor(() => { + expect(screen.getByTestId('claim-royalties-button')).toBeDisabled(); + }); + expect(screen.getByTestId('claim-royalties-button')).toHaveTextContent('Claiming…'); + }); +}); diff --git a/src/hooks/useRoyaltyClaim.ts b/src/hooks/useRoyaltyClaim.ts new file mode 100644 index 00000000..60f3b122 --- /dev/null +++ b/src/hooks/useRoyaltyClaim.ts @@ -0,0 +1,42 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import showToast from '@/utils/toast.util'; +import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; + +/** + * Creator-facing `claim_royalties` contract call issued from the dashboard's + * earnings section (issue #987). + * + * The on-chain wiring is not in the client yet, so the mutation simulates + * signing latency and resolves — matching useCreatorContractActions' + * submitContractCall convention. On success the royalties query is + * invalidated so totals and claimed history refresh. + */ + +const SIGN_LATENCY_MS = 1200; + +async function submitClaimRoyalties(creatorId: string) { + // In production this signs and submits `claim_royalties` with `creatorId` + // via the connected wallet. + void creatorId; + await new Promise(resolve => window.setTimeout(resolve, SIGN_LATENCY_MS)); + return { success: true as const }; +} + +export function useClaimRoyaltiesMutation(creatorId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['contract', 'claim_royalties', creatorId], + mutationFn: () => submitClaimRoyalties(creatorId), + onError: error => { + showToast.error(getSignatureErrorMessage(error)); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.creators.royalties(creatorId), + }); + showToast.success('Royalties claimed'); + }, + }); +} diff --git a/src/hooks/useRoyaltyEarnings.ts b/src/hooks/useRoyaltyEarnings.ts new file mode 100644 index 00000000..a98a1878 --- /dev/null +++ b/src/hooks/useRoyaltyEarnings.ts @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { royaltyEarningsService } from '@/services/royaltyEarnings.service'; +import type { RoyaltyEarnings } from '@/services/royaltyEarnings.service'; + +/** + * How often the earnings section refreshes in the background (issue #987: + * "Earnings update on 60s refresh"). Matches useNotifications' 60s cadence. + */ +export const ROYALTY_EARNINGS_REFETCH_INTERVAL_MS = 60_000; + +export interface UseRoyaltyEarningsOptions { + /** + * Override the query function — used by tests to inject mock earnings + * without module-level vi.mock() patching (matches useCreatorActivityFeed's + * convention). + */ + queryFn?: () => Promise; +} + +/** + * Fetches a creator's royalty earnings, polling every 60 seconds. + * Query key: ['creators', creatorId, 'royalties'] + */ +export function useRoyaltyEarnings( + creatorId: string, + options: UseRoyaltyEarningsOptions = {} +) { + const fetchEarnings = + options.queryFn ?? (() => royaltyEarningsService.getRoyaltyEarnings(creatorId)); + + return useQuery({ + queryKey: queryKeys.creators.royalties(creatorId), + queryFn: fetchEarnings, + enabled: !!creatorId, + refetchInterval: ROYALTY_EARNINGS_REFETCH_INTERVAL_MS, + }); +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 684e54ea..fee78ed3 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -49,6 +49,9 @@ export const queryKeys = { trending: () => ['creators', 'discovery', 'trending'] as const, newListings: () => ['creators', 'discovery', 'newListings'] as const, }, +======= + royalties: (creatorId: string) => + ['creators', creatorId, 'royalties'] as const, }, wallet: { holdings: (address: string) => ['wallet', address, 'holdings'] as const, diff --git a/src/pages/CreatorDashboardPage.tsx b/src/pages/CreatorDashboardPage.tsx index ad2307cf..99e7a1e6 100644 --- a/src/pages/CreatorDashboardPage.tsx +++ b/src/pages/CreatorDashboardPage.tsx @@ -13,6 +13,7 @@ import BuyCooldownPanel from '@/components/common/BuyCooldownPanel'; import DeprecateKeyPanel from '@/components/common/DeprecateKeyPanel'; import VestingSchedulePanel from '@/components/common/VestingSchedulePanel'; import { AlertTriangle } from 'lucide-react'; +import RoyaltyEarningsSection from '@/components/creator/RoyaltyEarningsSection'; import { useCancelAuctionMutation, useConfigureAuctionMutation, @@ -205,6 +206,8 @@ export default function CreatorDashboardPage() { + + { + try { + const response = await this.api.get>( + `/creators/${creatorId}/royalties` + ); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const royaltyEarningsService = new RoyaltyEarningsService(); From b32ea8eb8a8a473c8eb602d4a5fb49f1cc7d0350 Mon Sep 17 00:00:00 2001 From: Samuel Date: Sun, 27 Sep 2026 00:10:00 +0100 Subject: [PATCH 2/2] fix: drop leftover merge-conflict separator in queryKeys (CI lint) --- src/lib/queryKeys.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index fee78ed3..286b3775 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -49,7 +49,6 @@ export const queryKeys = { trending: () => ['creators', 'discovery', 'trending'] as const, newListings: () => ['creators', 'discovery', 'newListings'] as const, }, -======= royalties: (creatorId: string) => ['creators', creatorId, 'royalties'] as const, },