From a7251963dcab070a43cc375d7dde5b6f46e7ec5e Mon Sep 17 00:00:00 2001 From: ponmileleke54-dev Date: Fri, 28 Aug 2026 13:24:06 +0000 Subject: [PATCH 1/2] 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/2] 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} + />