diff --git a/src/components/common/PortfolioHoldingRow.tsx b/src/components/common/PortfolioHoldingRow.tsx index 37a32cc1..3dd52701 100644 --- a/src/components/common/PortfolioHoldingRow.tsx +++ b/src/components/common/PortfolioHoldingRow.tsx @@ -1,9 +1,12 @@ import React, { useState } from 'react'; import { Button } from '@/components/ui/button'; import LockupCountdown from '@/components/common/LockupCountdown'; +import ReinvestDividendDialog from '@/components/common/ReinvestDividendDialog'; import { computeRemainingLockupSeconds } from '@/utils/lockupCountdown.utils'; import { formatNumber } from '@/utils/numberFormat.utils'; import { formatDisplayKeyPrice, resolveCreatorKeyPriceStroops } from '@/utils/keyPriceDisplay.utils'; +import { hasUnclaimedDividend, xlmToStroops } from '@/utils/reinvestDividend.utils'; +import { TrendingUp } from 'lucide-react'; import type { HeldKeyPosition } from '@/utils/portfolioValue.utils'; import type { Course } from '@/services/course.service'; import { cn } from '@/lib/utils'; @@ -13,7 +16,9 @@ export interface PortfolioHoldingRowProps { creator?: Course; onBuy?: (creatorId: string) => void; onSell?: (creatorId: string) => void; + onReinvest?: (creatorId: string) => Promise | void; isSubmitting?: boolean; + isReinvesting?: boolean; isNetworkMismatch?: boolean; } @@ -22,13 +27,26 @@ export const PortfolioHoldingRow: React.FC = ({ creator, onBuy, onSell, + onReinvest, isSubmitting = false, + isReinvesting = false, isNetworkMismatch = false, }) => { const initialRemaining = computeRemainingLockupSeconds(position.last_buy_timestamp); const [isLocked, setIsLocked] = useState(initialRemaining > 0); + const [reinvestOpen, setReinvestOpen] = useState(false); + + const hasDividends = hasUnclaimedDividend(position.unclaimedDividend); + const keyPriceStroops = resolveCreatorKeyPriceStroops(position); + + const handleConfirmReinvest = async () => { + if (!onReinvest) return; + await onReinvest(position.creatorId); + setReinvestOpen(false); + }; return ( + <>
= ({ ? 'Price stale' : formatDisplayKeyPrice(resolveCreatorKeyPriceStroops(position))}
+ {hasDividends && ( + + + )}
@@ -65,6 +95,18 @@ export const PortfolioHoldingRow: React.FC = ({ />
+ {onReinvest && hasDividends && ( + + )} {onBuy && (
+ + {onReinvest && hasDividends && position.unclaimedDividend != null && ( + + )} + ); }; diff --git a/src/components/common/ReinvestDividendDialog.tsx b/src/components/common/ReinvestDividendDialog.tsx new file mode 100644 index 00000000..83474ca0 --- /dev/null +++ b/src/components/common/ReinvestDividendDialog.tsx @@ -0,0 +1,163 @@ +import { useEffect, useRef } from 'react'; +import { Button } from '@/components/ui/button'; +import { StableButtonContent } from '@/components/ui/stable-button-content'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { formatNumber } from '@/utils/numberFormat.utils'; +import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; +import { + estimateReinvest, + type ReinvestEstimate, +} from '@/utils/reinvestDividend.utils'; + +export interface ReinvestDividendDialogProps { + open: boolean; + /** Creator key name being reinvested into. */ + creatorName: string; + /** Unclaimed dividend balance in XLM. */ + unclaimedDividend: number; + /** Per-key price in stroops used to estimate the purchase. */ + keyPriceStroops?: number | null; + onOpenChange: (open: boolean) => void; + onConfirm: () => Promise | void; + isSubmitting?: boolean; +} + +/** + * Confirmation modal for reinvesting an unclaimed dividend balance back into + * creator keys (#824). Shows the unclaimed amount, the estimated keys to be + * bought, and any XLM remainder that cannot be converted to a whole key. + */ +const ReinvestDividendDialog: React.FC = ({ + open, + creatorName, + unclaimedDividend, + keyPriceStroops, + onOpenChange, + onConfirm, + isSubmitting = false, +}) => { + const triggerElementRef = useRef(null); + + useEffect(() => { + if (open) { + triggerElementRef.current = + document.activeElement as HTMLElement | null; + } + }, [open]); + + const estimate: ReinvestEstimate | null = estimateReinvest( + unclaimedDividend, + keyPriceStroops + ); + + const confirmDisabled = + isSubmitting || + estimate == null || + estimate.wholeKeys <= 0 || + estimate.unclaimedStroops <= 0; + + const handleConfirm = async () => { + if (confirmDisabled) return; + await onConfirm(); + }; + + return ( + !isSubmitting && onOpenChange(next)} + > + { + event.preventDefault(); + triggerElementRef.current?.focus(); + }} + onEscapeKeyDown={event => { + if (isSubmitting) event.preventDefault(); + }} + onInteractOutside={event => { + if (isSubmitting) event.preventDefault(); + }} + > + + Reinvest dividends + + Compound your unclaimed{' '} + {formatDisplayKeyPrice(estimate?.unclaimedStroops)} dividend + into more {creatorName} creator keys. + + + +
+
+ Unclaimed dividends + + {formatDisplayKeyPrice(estimate?.unclaimedStroops)} + +
+
+ Per-key price + + {formatDisplayKeyPrice(keyPriceStroops)} + +
+
+ Estimated keys + + {estimate + ? `${formatNumber(estimate.wholeKeys)} key${ + estimate.wholeKeys === 1 ? '' : 's' + }` + : 'Unavailable'} + +
+
+ XLM remainder + + {estimate + ? formatDisplayKeyPrice(estimate.remainderStroops) + : '—'} + +
+
+ + + + + +
+
+ ); +}; + +export default ReinvestDividendDialog; diff --git a/src/components/common/__tests__/PortfolioHoldingRow.reinvest.test.tsx b/src/components/common/__tests__/PortfolioHoldingRow.reinvest.test.tsx new file mode 100644 index 00000000..57ba48e9 --- /dev/null +++ b/src/components/common/__tests__/PortfolioHoldingRow.reinvest.test.tsx @@ -0,0 +1,123 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import PortfolioHoldingRow from '../PortfolioHoldingRow'; +import type { HeldKeyPosition } from '@/utils/portfolioValue.utils'; + +const basePosition: HeldKeyPosition = { + creatorId: 'creator-1', + quantity: 5, + priceStroops: 5_000_000, + last_buy_timestamp: null, +}; + +const creator = { + id: 'creator-1', + title: 'Alex Rivers', + description: 'Artist', + price: 0.5, + instructorId: 'inst-1', + category: 'Art', + level: 'BEGINNER' as const, +}; + +describe('PortfolioHoldingRow — dividend reinvest', () => { + it('shows an unclaimed dividend badge only when unclaimedDividend is greater than zero', () => { + const { rerender } = render( + + ); + + expect( + screen.getByTestId('unclaimed-dividend-badge') + ).toBeInTheDocument(); + expect(screen.getByTestId('holding-reinvest-button')).toBeInTheDocument(); + + rerender( + + ); + + expect( + screen.queryByTestId('unclaimed-dividend-badge') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('holding-reinvest-button') + ).not.toBeInTheDocument(); + }); + + it('does not render a reinvest button when no onReinvest handler is supplied', () => { + render( + + ); + + expect( + screen.getByTestId('unclaimed-dividend-badge') + ).toBeInTheDocument(); + expect( + screen.queryByTestId('holding-reinvest-button') + ).not.toBeInTheDocument(); + }); + + it('opens the confirmation modal with the estimated keys and remainder', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('holding-reinvest-button')); + + expect(screen.getByTestId('reinvest-dialog-confirm')).toBeInTheDocument(); + // 1.25 XLM at 0.5 XLM/key => 2 whole keys; remainder 0.25 XLM + expect(screen.getByText(/2 keys/)).toBeInTheDocument(); + expect(screen.getByText(/Unclaimed dividends/i)).toBeInTheDocument(); + }); + + it('calls onReinvest with the creator key id on confirm', async () => { + const onReinvest = vi.fn().mockResolvedValue(undefined); + + render( + + ); + + fireEvent.click(screen.getByTestId('holding-reinvest-button')); + + const confirmButton = screen.getByTestId('reinvest-dialog-confirm'); + expect(confirmButton).not.toBeDisabled(); + + fireEvent.click(confirmButton); + + expect(onReinvest).toHaveBeenCalledWith('creator-1'); + }); + + it('disables the confirm button when the dividend cannot buy a whole key', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('holding-reinvest-button')); + + const confirmButton = screen.getByTestId('reinvest-dialog-confirm'); + // 0.1 XLM at 0.5 XLM/key < 1 key => disabled + expect(confirmButton).toBeDisabled(); + }); +}); diff --git a/src/components/common/__tests__/ReinvestDividendDialog.test.tsx b/src/components/common/__tests__/ReinvestDividendDialog.test.tsx new file mode 100644 index 00000000..ff3deaaf --- /dev/null +++ b/src/components/common/__tests__/ReinvestDividendDialog.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import ReinvestDividendDialog from '../ReinvestDividendDialog'; + +describe('ReinvestDividendDialog', () => { + const baseProps = { + open: true, + creatorName: 'Alex Rivers', + unclaimedDividend: 1.25, + keyPriceStroops: 5_000_000, + onOpenChange: vi.fn(), + onConfirm: vi.fn(), + isSubmitting: false, + }; + + it('shows the unclaimed amount, estimated keys and XLM remainder', () => { + render(); + + expect(screen.getByText('Reinvest dividends')).toBeInTheDocument(); + // 1.25 XLM at 0.5 XLM/key => 2 whole keys, 0.25 XLM remainder + expect(screen.getByText(/2 keys/)).toBeInTheDocument(); + expect(screen.getByText(/Unclaimed dividends/i)).toBeInTheDocument(); + }); + + it('shows an unavailable estimate when key price is missing', () => { + render(); + + expect(screen.getByText(/Unavailable/)).toBeInTheDocument(); + expect(screen.getByTestId('reinvest-dialog-confirm')).toBeDisabled(); + }); +}); diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index 9b120076..5eb06ad3 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -202,6 +202,109 @@ export interface BatchOrder { ref?: string | null; } +export interface ReinvestDividendVariables { + /** The creator key whose dividends are being reinvested. */ + keyId: string; + /** Unclaimed dividend amount in XLM being compounded. */ + amount: number; + /** Number of whole keys the reinvestment buys (used for optimistic update). */ + keys: number; +} + +export function useReinvestDividendMutation(address: string) { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationKey: ['reinvest-dividend', address], + mutationFn: async (variables: ReinvestDividendVariables) => { + // In production this submits the on-chain `reinvest_dividend` + // contract function with the holder's `key_id` (`variables.keyId`). + // Here we simulate latency and accept the payload. + void variables; + await new Promise(resolve => window.setTimeout(resolve, 900)); + return { success: true as const }; + }, + onMutate: async ({ + keyId, + keys, + amount, + }: ReinvestDividendVariables) => { + const queryKey = queryKeys.wallet.holdings(address); + + await queryClient.cancelQueries({ queryKey }); + + const previousHoldings = + queryClient.getQueryData(queryKey) ?? []; + + queryClient.setQueryData(queryKey, (old = []) => + old.map(h => { + if (h.creatorId !== keyId) return h; + return { + ...h, + quantity: (h.quantity ?? 0) + keys, + pending: true, + // Optimistically clear the compounded dividend. + unclaimedDividend: Math.max( + 0, + (h.unclaimedDividend ?? 0) - amount + ), + }; + }) + ); + + return { previousHoldings }; + }, + onError: (error, _variables, context) => { + const holdingsKey = queryKeys.wallet.holdings(address); + + if (context?.previousHoldings) { + queryClient.setQueryData(holdingsKey, context.previousHoldings); + } else if (process.env.NODE_ENV !== 'test') { + console.warn('[optimistic-rollback]', { + cache_key: JSON.stringify(holdingsKey), + action: 'reinvest_dividend', + reason: 'snapshot_missing', + failed_at: new Date().toISOString(), + }); + } + + showToast.error(getSignatureErrorMessage(error)); + }, + onSuccess: (_data, variables) => { + // Clear the pending flag once the reinvestment settles. + queryClient.setQueryData( + queryKeys.wallet.holdings(address), + (old = []) => + old.map(h => + h.creatorId === variables.keyId + ? { ...h, pending: false } + : h + ) + ); + }, + onSettled: (_data, _error, variables) => { + // Reinvesting converts dividends back into keys, which changes the + // held quantity and ultimately supply/price on the marketplace. + queryClient.invalidateQueries({ + queryKey: queryKeys.wallet.holdings(address), + }); + + if (process.env.NODE_ENV !== 'test') { + console.debug('[cache-invalidation]', { + invalidated_keys: [JSON.stringify( + queryKeys.wallet.holdings(address) + )], + trigger: 'reinvest_dividend', + creator_id: variables.keyId, + invalidated_at: new Date().toISOString(), + }); + } + }, + }); + + return mutation; +} + export function useBatchBuyMutation(address?: string) { const queryClient = useQueryClient(); diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 63bd053b..9712237d 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -41,7 +41,7 @@ import NetworkMismatchBanner from '@/components/common/NetworkMismatchBanner'; import StellarConnectionQualityBadge from '@/components/common/StellarConnectionQualityBadge'; import { useAccount } from 'wagmi'; import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; -import { useTradeMutation, useWalletHoldings } from '@/hooks/useWallet'; +import { useTradeMutation, useWalletHoldings, useReinvestDividendMutation } from '@/hooks/useWallet'; import showToast from '@/utils/toast.util'; import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; @@ -66,9 +66,8 @@ import { CREATOR_CARD_ENTRY_CLASS, creatorCardEntryStyle, } from '@/utils/cardEntryAnimation.utils'; -import { - resolveCreatorKeyPriceStroops, -} from '@/utils/keyPriceDisplay.utils'; +import { resolveCreatorKeyPriceStroops, formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; +import { estimateReinvest } from '@/utils/reinvestDividend.utils'; import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'; import { useNavigationTiming } from '@/hooks/useNavigationTiming'; import { CREATOR_LIST_SORT_LAYOUT_TRANSITION } from '@/utils/creatorListSortTransition'; @@ -775,6 +774,7 @@ function LandingPage() { const activeWalletAddress = connectedAddress || DEMO_WALLET_ADDRESS; const tradeMutation = useTradeMutation(activeWalletAddress); + const reinvestMutation = useReinvestDividendMutation(activeWalletAddress); const { data: cachedHoldings = [] } = useWalletHoldings(activeWalletAddress); // Merged: keep total-value sorting (feature/holdings-sorting-tests) while @@ -801,6 +801,7 @@ function LandingPage() { isPriceLoading: isPriceRefreshing, isPriceStale: creatorsAreStale, pending: cached?.pending ?? false, + unclaimedDividend: cached?.unclaimedDividend ?? 0, }; }) ), @@ -1491,7 +1492,33 @@ function LandingPage() { creator={creator} onBuy={() => openTradeDialog('buy')} onSell={() => openTradeDialog('sell')} + onReinvest={async creatorId => { + const pos = heldKeyPositions.find( + p => p.creatorId === creatorId + ); + const keyPriceStroops = + resolveCreatorKeyPriceStroops(pos ?? {}); + const estimate = estimateReinvest( + pos?.unclaimedDividend ?? 0, + keyPriceStroops + ); + if (!estimate) { + showToast.error( + 'Reinvest estimate unavailable. Please refresh prices and try again.' + ); + return; + } + await reinvestMutation.mutateAsync({ + keyId: creatorId, + amount: pos?.unclaimedDividend ?? 0, + keys: estimate.wholeKeys, + }); + showToast.success( + `Reinvested ${formatDisplayKeyPrice(estimate.unclaimedStroops)} — received ${formatNumber(estimate.wholeKeys)} keys` + ); + }} isSubmitting={tradeSubmitting} + isReinvesting={reinvestMutation.isPending} isNetworkMismatch={isNetworkMismatch} /> ); diff --git a/src/utils/__tests__/reinvestDividend.utils.test.ts b/src/utils/__tests__/reinvestDividend.utils.test.ts new file mode 100644 index 00000000..4da6e00c --- /dev/null +++ b/src/utils/__tests__/reinvestDividend.utils.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + estimateReinvest, + hasUnclaimedDividend, + xlmToStroops, +} from '../reinvestDividend.utils'; + +describe('estimateReinvest', () => { + it('computes whole keys and XLM remainder from an unclaimed dividend', () => { + // 1.25 XLM unclaimed, key price 0.5 XLM (5_000_000 stroops) + const result = estimateReinvest(1.25, 5_000_000); + + expect(result).not.toBeNull(); + expect(result!.unclaimedStroops).toBe(12_500_000); + expect(result!.estimatedKeys).toBe(2.5); + expect(result!.wholeKeys).toBe(2); + // remainder: 12_500_000 % 5_000_000 = 2_500_000 stroops = 0.25 XLM + expect(result!.remainderStroops).toBe(2_500_000); + }); + + it('returns null when there is no unclaimed dividend', () => { + expect(estimateReinvest(0, 5_000_000)).toBeNull(); + expect(estimateReinvest(null, 5_000_000)).toBeNull(); + expect(estimateReinvest(undefined, 5_000_000)).toBeNull(); + }); + + it('returns null when key price is missing or non-positive', () => { + expect(estimateReinvest(1.25, null)).toBeNull(); + expect(estimateReinvest(1.25, undefined)).toBeNull(); + expect(estimateReinvest(1.25, 0)).toBeNull(); + expect(estimateReinvest(1.25, -100)).toBeNull(); + }); + + it('returns zero whole keys when dividend cannot buy one full key', () => { + // 0.1 XLM unclaimed at 1 XLM/key -> 0 whole keys, full remainder + const result = estimateReinvest(0.1, 10_000_000); + + expect(result).not.toBeNull(); + expect(result!.unclaimedStroops).toBe(1_000_000); + expect(result!.wholeKeys).toBe(0); + expect(result!.remainderStroops).toBe(1_000_000); + }); +}); + +describe('hasUnclaimedDividend', () => { + it('is true only when a positive balance exists', () => { + expect(hasUnclaimedDividend(0.5)).toBe(true); + expect(hasUnclaimedDividend(0)).toBe(false); + expect(hasUnclaimedDividend(null)).toBe(false); + expect(hasUnclaimedDividend(undefined)).toBe(false); + }); +}); + +describe('xlmToStroops', () => { + it('converts XLM to stroops and sanitizes invalid input', () => { + expect(xlmToStroops(1)).toBe(10_000_000); + expect(xlmToStroops(0)).toBe(0); + expect(xlmToStroops(null)).toBe(0); + expect(xlmToStroops(-5)).toBe(0); + }); +}); diff --git a/src/utils/portfolioValue.utils.ts b/src/utils/portfolioValue.utils.ts index 15efb8b8..f1e16886 100644 --- a/src/utils/portfolioValue.utils.ts +++ b/src/utils/portfolioValue.utils.ts @@ -11,6 +11,12 @@ export interface HeldKeyPosition extends CreatorKeyPriceFields { isPriceStale?: boolean; pending?: boolean; last_buy_timestamp?: number | string | null; + /** + * Unclaimed dividends (in XLM) accrued on this held position. When greater + * than zero the holding row surfaces a badge and a Reinvest action that + * compounds the balance back into more creator keys. + */ + unclaimedDividend?: number | null; } export type PortfolioValueStatus = 'ready' | 'loading' | 'unavailable'; diff --git a/src/utils/reinvestDividend.utils.ts b/src/utils/reinvestDividend.utils.ts new file mode 100644 index 00000000..135c24c8 --- /dev/null +++ b/src/utils/reinvestDividend.utils.ts @@ -0,0 +1,83 @@ +import { STROOPS_PER_XLM } from '@/constants/stellar'; + +/** + * Reinvest-dividend math for the portfolio page (#824). + * + * An unclaimed dividend balance (in XLM) is compounded back into more creator + * keys using the current per-key price. Because keys are purchased in whole + * units, only the whole-key portion is converted and the remainder stays as + * XLM in the wallet. + */ + +export interface ReinvestEstimate { + /** Unclaimed dividend expressed in stroops. */ + unclaimedStroops: number; + /** Per-key price in stroops (null when unavailable). */ + keyPriceStroops: number | null; + /** Fractional number of keys the balance could buy. */ + estimatedKeys: number; + /** Whole keys actually bought when reinvesting. */ + wholeKeys: number; + /** XLM remainder (in stroops) not converted to a whole key. */ + remainderStroops: number; +} + +function toNonNegative(value: number | null | undefined): number { + if (value == null || !Number.isFinite(value) || value <= 0) return 0; + return value; +} + +/** + * Estimates the key purchase from an unclaimed dividend balance. + * + * Returns `null` when the estimate cannot be computed (no unclaimed balance or + * no usable key price) so callers can render a placeholder. + */ +export function estimateReinvest( + unclaimedDividendXlm: number | null | undefined, + keyPriceStroops: number | null | undefined +): ReinvestEstimate | null { + if ( + keyPriceStroops == null || + !Number.isFinite(keyPriceStroops) || + keyPriceStroops <= 0 + ) { + return null; + } + + const unclaimedStroops = + toNonNegative(unclaimedDividendXlm) * STROOPS_PER_XLM; + + if (unclaimedStroops <= 0) { + return null; + } + + const estimatedKeys = unclaimedStroops / keyPriceStroops; + const wholeKeys = Math.floor(estimatedKeys); + const remainderStroops = unclaimedStroops % keyPriceStroops; + + return { + unclaimedStroops, + keyPriceStroops, + estimatedKeys, + wholeKeys, + remainderStroops, + }; +} + +/** + * Converts an XLM amount stored as a decimal number into stroops. + */ +export function xlmToStroops(xlm: number | null | undefined): number { + return toNonNegative(xlm) * STROOPS_PER_XLM; +} + +/** + * Whether a position carries an unclaimed dividend balance worth surfacing a + * badge for. + */ +export function hasUnclaimedDividend( + unclaimedDividendXlm: number | null | undefined +): boolean { + return toNonNegative(unclaimedDividendXlm) > 0; +}