diff --git a/src/components/common/PortfolioHoldingRow.tsx b/src/components/common/PortfolioHoldingRow.tsx index e118fbe..31cf42b 100644 --- a/src/components/common/PortfolioHoldingRow.tsx +++ b/src/components/common/PortfolioHoldingRow.tsx @@ -21,6 +21,10 @@ export interface PortfolioHoldingRowProps { 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; + onBurn?: (creatorId: string) => void; isSubmitting?: boolean; isReinvesting?: boolean; isRedeeming?: boolean; @@ -34,6 +38,10 @@ export const PortfolioHoldingRow: React.FC = ({ onSell, onReinvest, onRedeem, + onFreeze, + onUnfreeze, + onTransfer, + onBurn, isSubmitting = false, isReinvesting = false, isRedeeming = false, @@ -43,7 +51,10 @@ export const PortfolioHoldingRow: React.FC = ({ 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); @@ -70,20 +81,18 @@ export const PortfolioHoldingRow: React.FC = ({ data-testid="portfolio-holding-row" >
-
+
+
{formatNumber(position.quantity)} keys ·{' '} {position.isPriceLoading @@ -158,7 +167,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 @@ -168,6 +177,25 @@ export const PortfolioHoldingRow: React.FC = ({ )}
+ {isExpanded && ( +
+
+
+

Self-freeze

+
+
Frozen
{formatNumber(frozenQuantity)} keys
+
Liquid
{formatNumber(liquidQuantity)} keys
+
+
+
+ {onFreeze && } + {onUnfreeze && } + {onTransfer && } + {onBurn && } +
+
+
+ )} {onReinvest && hasDividends && position.unclaimedDividend != null && ( diff --git a/src/components/common/RedeemKeyDialog.tsx b/src/components/common/RedeemKeyDialog.tsx index 023803b..b367505 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/components/common/SelfFreezeDialog.tsx b/src/components/common/SelfFreezeDialog.tsx new file mode 100644 index 0000000..616ed5e --- /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/components/common/StakingPanel.tsx b/src/components/common/StakingPanel.tsx index e2a7bd7..ea5bfcf 100644 --- a/src/components/common/StakingPanel.tsx +++ b/src/components/common/StakingPanel.tsx @@ -1,83 +1,42 @@ import React, { useEffect, useState } from 'react'; -import { Clock, Coins } from 'lucide-react'; -import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { formatCountdownTime } from '@/utils/lockupCountdown.utils'; -import { computeRemainingClaimSeconds } from '@/utils/stakingClaim.utils'; export interface StakingPanelProps { - /** Identifier of the staked key, passed to the contract as `key_id`. */ - keyId: string | number; - /** Unix timestamp (seconds) at which the stake unlocks. */ - unlockLedger: number; - /** Called with the key id when the holder claims their unlocked stake. */ + key_id: string | number; + unlock_ledger: number; onClaim: (keyId: string | number) => void | Promise; - /** Whether a claim transaction is currently in flight. */ - isClaiming?: boolean; - className?: string; } -const CARD_CLASS = - 'rounded-[2rem] border border-white/10 bg-white/[0.02] p-6 shadow-2xl backdrop-blur-md md:p-8'; +const getRemainingSeconds = (unlockLedger: number): number => + Math.max(0, Math.ceil((unlockLedger * 1000 - Date.now()) / 1000)); -/** - * Staking panel shown on a held key while its stake is locked (#815). - * - * Displays a live countdown to `unlockLedger` and gates the Claim button on - * that countdown, enabling it automatically once the lock expires without - * requiring a page reload. - */ -const StakingPanel: React.FC = ({ - keyId, - unlockLedger, - onClaim, - isClaiming = false, - className, -}) => { +const StakingPanel: React.FC = ({ key_id, unlock_ledger, onClaim }) => { const [remainingSeconds, setRemainingSeconds] = useState(() => - computeRemainingClaimSeconds(unlockLedger) + getRemainingSeconds(unlock_ledger) ); useEffect(() => { - const updateRemaining = () => - setRemainingSeconds(computeRemainingClaimSeconds(unlockLedger)); - + const updateRemaining = () => setRemainingSeconds(getRemainingSeconds(unlock_ledger)); updateRemaining(); const intervalId = setInterval(updateRemaining, 1000); return () => clearInterval(intervalId); - }, [unlockLedger]); + }, [unlock_ledger]); const isLocked = remainingSeconds > 0; - const formattedTime = formatCountdownTime(remainingSeconds); return ( -
-
-
- -
-
- +
+ + {formatCountdownTime(remainingSeconds)} +
); diff --git a/src/components/common/__tests__/StakingPanel.test.tsx b/src/components/common/__tests__/StakingPanel.test.tsx index b15781f..1de6940 100644 --- a/src/components/common/__tests__/StakingPanel.test.tsx +++ b/src/components/common/__tests__/StakingPanel.test.tsx @@ -1,87 +1,52 @@ -import { act, fireEvent, render, screen, cleanup } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import StakingPanel from '../StakingPanel'; +import StakingPanel from '@/components/common/StakingPanel'; describe('StakingPanel (#815)', () => { - const nowSec = 1_700_000_000; + const now = 1_700_000_000_000; beforeEach(() => { vi.useFakeTimers(); - vi.setSystemTime(nowSec * 1000); + vi.setSystemTime(now); }); afterEach(() => { vi.useRealTimers(); - cleanup(); }); - it('disables the Claim button while unlockLedger is in the future', () => { - render( - - ); + it('disables Claim while unlock_ledger is in the future', () => { + render(); expect(screen.getByTestId('staking-claim-button')).toBeDisabled(); }); - it('enables the Claim button once unlockLedger has passed', () => { - render( - - ); + 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( - - ); + render(); expect(screen.getByTestId('staking-lock-countdown')).toHaveTextContent('01:01:01'); }); - it('counts down and reaches 00:00:00, enabling Claim without a page reload', () => { - render( - - ); + it('enables Claim automatically when the countdown reaches zero', () => { + render(); - expect(screen.getByTestId('staking-lock-countdown')).toHaveTextContent('00:00:02'); - expect(screen.getByTestId('staking-claim-button')).toBeDisabled(); - - act(() => { - vi.advanceTimersByTime(1000); - }); - expect(screen.getByTestId('staking-lock-countdown')).toHaveTextContent('00:00:01'); - expect(screen.getByTestId('staking-claim-button')).toBeDisabled(); + act(() => vi.advanceTimersByTime(2000)); - act(() => { - vi.advanceTimersByTime(1000); - }); expect(screen.getByTestId('staking-lock-countdown')).toHaveTextContent('00:00:00'); expect(screen.getByTestId('staking-claim-button')).not.toBeDisabled(); }); - it('calls onClaim with the correct key_id when the Claim button is clicked', () => { + it('calls the contract boundary with the correct key_id on Claim', () => { const onClaim = vi.fn(); - render(); + render(); fireEvent.click(screen.getByTestId('staking-claim-button')); - expect(onClaim).toHaveBeenCalledTimes(1); expect(onClaim).toHaveBeenCalledWith(42); }); - - it('keeps the Claim button disabled while a claim is already in flight', () => { - render( - - ); - - const claimButton = screen.getByTestId('staking-claim-button'); - expect(claimButton).toBeDisabled(); - expect(claimButton).toHaveTextContent('Claiming…'); - }); }); diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index 734a3c4..da73a0b 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -225,6 +225,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 { address: string; quantity: number; diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 8159359..4d4d184 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -48,10 +48,12 @@ import StellarConnectionQualityBadge from '@/components/common/StellarConnection import { useAccount } from 'wagmi'; import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; import { + useSelfFreezeMutation, useTradeMutation, useWalletHoldings, useReinvestDividendMutation, useRedeemDeprecatedKeyMutation, + type SelfFreezeAction, } from '@/hooks/useWallet'; import showToast from '@/utils/toast.util'; import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; @@ -65,6 +67,7 @@ import { calculatePnLSummary, formatPnLDisplay, formatPnLPercentage, + type HeldKeyPosition, } from '@/utils/portfolioValue.utils'; import PrecisionModeToggle, { type PrecisionMode, @@ -82,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'; @@ -96,9 +101,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 { useTradeKeyboardShortcuts } from '@/hooks/useTradeKeyboardShortcuts'; -import KeyboardShortcutsHelp from '@/components/common/KeyboardShortcutsHelp'; -import TradeShortcutHints from '@/components/common/TradeShortcutHints'; +import SelfFreezeDialog from '@/components/common/SelfFreezeDialog'; const FEATURED_CREATOR_FACTS = [ { label: 'Membership', value: 'Collectors Circle' }, @@ -272,6 +275,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(() => { @@ -760,6 +767,7 @@ function LandingPage() { const activeWalletAddress = connectedAddress || DEMO_WALLET_ADDRESS; const tradeMutation = useTradeMutation(activeWalletAddress); + const selfFreezeMutation = useSelfFreezeMutation(activeWalletAddress); const reinvestMutation = useReinvestDividendMutation(activeWalletAddress); const redeemMutation = useRedeemDeprecatedKeyMutation(activeWalletAddress); const { data: cachedHoldings = [] } = useWalletHoldings(activeWalletAddress); @@ -785,6 +793,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, @@ -828,15 +839,39 @@ function LandingPage() { setTradeDialogOpen(true); }, []); - // Callback to confirm trade via keyboard shortcut (reads current state) const handleConfirmTradeViaShortcut = useCallback(() => { - // Trigger the confirm button click - const confirmBtn = document.querySelector( + const confirmButton = document.querySelector( '[data-testid="trade-dialog-confirm"]' ) as HTMLButtonElement | null; - confirmBtn?.click(); + confirmButton?.click(); }, []); + 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. + } + }; + // Toggle shortcuts help dialog const toggleShortcutsHelp = useCallback(() => { setShortcutsHelpOpen(prev => !prev); @@ -1577,45 +1612,35 @@ 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` - ); - }} - onRedeem={async creatorId => { - const pos = heldKeyPositions.find( - p => p.creatorId === creatorId - ); + 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: pos?.quantity ?? 0, + creatorId, + quantity: + heldKeyPositions.find(item => item.creatorId === creatorId)?.quantity ?? 0, }); - showToast.success( - `Redeemed your ${creator?.title ?? 'deprecated'} key position` - ); }} + onFreeze={position => openSelfFreezeDialog('freeze', position)} + onUnfreeze={position => openSelfFreezeDialog('unfreeze', position)} isSubmitting={tradeSubmitting} isReinvesting={reinvestMutation.isPending} isRedeeming={redeemMutation.isPending} @@ -1626,6 +1651,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} + />