diff --git a/src/components/common/PortfolioHoldingRow.tsx b/src/components/common/PortfolioHoldingRow.tsx index e118fbe..173ab44 100644 --- a/src/components/common/PortfolioHoldingRow.tsx +++ b/src/components/common/PortfolioHoldingRow.tsx @@ -19,8 +19,10 @@ 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; + onBurn?: (creatorId: string) => void; isSubmitting?: boolean; isReinvesting?: boolean; isRedeeming?: boolean; @@ -32,8 +34,10 @@ export const PortfolioHoldingRow: React.FC = ({ creator, onBuy, onSell, - onReinvest, - onRedeem, + onFreeze, + onUnfreeze, + onTransfer, + onBurn, isSubmitting = false, isReinvesting = false, isRedeeming = false, @@ -41,18 +45,10 @@ 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 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 [isExpanded, setIsExpanded] = useState(false); + const frozenQuantity = position.frozenQuantity ?? 0; + const liquidQuantity = position.liquidQuantity ?? position.quantity ?? 0; + const isLiquidEmpty = liquidQuantity <= 0; const handleConfirmRedeem = async () => { if (!onRedeem) return; @@ -70,7 +66,7 @@ export const PortfolioHoldingRow: React.FC = ({ data-testid="portfolio-holding-row" >
-
+
+
{formatNumber(position.quantity)} keys ·{' '} {position.isPriceLoading @@ -115,59 +108,62 @@ export const PortfolioHoldingRow: React.FC = ({ )}
- {deprecated ? ( - onRedeem && ( - - ) - ) : ( - <> - {onReinvest && hasDividends && ( - - )} - {onBuy && ( - - )} - {onSell && ( - - )} - + {onReinvest && hasDividends && ( + + )} + {onBuy && ( + + )} + {onSell && ( + )}
+ {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/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 new file mode 100644 index 0000000..bfa4251 --- /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 0000000..7756735 --- /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 diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index 10d0506..055f619 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 68cb95d..7a97027 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -48,10 +48,10 @@ 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'; @@ -62,9 +62,7 @@ import { formatPortfolioValueDisplay, getPortfolioValueHelperText, sortHoldingsByTotalValue, - calculatePnLSummary, - formatPnLDisplay, - formatPnLPercentage, + type HeldKeyPosition, } from '@/utils/portfolioValue.utils'; import PrecisionModeToggle, { type PrecisionMode, @@ -96,6 +94,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' }, @@ -299,6 +298,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(() => { @@ -806,8 +809,7 @@ function LandingPage() { const activeWalletAddress = connectedAddress || DEMO_WALLET_ADDRESS; const tradeMutation = useTradeMutation(activeWalletAddress); - const reinvestMutation = useReinvestDividendMutation(activeWalletAddress); - const redeemMutation = useRedeemDeprecatedKeyMutation(activeWalletAddress); + const selfFreezeMutation = useSelfFreezeMutation(activeWalletAddress); const { data: cachedHoldings = [] } = useWalletHoldings(activeWalletAddress); // Merged: keep total-value sorting (feature/holdings-sorting-tests) while @@ -831,6 +833,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, @@ -874,6 +879,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) => { @@ -1592,45 +1623,8 @@ 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 - ); - await redeemMutation.mutateAsync({ - creatorId, - quantity: pos?.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} @@ -1641,6 +1635,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} + />