From a7251963dcab070a43cc375d7dde5b6f46e7ec5e Mon Sep 17 00:00:00 2001 From: ponmileleke54-dev Date: Fri, 28 Aug 2026 13:24:06 +0000 Subject: [PATCH 1/3] test: cover staking claim lock expiry --- src/components/common/StakingPanel.tsx | 45 ++++++++++++++++ .../common/__tests__/StakingPanel.test.tsx | 52 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 src/components/common/StakingPanel.tsx create mode 100644 src/components/common/__tests__/StakingPanel.test.tsx diff --git a/src/components/common/StakingPanel.tsx b/src/components/common/StakingPanel.tsx new file mode 100644 index 00000000..bfa42512 --- /dev/null +++ b/src/components/common/StakingPanel.tsx @@ -0,0 +1,45 @@ +import React, { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { formatCountdownTime } from '@/utils/lockupCountdown.utils'; + +export interface StakingPanelProps { + key_id: string | number; + unlock_ledger: number; + onClaim: (keyId: string | number) => void | Promise; +} + +const getRemainingSeconds = (unlockLedger: number): number => + Math.max(0, Math.ceil((unlockLedger * 1000 - Date.now()) / 1000)); + +const StakingPanel: React.FC = ({ key_id, unlock_ledger, onClaim }) => { + const [remainingSeconds, setRemainingSeconds] = useState(() => + getRemainingSeconds(unlock_ledger) + ); + + useEffect(() => { + const updateRemaining = () => setRemainingSeconds(getRemainingSeconds(unlock_ledger)); + updateRemaining(); + + const intervalId = setInterval(updateRemaining, 1000); + return () => clearInterval(intervalId); + }, [unlock_ledger]); + + const isLocked = remainingSeconds > 0; + + return ( +
+ + {formatCountdownTime(remainingSeconds)} + + +
+ ); +}; + +export default StakingPanel; \ No newline at end of file diff --git a/src/components/common/__tests__/StakingPanel.test.tsx b/src/components/common/__tests__/StakingPanel.test.tsx new file mode 100644 index 00000000..7756735e --- /dev/null +++ b/src/components/common/__tests__/StakingPanel.test.tsx @@ -0,0 +1,52 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import StakingPanel from '@/components/common/StakingPanel'; + +describe('StakingPanel (#815)', () => { + const now = 1_700_000_000_000; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('disables Claim while unlock_ledger is in the future', () => { + render(); + + expect(screen.getByTestId('staking-claim-button')).toBeDisabled(); + }); + + it('enables Claim when unlock_ledger has passed', () => { + render(); + + expect(screen.getByTestId('staking-claim-button')).not.toBeDisabled(); + }); + + it('displays the lock expiry countdown in HH:MM:SS format', () => { + render(); + + expect(screen.getByTestId('staking-lock-countdown')).toHaveTextContent('01:01:01'); + }); + + it('enables Claim automatically when the countdown reaches zero', () => { + render(); + + act(() => vi.advanceTimersByTime(2000)); + + expect(screen.getByTestId('staking-lock-countdown')).toHaveTextContent('00:00:00'); + expect(screen.getByTestId('staking-claim-button')).not.toBeDisabled(); + }); + + it('calls the contract boundary with the correct key_id on Claim', () => { + const onClaim = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId('staking-claim-button')); + + expect(onClaim).toHaveBeenCalledWith(42); + }); +}); \ No newline at end of file From deca62c02f702b988aeb5ee79906a23254166426 Mon Sep 17 00:00:00 2001 From: ponmileleke54-dev Date: Fri, 28 Aug 2026 15:22:57 +0000 Subject: [PATCH 2/3] feat: add self-freeze controls to portfolio holdings --- src/components/common/PortfolioHoldingRow.tsx | 37 +++++- src/components/common/SelfFreezeDialog.tsx | 114 ++++++++++++++++++ src/hooks/useWallet.ts | 81 +++++++++++++ src/pages/LandingPage.tsx | 61 +++++++++- src/utils/portfolioValue.utils.ts | 2 + 5 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 src/components/common/SelfFreezeDialog.tsx diff --git a/src/components/common/PortfolioHoldingRow.tsx b/src/components/common/PortfolioHoldingRow.tsx index 37a32cc1..f3d737d4 100644 --- a/src/components/common/PortfolioHoldingRow.tsx +++ b/src/components/common/PortfolioHoldingRow.tsx @@ -13,6 +13,10 @@ export interface PortfolioHoldingRowProps { creator?: Course; onBuy?: (creatorId: string) => void; onSell?: (creatorId: string) => void; + onFreeze?: (position: HeldKeyPosition) => void; + onUnfreeze?: (position: HeldKeyPosition) => void; + onTransfer?: (creatorId: string) => void; + onBurn?: (creatorId: string) => void; isSubmitting?: boolean; isNetworkMismatch?: boolean; } @@ -22,11 +26,19 @@ export const PortfolioHoldingRow: React.FC = ({ creator, onBuy, onSell, + onFreeze, + onUnfreeze, + onTransfer, + onBurn, isSubmitting = false, isNetworkMismatch = false, }) => { const initialRemaining = computeRemainingLockupSeconds(position.last_buy_timestamp); const [isLocked, setIsLocked] = useState(initialRemaining > 0); + const [isExpanded, setIsExpanded] = useState(false); + const frozenQuantity = position.frozenQuantity ?? 0; + const liquidQuantity = position.liquidQuantity ?? position.quantity ?? 0; + const isLiquidEmpty = liquidQuantity <= 0; return (
= ({ data-testid="portfolio-holding-row" >
-
+
+
{formatNumber(position.quantity)} keys ·{' '} {position.isPriceLoading @@ -82,7 +94,7 @@ export const PortfolioHoldingRow: React.FC = ({ variant="outline" className="rounded-xl" onClick={() => onSell(position.creatorId)} - disabled={isLocked || isNetworkMismatch || isSubmitting} + disabled={isLocked || isLiquidEmpty || isNetworkMismatch || isSubmitting} data-testid="holding-sell-button" > Sell @@ -90,6 +102,25 @@ export const PortfolioHoldingRow: React.FC = ({ )}
+ {isExpanded && ( +
+
+
+

Self-freeze

+
+
Frozen
{formatNumber(frozenQuantity)} keys
+
Liquid
{formatNumber(liquidQuantity)} keys
+
+
+
+ {onFreeze && } + {onUnfreeze && } + {onTransfer && } + {onBurn && } +
+
+
+ )}
); }; diff --git a/src/components/common/SelfFreezeDialog.tsx b/src/components/common/SelfFreezeDialog.tsx new file mode 100644 index 00000000..616ed5e8 --- /dev/null +++ b/src/components/common/SelfFreezeDialog.tsx @@ -0,0 +1,114 @@ +import { useEffect, useMemo, useRef, useState } 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 type { SelfFreezeAction } from '@/hooks/useWallet'; + +interface SelfFreezeDialogProps { + open: boolean; + action: SelfFreezeAction; + creatorName: string; + availableQuantity: number; + isSubmitting?: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (amount: number) => Promise | void; +} + +export default function SelfFreezeDialog({ + open, + action, + creatorName, + availableQuantity, + isSubmitting = false, + onOpenChange, + onConfirm, +}: SelfFreezeDialogProps) { + const [amountText, setAmountText] = useState('1'); + const [touched, setTouched] = useState(false); + const amountInputRef = useRef(null); + + useEffect(() => { + if (open) { + setAmountText('1'); + setTouched(false); + } + }, [open]); + + const amount = Number(amountText); + const validationError = useMemo(() => { + if (!amountText.trim()) return 'Please enter an amount.'; + if (!Number.isFinite(amount) || amount <= 0) + return 'Amount must be greater than zero.'; + if (amount > availableQuantity) + return `You can't ${action} more than your available balance (${formatNumber(availableQuantity)} keys).`; + return null; + }, [action, amount, amountText, availableQuantity]); + const showError = touched && validationError !== null; + const label = action === 'freeze' ? 'Freeze' : 'Unfreeze'; + + return ( + !isSubmitting && onOpenChange(next)}> + { + event.preventDefault(); + amountInputRef.current?.focus(); + }} + > + + {label} keys + + {label} keys for {creatorName} so they {action === 'freeze' ? 'cannot be sold or transferred' : 'can be sold or transferred again'}. + + +
+ + { + setAmountText(event.target.value); + setTouched(true); + }} + disabled={isSubmitting} + className="w-full rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-white outline-none focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15" + aria-label={`${label} quantity`} + aria-invalid={showError || undefined} + data-testid="self-freeze-amount" + /> + {showError &&

{validationError}

} +

Available: {formatNumber(availableQuantity)} keys

+
+ + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index 9b120076..7126f7a3 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -195,6 +195,87 @@ export function useTradeMutation(address: string) { return mutation; } +export type SelfFreezeAction = 'freeze' | 'unfreeze'; + +export interface SelfFreezeVariables { + creatorId: string; + amount: number; + action: SelfFreezeAction; +} + +async function submitWalletContractCall( + functionName: 'self_freeze' | 'self_unfreeze', + args: { creatorId: string; quantity: number } +) { + void functionName; + void args; + await new Promise(resolve => window.setTimeout(resolve, 900)); + return { success: true as const }; +} + +export function useSelfFreezeMutation(address: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['contract', 'self_freeze', address], + mutationFn: async ({ creatorId, amount, action }: SelfFreezeVariables) => { + const contractFunction = action === 'freeze' ? 'self_freeze' : 'self_unfreeze'; + return submitWalletContractCall(contractFunction, { + creatorId, + quantity: amount, + }); + }, + onMutate: async ({ creatorId, amount, action }) => { + const queryKey = queryKeys.wallet.holdings(address); + await queryClient.cancelQueries({ queryKey }); + const previousHoldings = + queryClient.getQueryData(queryKey) ?? []; + + queryClient.setQueryData(queryKey, holdings => + (holdings ?? []).map(holding => { + if (holding.creatorId !== creatorId) return holding; + const frozen = holding.frozenQuantity ?? 0; + const liquid = holding.liquidQuantity ?? holding.quantity ?? 0; + const delta = action === 'freeze' ? amount : -amount; + return { + ...holding, + frozenQuantity: frozen + delta, + liquidQuantity: liquid - delta, + pending: true, + }; + }) + ); + + return { previousHoldings }; + }, + onError: (error, _variables, context) => { + if (context?.previousHoldings) { + queryClient.setQueryData( + queryKeys.wallet.holdings(address), + context.previousHoldings + ); + } + showToast.error(getSignatureErrorMessage(error)); + }, + onSuccess: (_data, variables) => { + queryClient.setQueryData( + queryKeys.wallet.holdings(address), + (holdings = []) => + holdings.map(holding => + holding.creatorId === variables.creatorId + ? { ...holding, pending: false } + : holding + ) + ); + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.wallet.holdings(address), + }); + }, + }); +} + export interface BatchOrder { creatorId: string; quantity: number; diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 63bd053b..3573ea21 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -41,7 +41,12 @@ 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 { + useSelfFreezeMutation, + useTradeMutation, + useWalletHoldings, + type SelfFreezeAction, +} from '@/hooks/useWallet'; import showToast from '@/utils/toast.util'; import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; @@ -51,6 +56,7 @@ import { formatPortfolioValueDisplay, getPortfolioValueHelperText, sortHoldingsByTotalValue, + type HeldKeyPosition, } from '@/utils/portfolioValue.utils'; import PrecisionModeToggle, { type PrecisionMode, @@ -79,6 +85,7 @@ import CreatorListPagination from '@/components/common/CreatorListPagination'; import CreatorListGroupSeparator from '@/components/common/CreatorListGroupSeparator'; import MarketplaceSidebar from '@/components/common/MarketplaceSidebar'; import { copyTextToClipboard } from '@/utils/clipboard.utils'; +import SelfFreezeDialog from '@/components/common/SelfFreezeDialog'; const FEATURED_CREATOR_FACTS = [ { label: 'Membership', value: 'Collectors Circle' }, @@ -281,6 +288,10 @@ function LandingPage() { const [tradeSide, setTradeSide] = useState('buy'); const [tradeDialogOpen, setTradeDialogOpen] = useState(false); const [tradeSubmitting, setTradeSubmitting] = useState(false); + const [selfFreezeDialog, setSelfFreezeDialog] = useState<{ + action: SelfFreezeAction; + position: HeldKeyPosition; + } | null>(null); const [stellarAddressCopied, setStellarAddressCopied] = useState(false); const prefersReducedMotion = usePrefersReducedMotion(); const [sortOption, setSortOption] = useState(() => { @@ -775,6 +786,7 @@ function LandingPage() { const activeWalletAddress = connectedAddress || DEMO_WALLET_ADDRESS; const tradeMutation = useTradeMutation(activeWalletAddress); + const selfFreezeMutation = useSelfFreezeMutation(activeWalletAddress); const { data: cachedHoldings = [] } = useWalletHoldings(activeWalletAddress); // Merged: keep total-value sorting (feature/holdings-sorting-tests) while @@ -798,6 +810,9 @@ function LandingPage() { quantity: cached?.quantity ?? baseQuantity, priceStroops: creator.priceStroops, price: creator.price, + frozenQuantity: cached?.frozenQuantity ?? 0, + liquidQuantity: + cached?.liquidQuantity ?? cached?.quantity ?? baseQuantity, isPriceLoading: isPriceRefreshing, isPriceStale: creatorsAreStale, pending: cached?.pending ?? false, @@ -836,6 +851,32 @@ function LandingPage() { setTradeDialogOpen(true); }, []); + const openSelfFreezeDialog = useCallback( + (action: SelfFreezeAction, position: HeldKeyPosition) => { + setSelfFreezeDialog({ action, position }); + }, + [] + ); + + const handleConfirmSelfFreeze = async (amount: number) => { + if (!selfFreezeDialog) return; + const { action, position } = selfFreezeDialog; + try { + await selfFreezeMutation.mutateAsync({ + creatorId: position.creatorId, + amount, + action, + }); + setSelfFreezeDialog(null); + showToast.transactionSuccess( + `${action === 'freeze' ? 'Freeze' : 'Unfreeze'} confirmed`, + `${action === 'freeze' ? 'Froze' : 'Unfroze'} ${formatNumber(amount)} key${amount === 1 ? '' : 's'}` + ); + } catch { + // The mutation reports the signing error and restores its optimistic cache. + } + }; + // Issue 554: T key opens the trade panel from the creator profile page. useEffect(() => { const handleTradeShortcut = (event: KeyboardEvent) => { @@ -1491,6 +1532,8 @@ function LandingPage() { creator={creator} onBuy={() => openTradeDialog('buy')} onSell={() => openTradeDialog('sell')} + onFreeze={position => openSelfFreezeDialog('freeze', position)} + onUnfreeze={position => openSelfFreezeDialog('unfreeze', position)} isSubmitting={tradeSubmitting} isNetworkMismatch={isNetworkMismatch} /> @@ -1499,6 +1542,22 @@ function LandingPage() { )} + item.id === selfFreezeDialog?.position.creatorId)?.title ?? + 'creator' + } + availableQuantity={ + selfFreezeDialog?.action === 'unfreeze' + ? selfFreezeDialog.position.frozenQuantity ?? 0 + : selfFreezeDialog?.position.liquidQuantity ?? 0 + } + isSubmitting={selfFreezeMutation.isPending} + onOpenChange={open => !open && setSelfFreezeDialog(null)} + onConfirm={handleConfirmSelfFreeze} + /> Date: Tue, 8 Sep 2026 11:44:46 +0000 Subject: [PATCH 3/3] fix: resolve PR lint and type errors --- src/components/common/PortfolioHoldingRow.tsx | 100 ++++++++++++------ src/components/common/RedeemKeyDialog.tsx | 6 +- src/pages/LandingPage.tsx | 57 +++++++--- 3 files changed, 115 insertions(+), 48 deletions(-) diff --git a/src/components/common/PortfolioHoldingRow.tsx b/src/components/common/PortfolioHoldingRow.tsx index 173ab44a..31cf42b2 100644 --- a/src/components/common/PortfolioHoldingRow.tsx +++ b/src/components/common/PortfolioHoldingRow.tsx @@ -19,6 +19,8 @@ export interface PortfolioHoldingRowProps { creator?: Course; onBuy?: (creatorId: string) => void; onSell?: (creatorId: string) => void; + onReinvest?: (creatorId: string) => Promise | void; + onRedeem?: (creatorId: string) => Promise | void; onFreeze?: (position: HeldKeyPosition) => void; onUnfreeze?: (position: HeldKeyPosition) => void; onTransfer?: (creatorId: string) => void; @@ -34,6 +36,8 @@ export const PortfolioHoldingRow: React.FC = ({ creator, onBuy, onSell, + onReinvest, + onRedeem, onFreeze, onUnfreeze, onTransfer, @@ -45,10 +49,21 @@ export const PortfolioHoldingRow: React.FC = ({ }) => { const initialRemaining = computeRemainingLockupSeconds(position.last_buy_timestamp); const [isLocked, setIsLocked] = useState(initialRemaining > 0); + const [reinvestOpen, setReinvestOpen] = useState(false); + const [redeemOpen, setRedeemOpen] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const frozenQuantity = position.frozenQuantity ?? 0; const liquidQuantity = position.liquidQuantity ?? position.quantity ?? 0; const isLiquidEmpty = liquidQuantity <= 0; + const hasDividends = hasUnclaimedDividend(position.unclaimedDividend); + const keyPriceStroops = resolveCreatorKeyPriceStroops(position); + const deprecated = isKeyDeprecated(creator); + + const handleConfirmReinvest = async () => { + if (!onReinvest) return; + await onReinvest(position.creatorId); + setReinvestOpen(false); + }; const handleConfirmRedeem = async () => { if (!onRedeem) return; @@ -70,6 +85,7 @@ export const PortfolioHoldingRow: React.FC = ({ {creator?.title ?? 'Unknown creator'} + {deprecated && } {position.pending && ( @@ -108,40 +124,56 @@ export const PortfolioHoldingRow: React.FC = ({ )}
- {onReinvest && hasDividends && ( - - )} - {onBuy && ( - - )} - {onSell && ( - + {deprecated ? ( + onRedeem && ( + + ) + ) : ( + <> + {onReinvest && hasDividends && ( + + )} + {onBuy && ( + + )} + {onSell && ( + + )} + )}
diff --git a/src/components/common/RedeemKeyDialog.tsx b/src/components/common/RedeemKeyDialog.tsx index 023803b8..b3675052 100644 --- a/src/components/common/RedeemKeyDialog.tsx +++ b/src/components/common/RedeemKeyDialog.tsx @@ -13,6 +13,7 @@ import { formatNumber } from '@/utils/numberFormat.utils'; import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; import { estimateRedeemValue, type RedeemEstimate } from '@/utils/keyDeprecation.utils'; import type { CreatorKeyPriceFields } from '@/utils/keyPriceDisplay.utils'; +import { STROOPS_PER_XLM } from '@/constants/stellar'; export interface RedeemKeyDialogProps { open: boolean; @@ -112,7 +113,10 @@ const RedeemKeyDialog: React.FC = ({ Redemption value {estimate - ? formatDisplayKeyPrice(estimate.totalValueStroops) + ? `${formatNumber(estimate.totalValueStroops / STROOPS_PER_XLM, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} XLM` : 'Unavailable'} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 76679d6e..4d4d184d 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -51,6 +51,8 @@ import { useSelfFreezeMutation, useTradeMutation, useWalletHoldings, + useReinvestDividendMutation, + useRedeemDeprecatedKeyMutation, type SelfFreezeAction, } from '@/hooks/useWallet'; import showToast from '@/utils/toast.util'; @@ -62,6 +64,9 @@ import { formatPortfolioValueDisplay, getPortfolioValueHelperText, sortHoldingsByTotalValue, + calculatePnLSummary, + formatPnLDisplay, + formatPnLPercentage, type HeldKeyPosition, } from '@/utils/portfolioValue.utils'; import PrecisionModeToggle, { @@ -80,9 +85,11 @@ import { } from '@/utils/cardEntryAnimation.utils'; import { resolveCreatorKeyPriceStroops, - formatDisplayKeyPrice, } from '@/utils/keyPriceDisplay.utils'; import { estimateReinvest } from '@/utils/reinvestDividend.utils'; +import { useTradeKeyboardShortcuts } from '@/hooks/useTradeKeyboardShortcuts'; +import KeyboardShortcutsHelp from '@/components/common/KeyboardShortcutsHelp'; +import TradeShortcutHints from '@/components/common/TradeShortcutHints'; import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'; import { useNavigationTiming } from '@/hooks/useNavigationTiming'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; @@ -761,6 +768,8 @@ function LandingPage() { const tradeMutation = useTradeMutation(activeWalletAddress); const selfFreezeMutation = useSelfFreezeMutation(activeWalletAddress); + const reinvestMutation = useReinvestDividendMutation(activeWalletAddress); + const redeemMutation = useRedeemDeprecatedKeyMutation(activeWalletAddress); const { data: cachedHoldings = [] } = useWalletHoldings(activeWalletAddress); // Merged: keep total-value sorting (feature/holdings-sorting-tests) while @@ -830,6 +839,13 @@ function LandingPage() { setTradeDialogOpen(true); }, []); + const handleConfirmTradeViaShortcut = useCallback(() => { + const confirmButton = document.querySelector( + '[data-testid="trade-dialog-confirm"]' + ) as HTMLButtonElement | null; + confirmButton?.click(); + }, []); + const openSelfFreezeDialog = useCallback( (action: SelfFreezeAction, position: HeldKeyPosition) => { setSelfFreezeDialog({ action, position }); @@ -856,18 +872,6 @@ function LandingPage() { } }; - // Issue 554: T key opens the trade panel from the creator profile page. - useEffect(() => { - const handleTradeShortcut = (event: KeyboardEvent) => { - if ( - event.defaultPrevented || - event.repeat || - !isTradeShortcut(event) || - isEditableShortcutTarget(event.target) - ) { - return; - } - // Toggle shortcuts help dialog const toggleShortcutsHelp = useCallback(() => { setShortcutsHelpOpen(prev => !prev); @@ -1608,6 +1612,33 @@ function LandingPage() { creator={creator} onBuy={() => openTradeDialog('buy')} onSell={() => openTradeDialog('sell')} + onReinvest={async creatorId => { + const heldPosition = heldKeyPositions.find( + item => item.creatorId === creatorId + ); + const estimate = estimateReinvest( + heldPosition?.unclaimedDividend ?? 0, + resolveCreatorKeyPriceStroops(heldPosition ?? {}) + ); + if (!estimate) { + showToast.error( + 'Reinvest estimate unavailable. Please refresh prices and try again.' + ); + return; + } + await reinvestMutation.mutateAsync({ + keyId: creatorId, + amount: heldPosition?.unclaimedDividend ?? 0, + keys: estimate.wholeKeys, + }); + }} + onRedeem={async creatorId => { + await redeemMutation.mutateAsync({ + creatorId, + quantity: + heldKeyPositions.find(item => item.creatorId === creatorId)?.quantity ?? 0, + }); + }} onFreeze={position => openSelfFreezeDialog('freeze', position)} onUnfreeze={position => openSelfFreezeDialog('unfreeze', position)} isSubmitting={tradeSubmitting}