diff --git a/src/App.tsx b/src/App.tsx index f2487a0a..156bb11b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,8 +4,10 @@ import { Toaster } from 'react-hot-toast'; import { createBrowserRouter, RouterProvider } from 'react-router'; import AppErrorBoundary from './components/common/AppErrorBoundary'; +import ContractPausedBanner from './components/common/ContractPausedBanner'; import OfflineBanner from './components/common/OfflineBanner'; import SessionExpiryWatcher from './components/common/SessionExpiryWatcher'; +import { useContractPausedStore } from './hooks/useContractPausedStore'; import { routes } from './routes'; import { useRouteChangeLogging } from './hooks/useRouteChangeLogging'; @@ -14,6 +16,15 @@ const router = createBrowserRouter(routes); function App() { useRouteChangeLogging(); + // Start polling contract pause state on app load (#953) + useEffect(() => { + const store = useContractPausedStore.getState(); + store.startPolling(); + return () => { + store.stopPolling(); + }; + }, []); + useEffect(() => { const lenis = new Lenis({ duration: 1.2, @@ -29,6 +40,8 @@ function App() { return ( + {/* Full-width banner when contract emergency pause is active (#953) */} + = ({ + walletAddress, + className, +}) => { + const { address } = useAccount(); + const activeAddress = walletAddress ?? address; + + const isPaused = useContractPausedStore(selectIsPaused); + const togglePause = useContractPausedStore(state => state.togglePause); + + // Only visible to authorised admin wallets + if (!isAuthorizedAdminWallet(activeAddress)) { + return null; + } + + const handleToggle = () => { + const nextState = !isPaused; + togglePause(); + if (nextState) { + toast.error('Emergency pause activated. All trading disabled.'); + } else { + toast.success('Contract unpaused. Trading resumed.'); + } + }; + + return ( +
+
+
+ {isPaused ? ( +
+
+

+ Admin Controls +

+

+ Contract status:{' '} + + {isPaused ? 'Paused (Trading Suspended)' : 'Active (Trading Open)'} + +

+
+
+ + +
+ ); +}; + +export default AdminPauseControl; diff --git a/src/components/common/AllowanceApprovalStep.tsx b/src/components/common/AllowanceApprovalStep.tsx new file mode 100644 index 00000000..aa67f989 --- /dev/null +++ b/src/components/common/AllowanceApprovalStep.tsx @@ -0,0 +1,133 @@ +/** + * AllowanceApprovalStep (#955) + * + * Inline approval step rendered inside the buy/staking flow when the + * staking contract does not yet have sufficient allowance to transfer + * the user's keys. + * + * Flow: + * 1. Parent checks `needsApproval` (from useAllowanceStore). + * 2. If true, renders this component instead of the normal confirm button. + * 3. User reads the explanation and clicks "Approve transfer". + * 4. On success, `onApproved` is called so the parent can proceed. + * 5. On error, an inline error message is shown with a retry option. + * + * The component is purely presentational with respect to layout — it takes + * no wallet addresses; the parent passes the pre-checked status and the + * submit handler. + */ + +import { ShieldCheck, AlertCircle, Loader2, Info } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { StableButtonContent } from '@/components/ui/stable-button-content'; +import { cn } from '@/lib/utils'; + +export interface AllowanceApprovalStepProps { + /** Whether the approval transaction is currently in-flight. */ + isApproving: boolean; + /** Non-null when the approval transaction failed. */ + errorMessage: string | null; + /** Called when the user clicks "Approve transfer". */ + onApprove: () => void; + className?: string; +} + +const AllowanceApprovalStep: React.FC = ({ + isApproving, + errorMessage, + onApprove, + className, +}) => { + return ( +
+ {/* Header */} +
+
+
+
+

+ Approval required +

+

+ Before staking your keys, you must grant the staking contract + permission to transfer them on your behalf. This is a one-time + on-chain approval per wallet. +

+
+
+ + {/* What the approval grants */} +
+

+

+
    +
  • + + The staking contract may transfer your approved creator keys. +
  • +
  • + + Your wallet retains full custody until a stake action is + confirmed. +
  • +
  • + + You can revoke this approval at any time from your wallet + settings. +
  • +
+
+ + {/* Error state */} + {errorMessage && ( +
+
+ )} + + {/* Approve button */} + +
+ ); +}; + +export default AllowanceApprovalStep; diff --git a/src/components/common/ContractPausedBanner.tsx b/src/components/common/ContractPausedBanner.tsx new file mode 100644 index 00000000..8cf91021 --- /dev/null +++ b/src/components/common/ContractPausedBanner.tsx @@ -0,0 +1,84 @@ +/** + * ContractPausedBanner (#953) + * + * Full-width banner shown at the top of the app when the contract emergency + * pause is active. Dismissed automatically when the contract is unpaused + * (the store polling handles this). + * + * When paused: + * - Renders a prominent red/amber banner with an explanation. + * - All trade action buttons elsewhere are disabled (enforced by callers + * reading `selectIsPaused` from the store). + * + * When not paused (or still checking): renders nothing. + */ + +import { AnimatePresence, motion } from 'framer-motion'; +import { ShieldAlert } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + useContractPausedStore, + selectIsPaused, +} from '@/hooks/useContractPausedStore'; + +interface ContractPausedBannerProps { + className?: string; +} + +const ContractPausedBanner: React.FC = ({ + className, +}) => { + const isPaused = useContractPausedStore(selectIsPaused); + + return ( + + {isPaused && ( + + {/* Subtle animated shimmer to draw attention */} + + + {transactionState === 'success' && ( + + )} + {transactionState === 'submitting' && ( + + )} + {transactionState === 'failed' && ( + + )} + + {isKeyDeprecated(creator) + ? 'Key Deprecated' + : transactionState === 'submitting' + ? 'Processing...' + : transactionState === 'success' + ? 'Completed' + : transactionState === 'failed' + ? 'Retry Purchase' + : 'Buy Key'} + = ({ + data, + className, +}) => { + const fraction = capUsedFraction(data); + const pct = Math.round(fraction * 100); + const atCap = isAtHoldingCap(data); + const approaching = isApproachingCap(data); + const remaining = remainingCapacity(data); + + // Show nothing while loading or when there's no cap data. + if (data.isLoading || data.maxCap === 0) return null; + + return ( +
+ {/* Progress bar row */} +
+
+ + Holding:{' '} + + {data.currentHolding} + {' '} + / {data.maxCap} keys + + + {pct}% + +
+ + {/* Track + fill */} +
+
+
+
+ + {/* Warning banner — approaching cap */} + {approaching && !atCap && ( +
+
+ )} + + {/* Cap-reached state */} + {atCap && ( +
+
+ )} +
+ ); +}; + +export default HoldingCapWarning; diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index 8105aca2..c9ce716e 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -1,4 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useAccount } from 'wagmi'; +import { ShieldAlert } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { StableButtonContent } from '@/components/ui/stable-button-content'; import { @@ -25,17 +27,26 @@ import { } from '@/utils/keyPriceDisplay.utils'; import PercentageBadge from '@/components/common/PercentageBadge'; import NetworkFeeHint from '@/components/common/NetworkFeeHint'; +import AllowanceApprovalStep from '@/components/common/AllowanceApprovalStep'; +import HoldingCapWarning from '@/components/common/HoldingCapWarning'; import BuyFeeBreakdown from '@/components/common/BuyFeeBreakdown'; import SellFeeBreakdown from '@/components/common/SellFeeBreakdown'; import LaunchPenaltyWarning from '@/components/common/LaunchPenaltyWarning'; import SlippageToleranceSelector from '@/components/common/SlippageToleranceSelector'; +import PriceImpactWarning from '@/components/common/PriceImpactWarning'; +import TradeConfirmationModal from '@/components/common/TradeConfirmationModal'; import { TRADE_FEE_ESTIMATE, FEE_BOUNDS, BUY_QUANTITY_BOUNDS, } from '@/constants/fees'; -import { formatTransactionFeeDisplay } from '@/utils/transactionFee.utils'; +import { + fetchTradeNetworkFeeEstimate, + formatTransactionFeeDisplay, + type NetworkFeeDataProvider, +} from '@/utils/transactionFee.utils'; import { clampBuyQuantity } from '@/utils/buyQuantity'; +import { normalizeCreatorDisplayName } from '@/utils/creatorDisplayName.utils'; import { calculateLaunchPenalty } from '@/utils/launchPenalty.utils'; import { fetchPricePreview, @@ -46,26 +57,35 @@ import { type ContractDynamicFeeRate, type DynamicFeeBreakdown as DynamicFeeBreakdownData, } from '@/utils/dynamicFeeRate.utils'; -import { courseService } from '@/services/course.service'; -import PriceImpactWarning from '@/components/common/PriceImpactWarning'; -import PriceImpactOverrideCheckbox from '@/components/common/PriceImpactOverrideCheckbox'; -import TradeConfirmationModal from '@/components/common/TradeConfirmationModal'; +import { courseService, type KeyConfig } from '@/services/course.service'; +import SpreadIndicator from '@/components/common/SpreadIndicator'; +import CircuitBreakerStatusIndicator from '@/components/common/CircuitBreakerStatusIndicator'; +import { evaluateCircuitBreakerStatus } from '@/utils/circuitBreaker.utils'; import { calculateTradePriceImpact, - isHighPriceImpact, + PRICE_IMPACT_THRESHOLD_PERCENT, } from '@/utils/priceImpact.utils'; import { + DEFAULT_SLIPPAGE_TOLERANCE_PERCENT, computeSlippageBounds, type SlippageBounds, } from '@/utils/slippageTolerance.utils'; -import type { KeyConfig } from '@/services/course.service'; -import SpreadIndicator from '@/components/common/SpreadIndicator'; -import HoldingCapIndicator from '@/components/common/HoldingCapIndicator'; -import { useSlippageTolerancePreference } from '@/hooks/useSlippageTolerancePreference'; -import CircuitBreakerStatusIndicator from '@/components/common/CircuitBreakerStatusIndicator'; -import { evaluateCircuitBreakerStatus } from '@/utils/circuitBreaker.utils'; +import { + useAllowanceStore, + selectNeedsApproval, +} from '@/hooks/useAllowanceStore'; +import { + useHoldingCapStore, + isAtHoldingCap, + remainingCapacity, +} from '@/hooks/useHoldingCapStore'; +import { + useContractPausedStore, + selectIsPaused, +} from '@/hooks/useContractPausedStore'; +import { useShallow } from 'zustand/react/shallow'; -export type TradeSide = 'buy' | 'sell'; +export type TradeSide = 'buy' | 'sell' | 'stake' | 'transfer'; /** * Merges contract-returned dynamic fee rates over the dialog's configured @@ -88,6 +108,8 @@ export interface TradeDialogProps { open: boolean; side: TradeSide; creatorName: string; + /** Creator / course ID — used to look up the holding cap. */ + creatorId?: string; availableHoldings: number; /** Per-key price in stroops, shown on the buy confirmation step. */ keyPriceStroops?: number | null; @@ -105,9 +127,6 @@ export interface TradeDialogProps { launchPenaltyBps?: number | null; /** Max buy quantity allowed per transaction; null means no limit. */ maxBuyQuantity?: number | null; - /** Maximum holding cap per wallet configured by creator (#1015); null means no limit. */ - holdingCap?: number | null; - maxHoldingCap?: number | null; /** Live key trading config carrying the bid-ask spread (#951). */ keyConfig?: KeyConfig | null; /** Whether the key config query is still loading. */ @@ -127,15 +146,20 @@ export interface TradeDialogProps { slippage?: SlippageBounds | null ) => Promise | void; isSubmitting?: boolean; - networkFeeEstimateProvider?: { - getFeeData: () => Promise<{ gasPrice?: bigint }>; - }; + networkFeeEstimateProvider?: NetworkFeeDataProvider; + /** Optional wallet address override (otherwise uses wagmi account). */ + walletAddress?: string; } +type NetworkFeeEstimateState = + | { status: 'idle' | 'loading' | 'error'; fee: null } + | { status: 'success'; fee: number }; + const TradeDialog: React.FC = ({ open, side, creatorName, + creatorId, availableHoldings, keyPriceStroops, currentSupply, @@ -145,8 +169,6 @@ const TradeDialog: React.FC = ({ currentLedger, launchPenaltyBps, maxBuyQuantity = null, - holdingCap = null, - maxHoldingCap = null, keyConfig, isKeyConfigLoading = false, circuitBreakerThresholdPercent, @@ -156,292 +178,187 @@ const TradeDialog: React.FC = ({ onOpenChange, onConfirm, isSubmitting = false, + networkFeeEstimateProvider, + walletAddress, }) => { - const effectiveHoldingCap = useMemo( - () => - holdingCap ?? - maxHoldingCap ?? - keyConfig?.holdingCap ?? - keyConfig?.maxHoldingCap ?? - null, - [holdingCap, maxHoldingCap, keyConfig?.holdingCap, keyConfig?.maxHoldingCap] + const { address } = useAccount(); + const activeAddress = walletAddress ?? address; + + // ── Contract pause state (#953) ─────────────────────────────────────── + const isPaused = useContractPausedStore(selectIsPaused); + + // ── Allowance (#955) ────────────────────────────────────────────────── + const { + status: allowanceStatus, + checkAllowance, + submitApproval, + } = useAllowanceStore( + useShallow(s => ({ + status: s.status, + checkAllowance: s.checkAllowance, + submitApproval: s.submitApproval, + })) + ); + const needsApproval = useAllowanceStore(selectNeedsApproval); + const isApproving = allowanceStatus === 'approving'; + const approvalError = + allowanceStatus === 'error' + ? (useAllowanceStore.getState().errorMessage ?? null) + : null; + + // ── Holding cap (#961) ──────────────────────────────────────────────── + const { caps, fetchCap, incrementHolding } = useHoldingCapStore( + useShallow(s => ({ + caps: s.caps, + fetchCap: s.fetchCap, + incrementHolding: s.incrementHolding, + })) ); + const capData = creatorId ? caps[creatorId] : undefined; + const atCap = capData ? isAtHoldingCap(capData) : false; + const maxBuyable = capData ? remainingCapacity(capData) : null; + + // ── Local state ─────────────────────────────────────────────────────── const [amountText, setAmountText] = useState('1'); const [touched, setTouched] = useState(false); + const [adjustmentNote, setAdjustmentNote] = useState(null); + const [networkFeeEstimate, setNetworkFeeEstimate] = + useState({ status: 'idle', fee: null }); const [pricePreview, setPricePreview] = useState(null); const [previewLoading, setPreviewLoading] = useState(false); const [previewError, setPreviewError] = useState(null); - const [slippageTolerancePercent, setSlippageTolerancePercent] = - useSlippageTolerancePreference(); - const [acknowledgedImpactKey, setAcknowledgedImpactKey] = useState< - string | null - >(null); + const [slippageTolerancePercent, setSlippageTolerancePercent] = useState( + DEFAULT_SLIPPAGE_TOLERANCE_PERCENT + ); const [confirmationOpen, setConfirmationOpen] = useState(false); const amountInputRef = useRef(null); const pricePreviewFailureLogged = useRef(false); const previewAbortControllerRef = useRef(null); - // TradeDialog is opened via `open`/`onOpenChange` props from several - // different external trigger buttons (see LandingPage.tsx), never via - // Radix's own . That means Radix's built-in - // focus-return-to-trigger (which targets its own internal triggerRef) - // is always a no-op here — there is no triggerRef to return to. We - // capture whatever had focus right before the dialog opened ourselves - // and restore it in onCloseAutoFocus instead. + // Capture whatever had focus right before dialog opened; restore on close. const triggerElementRef = useRef(null); + // On open: reset state, kick off allowance check + cap fetch. useEffect(() => { if (open) { - triggerElementRef.current = - document.activeElement as HTMLElement | null; + triggerElementRef.current = document.activeElement as HTMLElement | null; setAmountText('1'); setTouched(false); + setAdjustmentNote(null); setPricePreview(null); setPreviewLoading(false); setPreviewError(null); - setAcknowledgedImpactKey(null); + setSlippageTolerancePercent(DEFAULT_SLIPPAGE_TOLERANCE_PERCENT); pricePreviewFailureLogged.current = false; } - }, [open]); + if (open && side === 'stake' && activeAddress) { + checkAllowance(activeAddress); + } + if (open && side === 'buy' && creatorId && activeAddress) { + fetchCap(creatorId, activeAddress); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, side, activeAddress, creatorId]); - // Internal keyboard shortcuts for amount adjustment when the dialog is open. - // Quick presets: 1→1, 2→2, 3→3, 4→5, 5→10, Shift+1→10 - // Adjust: +/- to increment/decrement by 1 + // Keyboard shortcuts for amount adjustment. useEffect(() => { if (!open || isSubmitting) return; const handleAmountKey = (event: KeyboardEvent) => { if (event.defaultPrevented || event.repeat) return; - - // Only intercept when the amount input is focused const activeEl = document.activeElement; if ( !(activeEl instanceof HTMLInputElement) || - activeEl.dataset.testid !== 'trade-dialog-amount' + activeEl.getAttribute('data-testid') !== 'trade-dialog-amount' ) { return; } - - // Ignore if modifier keys are held (except Shift for !) if (event.ctrlKey || event.metaKey || event.altKey) return; - const key = event.key; - - // Quick amount presets - const presets: Record = { - '1': 1, - '2': 2, - '3': 3, - '4': 5, - '5': 10, - }; - + const presets: Record = { '1': 1, '2': 2, '3': 3, '4': 5, '5': 10 }; if (!event.shiftKey && presets[key] !== undefined) { event.preventDefault(); - const clamped = clampBuyQuantity(presets[key].toString()); - setAmountText(clamped.value.toString()); + setAmountText(clampBuyQuantity(presets[key].toString()).value.toString()); setTouched(true); return; } - - // Shift+1 = 10 if (key === '!' && event.shiftKey) { event.preventDefault(); - const clamped = clampBuyQuantity('10'); - setAmountText(clamped.value.toString()); + setAmountText(clampBuyQuantity('10').value.toString()); setTouched(true); return; } - - // Adjust amount: + and - if (key === '+' || (key === '=' && event.shiftKey)) { event.preventDefault(); - const current = Number(amountText) || 0; - const next = Math.max(1, current + 1); - const clamped = clampBuyQuantity(next.toString()); - setAmountText(clamped.value.toString()); + const next = Math.max(1, (Number(amountText) || 0) + 1); + setAmountText(clampBuyQuantity(next.toString()).value.toString()); setTouched(true); return; } - if (key === '-') { event.preventDefault(); - const current = Number(amountText) || 0; - const next = Math.max(1, current - 1); - const clamped = clampBuyQuantity(next.toString()); - setAmountText(clamped.value.toString()); + const next = Math.max(1, (Number(amountText) || 0) - 1); + setAmountText(clampBuyQuantity(next.toString()).value.toString()); setTouched(true); } }; - window.addEventListener('keydown', handleAmountKey); return () => window.removeEventListener('keydown', handleAmountKey); }, [open, isSubmitting, amountText]); - const handleBlur = () => { + const handleBlur = useCallback(() => { setTouched(true); - const normalized = amountText.trim(); - if (normalized) { - const clampedResult = clampBuyQuantity(amountText); - if (clampedResult.adjusted) { - setAmountText(clampedResult.value.toString()); + if (side !== 'buy') return; + const maxClamp = + maxBuyable !== null + ? Math.min(BUY_QUANTITY_BOUNDS.MAX_QTY, maxBuyable) + : BUY_QUANTITY_BOUNDS.MAX_QTY; + const res = clampBuyQuantity(amountText.trim(), BUY_QUANTITY_BOUNDS.MIN_QTY, maxClamp); + if (res.adjusted) { + setAmountText(res.value.toString()); + if (res.reason === 'below_min') { + setAdjustmentNote(`Quantity adjusted to the minimum of ${res.value}.`); + } else if (res.reason === 'above_max') { + setAdjustmentNote(`Quantity adjusted to the maximum of ${res.value}.`); + } else { + setAdjustmentNote(`Quantity rounded to ${res.value}.`); } + } else { + setAdjustmentNote(null); } - }; + }, [amountText, maxBuyable, side]); const parsedAmount = useMemo(() => { const normalized = amountText.trim(); - if (!normalized) return Number.NaN; + if (!normalized) return NaN; return Number(normalized); }, [amountText]); - const isCapLimitReached = useMemo( - () => - side === 'buy' && - effectiveHoldingCap != null && - Number.isFinite(effectiveHoldingCap) && - effectiveHoldingCap > 0 && - availableHoldings >= effectiveHoldingCap, - [side, effectiveHoldingCap, availableHoldings] - ); - - const isCapBreached = useMemo( - () => - side === 'buy' && - effectiveHoldingCap != null && - Number.isFinite(effectiveHoldingCap) && - effectiveHoldingCap > 0 && - Number.isFinite(parsedAmount) && - parsedAmount > 0 && - availableHoldings + parsedAmount > effectiveHoldingCap, - [side, effectiveHoldingCap, availableHoldings, parsedAmount] - ); - - const validationError = useMemo((): string | null => { const normalized = amountText.trim(); if (!normalized) return 'Please enter an amount.'; - if (!Number.isFinite(parsedAmount)) - return 'Amount must be a valid number.'; + if (!Number.isFinite(parsedAmount)) return 'Amount must be a valid number.'; if (parsedAmount <= 0) return 'Amount must be greater than zero.'; - if ( - side === 'buy' && - maxBuyQuantity != null && - parsedAmount > maxBuyQuantity - ) { + if (isPaused) return 'Trading is currently suspended because the contract is paused.'; + if (side === 'buy' && atCap) + return 'You have reached the maximum holding cap for this creator.'; + if (side === 'buy' && maxBuyable !== null && parsedAmount > maxBuyable) + return `You can only buy up to ${formatNumber(maxBuyable)} more key${maxBuyable !== 1 ? 's' : ''} (holding cap).`; + if (side === 'buy' && maxBuyQuantity != null && parsedAmount > maxBuyQuantity) return `Maximum ${formatNumber(maxBuyQuantity)} keys per transaction for this key`; - } if ( - side === 'buy' && - effectiveHoldingCap != null && - Number.isFinite(effectiveHoldingCap) && - effectiveHoldingCap > 0 - ) { - if (availableHoldings >= effectiveHoldingCap) { - return `Holding cap reached (${formatNumber(effectiveHoldingCap)} keys max per wallet).`; - } - if ( - Number.isFinite(parsedAmount) && - parsedAmount > 0 && - availableHoldings + parsedAmount > effectiveHoldingCap - ) { - return `Purchase would exceed the holding cap of ${formatNumber(effectiveHoldingCap)} keys (you hold ${formatNumber(availableHoldings)}).`; - } - } - if (side === 'sell' && parsedAmount > availableHoldings) - return `You can't sell more than your holdings (${formatNumber(availableHoldings)} keys).`; + (side === 'sell' || side === 'stake' || side === 'transfer') && + parsedAmount > availableHoldings + ) + return `You can't ${side} more than your holdings (${formatNumber(availableHoldings)} keys).`; return null; - }, [ - amountText, - parsedAmount, - side, - maxBuyQuantity, - availableHoldings, - effectiveHoldingCap, - ]); + }, [amountText, parsedAmount, side, availableHoldings, atCap, maxBuyable, maxBuyQuantity, isPaused]); const amountValid = validationError === null; const showError = touched && validationError !== null; - const title = side === 'buy' ? 'Buy keys' : 'Sell keys'; - const confirmLabel = useMemo(() => { - if (side === 'sell') return 'Confirm sell'; - if (isCapLimitReached) return 'Holding Cap Reached'; - if (isCapBreached) return 'Holding Cap Exceeded'; - return 'Confirm buy'; - }, [side, isCapLimitReached, isCapBreached]); - const estimatedNetworkFee = formatTransactionFeeDisplay( - TRADE_FEE_ESTIMATE.DEFAULT_NETWORK_FEE, - { unit: TRADE_FEE_ESTIMATE.UNIT } - ); - - const estimatedProceedsStroops = useMemo(() => { - if ( - side !== 'sell' || - !Number.isFinite(parsedAmount) || - parsedAmount <= 0 - ) { - return null; - } - return estimateSellProceeds(keyPriceStroops, currentSupply, parsedAmount); - }, [side, keyPriceStroops, currentSupply, parsedAmount]); - - const launchPenalty = useMemo( - () => - calculateLaunchPenalty( - estimatedProceedsStroops, - createdAtLedger, - currentLedger, - launchPenaltyBps - ), - [ - estimatedProceedsStroops, - createdAtLedger, - currentLedger, - launchPenaltyBps, - ] - ); - - const estimatedTotalStroops = useMemo(() => { - if ( - side !== 'buy' || - !Number.isFinite(parsedAmount) || - parsedAmount <= 0 - ) { - return null; - } - if (keyPriceStroops == null) return null; - return keyPriceStroops * parsedAmount; - }, [side, keyPriceStroops, parsedAmount]); - - // The reference price slippage bounds are computed from: the fee-inclusive - // buy total when available, falling back to the raw estimated total, and - // the estimated sell proceeds on the sell side. This mirrors the same - // price shown to the user just above the slippage selector, so the - // max/min bound the contract enforces always matches what was displayed. - const slippageReferencePriceStroops = useMemo(() => { - if (side === 'buy') { - return pricePreview?.totalCostStroops ?? estimatedTotalStroops ?? null; - } - return estimatedProceedsStroops; - }, [side, pricePreview, estimatedTotalStroops, estimatedProceedsStroops]); - - const slippageBounds = useMemo(() => { - if (slippageReferencePriceStroops == null) return null; - return computeSlippageBounds( - side, - slippageReferencePriceStroops, - slippageTolerancePercent - ); - }, [side, slippageReferencePriceStroops, slippageTolerancePercent]); - const priceImpactPercent = useMemo(() => { - if (!amountValid || !Number.isFinite(parsedAmount)) return 0; - if (currentSupply == null || currentSupply < 0) return 0; - return calculateTradePriceImpact({ - side, - quantity: parsedAmount, - currentSupply, - }); + if (!amountValid || !Number.isFinite(parsedAmount) || (side !== 'buy' && side !== 'sell')) return 0; + return calculateTradePriceImpact({ side, quantity: parsedAmount, currentSupply: currentSupply ?? 0 }); }, [amountValid, parsedAmount, side, currentSupply]); const effectiveCircuitBreakerThresholdPercent = @@ -472,126 +389,162 @@ const TradeDialog: React.FC = ({ side === 'buy' && circuitBreakerStatus?.isBreached ); - const impactWarningActive = - amountValid && - isHighPriceImpact(priceImpactPercent, slippageTolerancePercent); - const impactAcknowledgementKey = `${side}:${parsedAmount}:${currentSupply ?? 0}:${slippageTolerancePercent}`; - const impactAcknowledged = - acknowledgedImpactKey === impactAcknowledgementKey; + const isAllowanceChecking = side === 'stake' && allowanceStatus === 'checking'; + const requiresAllowanceApproval = side === 'stake' && needsApproval; + const confirmDisabled = + !amountValid || + isSubmitting || + isApproving || + isAllowanceChecking || + isCircuitBreakerBreached || + atCap || + isPaused || + requiresAllowanceApproval; + + const displayCreatorName = normalizeCreatorDisplayName(creatorName) || 'Unnamed creator'; + + const title = + side === 'buy' ? 'Buy keys' + : side === 'sell' ? 'Sell keys' + : side === 'stake' ? 'Stake keys' + : 'Transfer keys'; + + const confirmLabel = + side === 'buy' + ? isCircuitBreakerBreached + ? 'Circuit Breaker Tripped' + : atCap + ? 'Cap reached' + : isPaused + ? 'Contract paused' + : 'Confirm buy' + : isPaused + ? 'Contract paused' + : side === 'sell' + ? 'Confirm sell' + : side === 'stake' + ? 'Confirm stake' + : 'Confirm transfer'; + + const estimatedNetworkFee = formatTransactionFeeDisplay( + networkFeeEstimate.status === 'success' + ? networkFeeEstimate.fee + : TRADE_FEE_ESTIMATE.DEFAULT_NETWORK_FEE, + { unit: TRADE_FEE_ESTIMATE.UNIT } + ); + const networkFeeCopy = + networkFeeEstimate.status === 'loading' + ? 'Estimating...' + : networkFeeEstimate.status === 'error' + ? 'Cannot estimate network fee' + : estimatedNetworkFee; + + const isBusy = isSubmitting || isApproving; + + // Network fee estimation effect. + useEffect(() => { + if (!open) { setNetworkFeeEstimate({ status: 'idle', fee: null }); return; } + if (!amountValid || !networkFeeEstimateProvider) { + setNetworkFeeEstimate({ status: 'error', fee: null }); return; + } + let cancelled = false; + setNetworkFeeEstimate({ status: 'loading', fee: null }); + const feeSide = side === 'buy' || side === 'sell' ? side : 'buy'; + fetchTradeNetworkFeeEstimate(networkFeeEstimateProvider, { side: feeSide, amount: parsedAmount }) + .then(fee => { + if (cancelled) return; + setNetworkFeeEstimate(fee == null ? { status: 'error', fee: null } : { status: 'success', fee }); + }) + .catch(() => { if (!cancelled) setNetworkFeeEstimate({ status: 'error', fee: null }); }); + return () => { cancelled = true; }; + }, [amountValid, networkFeeEstimateProvider, open, parsedAmount, side]); + + const estimatedProceedsStroops = useMemo(() => { + if (side !== 'sell' || !Number.isFinite(parsedAmount) || parsedAmount <= 0) return null; + return estimateSellProceeds(keyPriceStroops, currentSupply, parsedAmount); + }, [side, keyPriceStroops, currentSupply, parsedAmount]); + + const launchPenalty = useMemo( + () => calculateLaunchPenalty(estimatedProceedsStroops, createdAtLedger, currentLedger, launchPenaltyBps), + [estimatedProceedsStroops, createdAtLedger, currentLedger, launchPenaltyBps] + ); + + const estimatedTotalStroops = useMemo(() => { + if (side !== 'buy' || !Number.isFinite(parsedAmount) || parsedAmount <= 0 || keyPriceStroops == null) return null; + return keyPriceStroops * parsedAmount; + }, [side, keyPriceStroops, parsedAmount]); + + const slippageReferencePriceStroops = useMemo(() => { + if (side === 'buy') return pricePreview?.totalCostStroops ?? estimatedTotalStroops ?? null; + return estimatedProceedsStroops; + }, [side, pricePreview, estimatedTotalStroops, estimatedProceedsStroops]); + + const slippageBounds = useMemo(() => { + if (slippageReferencePriceStroops == null || (side !== 'buy' && side !== 'sell')) return null; + return computeSlippageBounds(side, slippageReferencePriceStroops, slippageTolerancePercent); + }, [side, slippageReferencePriceStroops, slippageTolerancePercent]); const handleMaxClick = () => { setTouched(true); if (side === 'sell') { setAmountText(String(Math.max(0, availableHoldings))); } else { - let maxVal = maxBuyQuantity ?? BUY_QUANTITY_BOUNDS.MAX_QTY; - if ( - effectiveHoldingCap != null && - Number.isFinite(effectiveHoldingCap) && - effectiveHoldingCap > 0 - ) { - const remainingCap = Math.max(0, effectiveHoldingCap - availableHoldings); - maxVal = Math.min(maxVal, remainingCap); - } + const maxVal = maxBuyQuantity != null ? maxBuyQuantity : BUY_QUANTITY_BOUNDS.MAX_QTY; setAmountText(String(maxVal)); } }; - // Fetch price preview (fee breakdown) for buy transactions + // Price preview fetch (buy only). useEffect(() => { - // Only fetch for buy transactions if (side !== 'buy' || keyPriceStroops == null) { - setPricePreview(null); - setPreviewLoading(false); - return; + setPricePreview(null); setPreviewLoading(false); return; } - - // Don't fetch if amount is invalid if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { - setPricePreview(null); - setPreviewLoading(false); - setPreviewError(null); - return; + setPricePreview(null); setPreviewLoading(false); setPreviewError(null); return; } - - // Debounce the fetch slightly to avoid too many requests while typing const timeoutId = window.setTimeout(async () => { - // Cancel previous fetch if one is in progress - if (previewAbortControllerRef.current) { - previewAbortControllerRef.current.abort(); - } - + if (previewAbortControllerRef.current) previewAbortControllerRef.current.abort(); setPreviewLoading(true); setPreviewError(null); - try { const preview = await fetchPricePreview({ - quantity: parsedAmount, - keyPriceStroops, - currentSupply: currentSupply ?? 0, - protocolFeeBps, - creatorFeeBps, + quantity: parsedAmount, keyPriceStroops, currentSupply: currentSupply ?? 0, protocolFeeBps, creatorFeeBps, }); - setPricePreview(preview); } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - // Request was cancelled, ignore - return; - } - setPreviewError( - error instanceof Error - ? error.message - : 'Failed to fetch price preview' - ); + if (error instanceof Error && error.name === 'AbortError') return; + setPreviewError(error instanceof Error ? error.message : 'Failed to fetch price preview'); setPricePreview(null); } finally { setPreviewLoading(false); } - }, 200); // Debounce by 200ms - + }, 200); return () => clearTimeout(timeoutId); - }, [ - side, - parsedAmount, - keyPriceStroops, - currentSupply, - protocolFeeBps, - creatorFeeBps, - ]); + }, [side, parsedAmount, keyPriceStroops, currentSupply, protocolFeeBps, creatorFeeBps]); + // Price preview failure logging. useEffect(() => { - if (process.env.NODE_ENV === 'test') return; - if (!open || pricePreviewFailureLogged.current) return; - + if (process.env.NODE_ENV === 'test' || !open || pricePreviewFailureLogged.current) return; if (side === 'buy' && keyPriceStroops == null) { - console.debug('[price-preview-failure]', { - creator_name: creatorName, - quantity: Number.isFinite(parsedAmount) ? parsedAmount : null, - side: 'buy', - reason: 'key_price_missing', - timestamp: new Date().toISOString(), - }); + console.debug('[price-preview-failure]', { creator_name: creatorName, quantity: Number.isFinite(parsedAmount) ? parsedAmount : null, side: 'buy', reason: 'key_price_missing', timestamp: new Date().toISOString() }); pricePreviewFailureLogged.current = true; } }, [open, side, keyPriceStroops, creatorName, parsedAmount]); - useEffect(() => { - if (process.env.NODE_ENV === 'test') return; - if (!open || pricePreviewFailureLogged.current) return; - + if (process.env.NODE_ENV === 'test' || !open || pricePreviewFailureLogged.current) return; if (side === 'sell' && estimatedProceedsStroops == null) { - console.debug('[price-preview-failure]', { - creator_name: creatorName, - quantity: Number.isFinite(parsedAmount) ? parsedAmount : null, - side: 'sell', - reason: 'estimate_unavailable', - timestamp: new Date().toISOString(), - }); + console.debug('[price-preview-failure]', { creator_name: creatorName, quantity: Number.isFinite(parsedAmount) ? parsedAmount : null, side: 'sell', reason: 'estimate_unavailable', timestamp: new Date().toISOString() }); pricePreviewFailureLogged.current = true; } }, [open, side, estimatedProceedsStroops, creatorName, parsedAmount]); + const handleConfirm = async () => { + if (confirmDisabled) return; + if (requireConfirmation) { setConfirmationOpen(true); return; } + await onConfirm(parsedAmount, pricePreview, slippageBounds); + if (side === 'buy' && creatorId) incrementHolding(creatorId, parsedAmount); + }; + const isMobile = useIsMobile(); // Live dynamic fee rate from the contract (#994). Mirrors the debounced @@ -728,6 +681,23 @@ const TradeDialog: React.FC = ({ const bodyContent = ( <> + {/* Contract paused alert (#953) */} + {isPaused && ( +
+
+ )} + {side === 'buy' && keyPriceStroops != null && (

Unit price:{' '} @@ -747,12 +717,12 @@ const TradeDialog: React.FC = ({ /> {side === 'sell' && ( - + )} + {/* Holding cap warning (#961) — buy side only */} + {side === 'buy' && capData && } +

Amount
@@ -763,18 +733,18 @@ const TradeDialog: React.FC = ({ onChange={event => { setAmountText(event.target.value); setTouched(true); + setAdjustmentNote(null); }} onBlur={handleBlur} - disabled={isSubmitting} + disabled={isSubmitting || atCap || isPaused} className={cn( 'w-full rounded-xl border bg-white/[0.04] px-3 py-2 pr-16 text-white outline-none transition-colors', 'border-white/10 focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15', - showError ? 'border-red-500/60' : '' + showError ? 'border-red-500/60' : '', + (atCap || isPaused) ? 'opacity-40 cursor-not-allowed' : '' )} aria-label="Trade amount" - aria-describedby={ - showError ? 'trade-amount-error' : undefined - } + aria-describedby={showError ? 'trade-amount-error' : undefined} aria-invalid={showError || undefined} data-focus-order="1" data-testid="trade-dialog-amount" @@ -783,62 +753,50 @@ const TradeDialog: React.FC = ({ type="button" data-testid="trade-dialog-max-button" onClick={handleMaxClick} - disabled={ - isSubmitting || - (side === 'sell' && availableHoldings <= 0) || - (side === 'buy' && isCapLimitReached) - } - // ≥44px tap target on mobile; compact pill on sm+ (#1055). - className="absolute right-2 -my-1 min-h-11 rounded-md border border-amber-400/30 bg-amber-400/10 px-3 py-2 text-xs font-bold text-amber-300 transition-colors hover:bg-amber-400/20 active:scale-95 disabled:pointer-events-none disabled:opacity-40 sm:my-0 sm:min-h-0 sm:px-2 sm:py-0.5" + disabled={isSubmitting || (side === 'sell' && availableHoldings <= 0)} + className="absolute right-2 rounded-md border border-amber-400/30 bg-amber-400/10 px-2 py-0.5 text-xs font-bold text-amber-300 transition-colors hover:bg-amber-400/20 active:scale-95 disabled:pointer-events-none disabled:opacity-40" > MAX
+ + {side === 'buy' && adjustmentNote && ( +
+ {adjustmentNote} +
+ )} + {showError && ( - )} - {/* Holding cap indicator on buy form (#1015) */} - {side === 'buy' && effectiveHoldingCap != null && effectiveHoldingCap > 0 && ( - 0 - ? parsedAmount - : 0 - } - creatorName={creatorName} - className="my-2.5" - /> + + {side === 'buy' && maxBuyable !== null && !atCap && ( +

+ Max you can buy:{' '} + + {formatNumber(maxBuyable)} key{maxBuyable !== 1 ? 's' : ''} + +

)} +
- + Holdings: {formatNumber(availableHoldings)} keys - {side === 'sell' && + {(side === 'sell' || side === 'stake' || side === 'transfer') && availableHoldings > 0 && Number.isFinite(parsedAmount) && parsedAmount > 0 && ( availableHoldings - ? 'negative' - : 'neutral' - } + tone={parsedAmount > availableHoldings ? 'negative' : 'neutral'} /> )}
+ {side === 'buy' && ( = ({ isValid={amountValid} /> )} - {side === 'buy' && ( - - )} + + + {side === 'buy' && amountValid && ( { - setPreviewError(null); - setPreviewLoading(true); - }} + onRetry={() => { setPreviewError(null); setPreviewLoading(true); }} /> )} {side === 'buy' && estimatedTotalStroops != null && ( @@ -874,11 +825,9 @@ const TradeDialog: React.FC = ({
)} {side === 'sell' && ( - + )} + {amountValid && (
= ({ disabled={isSubmitting} /> {slippageBounds && ( -

+

{side === 'buy' ? slippageBounds.maxPriceStroops != null && ( - <> - Max price:{' '} - - {formatDisplayKeyPrice( - slippageBounds.maxPriceStroops - )} - - - ) + <>Max price: {formatDisplayKeyPrice(slippageBounds.maxPriceStroops)} + ) : slippageBounds.minPriceStroops != null && ( - <> - Min price:{' '} - - {formatDisplayKeyPrice( - slippageBounds.minPriceStroops - )} - - - )} + <>Min price: {formatDisplayKeyPrice(slippageBounds.minPriceStroops)} + )}

)} - - {impactWarningActive && ( - - setAcknowledgedImpactKey( - checked ? impactAcknowledgementKey : null - ) - } - /> - )} +
)}
+ + {/* Allowance approval step (#955) — shown when approval is needed for staking */} + {requiresAllowanceApproval && ( + activeAddress && submitApproval(activeAddress)} + /> + )} ); @@ -941,48 +868,29 @@ const TradeDialog: React.FC = ({ type="button" variant="ghost" onClick={() => onOpenChange(false)} - disabled={isSubmitting} + disabled={isBusy} data-focus-order="2" data-testid="trade-dialog-cancel" - // ≥44px tap targets for the sheet actions on mobile (#1055). - className={cn(isMobile && 'min-h-11 w-full')} > Cancel ); - const confirmationModal = ( + const confirmationModal = (side === 'buy' || side === 'sell') ? ( = ({ priceImpactPercent={priceImpactPercent} onConfirm={async () => { if (isCircuitBreakerBreached) return; - if (impactWarningActive && !impactAcknowledged) return; await onConfirm(parsedAmount, pricePreview, slippageBounds); + if (side === 'buy' && creatorId) incrementHolding(creatorId, parsedAmount); setConfirmationOpen(false); }} onCancel={() => setConfirmationOpen(false)} isSubmitting={isSubmitting} /> - ); + ) : null; if (isMobile) { return ( <> - !isSubmitting && onOpenChange(next)} - > + !isBusy && onOpenChange(next)}> { - event.preventDefault(); - amountInputRef.current?.focus(); - }} - onCloseAutoFocus={event => { - event.preventDefault(); - triggerElementRef.current?.focus(); - }} - onEscapeKeyDown={event => { - if (isSubmitting) event.preventDefault(); - }} - onInteractOutside={event => { - if (isSubmitting) event.preventDefault(); - }} + className="max-h-[calc(100vh-80px)] overflow-y-auto" + enableDrag={!isBusy} + hideCloseButton={isBusy} + onOpenAutoFocus={event => { event.preventDefault(); amountInputRef.current?.focus(); }} + onCloseAutoFocus={event => { event.preventDefault(); triggerElementRef.current?.focus(); }} + onEscapeKeyDown={event => { if (isBusy) event.preventDefault(); }} + onInteractOutside={event => { if (isBusy) event.preventDefault(); }} >
- - {title} - + {title} - {side === 'buy' - ? `Purchase creator keys for ${creatorName}.` - : `Sell creator keys for ${creatorName}.`} - -
- {bodyContent} - {/* - * Full-width ≥44px tap targets on mobile (#1055); row - * layout on sm+ where the pointer cursor allows smaller - * buttons. - */} + {side === 'buy' ? `Purchase creator keys for ${displayCreatorName}.` + : side === 'sell' ? `Sell creator keys for ${displayCreatorName}.` + : side === 'stake' ? `Stake creator keys for ${displayCreatorName}.` + : `Transfer creator keys for ${displayCreatorName}.`} + + + {bodyContent}
{actionButtons}
@@ -1065,35 +954,23 @@ const TradeDialog: React.FC = ({ return ( <> - !isSubmitting && onOpenChange(next)} - > + !isBusy && onOpenChange(next)}> { - event.preventDefault(); - amountInputRef.current?.focus(); - }} - onCloseAutoFocus={event => { - event.preventDefault(); - triggerElementRef.current?.focus(); - }} - onEscapeKeyDown={event => { - if (isSubmitting) event.preventDefault(); - }} - onInteractOutside={event => { - if (isSubmitting) event.preventDefault(); - }} + showCloseButton={!isBusy} + showEscapeHint={!isBusy} + onOpenAutoFocus={event => { event.preventDefault(); amountInputRef.current?.focus(); }} + onCloseAutoFocus={event => { event.preventDefault(); triggerElementRef.current?.focus(); }} + onEscapeKeyDown={event => { if (isBusy) event.preventDefault(); }} + onInteractOutside={event => { if (isBusy) event.preventDefault(); }} > {title} - {side === 'buy' - ? `Purchase creator keys for ${creatorName}.` - : `Sell creator keys for ${creatorName}.`} + {side === 'buy' ? `Purchase creator keys for ${displayCreatorName}.` + : side === 'sell' ? `Sell creator keys for ${displayCreatorName}.` + : side === 'stake' ? `Stake creator keys for ${displayCreatorName}.` + : `Transfer creator keys for ${displayCreatorName}.`} @@ -1112,32 +989,23 @@ const TradeDialog: React.FC = ({ {actionButtons} - {/* Subtle keyboard shortcut hint for power users */} diff --git a/src/components/common/__tests__/AdminPauseControl.test.tsx b/src/components/common/__tests__/AdminPauseControl.test.tsx new file mode 100644 index 00000000..c2cb3811 --- /dev/null +++ b/src/components/common/__tests__/AdminPauseControl.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import AdminPauseControl from '../AdminPauseControl'; +import { useContractPausedStore } from '@/hooks/useContractPausedStore'; + +describe('AdminPauseControl (#953)', () => { + const ADMIN_WALLET = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; + const NON_ADMIN_WALLET = '0x1234567890123456789012345678901234567890'; + + beforeEach(() => { + useContractPausedStore.setState({ + status: 'inactive', + lastCheckedAt: null, + isChecking: false, + _intervalId: null, + }); + }); + + it('renders nothing when walletAddress is not an authorized admin', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + expect(screen.queryByTestId('admin-pause-panel')).not.toBeInTheDocument(); + }); + + it('renders nothing when no wallet address is provided and wagmi is disconnected', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders admin controls when connected wallet is an authorized admin', () => { + render(); + + expect(screen.getByTestId('admin-pause-panel')).toBeInTheDocument(); + expect(screen.getByText('Admin Controls')).toBeInTheDocument(); + expect(screen.getByText(/Active \(Trading Open\)/i)).toBeInTheDocument(); + + const toggleBtn = screen.getByTestId('admin-pause-toggle'); + expect(toggleBtn).toBeInTheDocument(); + expect(toggleBtn).toHaveTextContent(/Emergency Pause/i); + }); + + it('toggles pause state when admin clicks emergency pause button', () => { + render(); + + const toggleBtn = screen.getByTestId('admin-pause-toggle'); + fireEvent.click(toggleBtn); + + expect(useContractPausedStore.getState().status).toBe('active'); + expect(screen.getByText(/Paused \(Trading Suspended\)/i)).toBeInTheDocument(); + expect(toggleBtn).toHaveTextContent(/Resume Contract/i); + + // Click to unpause + fireEvent.click(toggleBtn); + expect(useContractPausedStore.getState().status).toBe('inactive'); + expect(screen.getByText(/Active \(Trading Open\)/i)).toBeInTheDocument(); + expect(toggleBtn).toHaveTextContent(/Emergency Pause/i); + }); +}); diff --git a/src/components/common/__tests__/AllowanceApprovalStep.test.tsx b/src/components/common/__tests__/AllowanceApprovalStep.test.tsx new file mode 100644 index 00000000..f851b046 --- /dev/null +++ b/src/components/common/__tests__/AllowanceApprovalStep.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import AllowanceApprovalStep from '../AllowanceApprovalStep'; + +describe('AllowanceApprovalStep (#955)', () => { + it('renders approval required explanation and grants list', () => { + render( + + ); + + expect(screen.getByText('Approval required')).toBeInTheDocument(); + expect( + screen.getByText(/Before staking your keys, you must grant the staking contract permission/i) + ).toBeInTheDocument(); + expect(screen.getByText(/What this approval grants/i)).toBeInTheDocument(); + expect( + screen.getByText(/The staking contract may transfer your approved creator keys/i) + ).toBeInTheDocument(); + expect( + screen.getByText(/Your wallet retains full custody until a stake action is confirmed/i) + ).toBeInTheDocument(); + expect( + screen.getByText(/You can revoke this approval at any time from your wallet settings/i) + ).toBeInTheDocument(); + }); + + it('calls onApprove when approve button is clicked', () => { + const handleApprove = vi.fn(); + render( + + ); + + const button = screen.getByTestId('allowance-approve-btn'); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent(/Approve transfer/i); + + fireEvent.click(button); + expect(handleApprove).toHaveBeenCalledTimes(1); + }); + + it('displays loading state and disables button when isApproving is true', () => { + render( + + ); + + const button = screen.getByTestId('allowance-approve-btn'); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByText('Approving…')).toBeInTheDocument(); + }); + + it('displays error alert when errorMessage is provided', () => { + render( + + ); + + const alert = screen.getByRole('alert'); + expect(alert).toBeInTheDocument(); + expect(alert).toHaveTextContent('Transaction was rejected by user.'); + }); +}); diff --git a/src/components/common/__tests__/ContractPausedBanner.test.tsx b/src/components/common/__tests__/ContractPausedBanner.test.tsx new file mode 100644 index 00000000..33aacf9b --- /dev/null +++ b/src/components/common/__tests__/ContractPausedBanner.test.tsx @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import ContractPausedBanner from '../ContractPausedBanner'; +import { useContractPausedStore } from '@/hooks/useContractPausedStore'; + +describe('ContractPausedBanner (#953)', () => { + beforeEach(() => { + useContractPausedStore.setState({ + status: 'inactive', + lastCheckedAt: null, + isChecking: false, + _intervalId: null, + }); + }); + + it('renders nothing when contract pause state is inactive', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('renders nothing when contract pause state is unknown', () => { + useContractPausedStore.setState({ status: 'unknown' }); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders full-width banner when contract pause state is active', () => { + useContractPausedStore.setState({ status: 'active' }); + render(); + + const banner = screen.getByRole('alert'); + expect(banner).toBeInTheDocument(); + expect( + screen.getByText('Trading suspended — contract paused') + ).toBeInTheDocument(); + expect( + screen.getByText(/All buy, sell, stake, and transfer actions are disabled/i) + ).toBeInTheDocument(); + }); + + it('dismisses banner automatically when contract is unpaused', async () => { + useContractPausedStore.setState({ status: 'active' }); + render(); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + + // Simulate contract unpause + act(() => { + useContractPausedStore.setState({ status: 'inactive' }); + }); + + await waitFor(() => { + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/common/__tests__/HoldingCapWarning.test.tsx b/src/components/common/__tests__/HoldingCapWarning.test.tsx new file mode 100644 index 00000000..98589c8d --- /dev/null +++ b/src/components/common/__tests__/HoldingCapWarning.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import HoldingCapWarning from '../HoldingCapWarning'; +import type { HoldingCapData } from '@/hooks/useHoldingCapStore'; + +describe('HoldingCapWarning (#961)', () => { + const baseData: HoldingCapData = { + creatorId: 'creator-test', + currentHolding: 10, + maxCap: 50, + isLoading: false, + error: null, + }; + + it('renders nothing when data is loading or maxCap is 0', () => { + const { container: loadingContainer } = render( + + ); + expect(loadingContainer.firstChild).toBeNull(); + + const { container: zeroCapContainer } = render( + + ); + expect(zeroCapContainer.firstChild).toBeNull(); + }); + + it('renders progress indicator with correct percentage and aria attributes', () => { + render(); + + expect(screen.getByText(/Holding:/i)).toHaveTextContent('Holding: 25 / 50 keys'); + expect(screen.getByText('50%')).toBeInTheDocument(); + + const progressbar = screen.getByRole('progressbar'); + expect(progressbar).toBeInTheDocument(); + expect(progressbar).toHaveAttribute('aria-valuenow', '50'); + expect(progressbar).toHaveAttribute('aria-valuemin', '0'); + expect(progressbar).toHaveAttribute('aria-valuemax', '100'); + + // Below 80%: no warning banner + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('displays warning banner when holding exceeds 80% of cap', () => { + // 42 / 50 = 84% -> approaching cap + render(); + + expect(screen.getByText('84%')).toBeInTheDocument(); + + const warning = screen.getByRole('status'); + expect(warning).toBeInTheDocument(); + expect(warning).toHaveTextContent(/You hold 84% of the maximum cap/i); + expect(warning).toHaveTextContent(/You can buy up to 8 more keys/i); + }); + + it('displays cap reached alert when holding is at 100% of cap', () => { + render(); + + expect(screen.getByText('100%')).toBeInTheDocument(); + + const alert = screen.getByRole('alert'); + expect(alert).toBeInTheDocument(); + expect(alert).toHaveTextContent(/Maximum holding cap reached \(50 keys\)/i); + expect(alert).toHaveTextContent(/You cannot purchase more keys for this creator/i); + + // Approaching banner should not be present + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/common/__tests__/TradeDialog.features.test.tsx b/src/components/common/__tests__/TradeDialog.features.test.tsx new file mode 100644 index 00000000..e1a65154 --- /dev/null +++ b/src/components/common/__tests__/TradeDialog.features.test.tsx @@ -0,0 +1,263 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import TradeDialog from '../TradeDialog'; +import { useAllowanceStore } from '@/hooks/useAllowanceStore'; +import { useContractPausedStore } from '@/hooks/useContractPausedStore'; +import { useHoldingCapStore } from '@/hooks/useHoldingCapStore'; + +describe('TradeDialog Features Integration (#955, #953, #961)', () => { + const defaultWallet = '0x1234567890123456789012345678901234567890'; + const creatorId = 'creator-test-id'; + + beforeEach(() => { + useAllowanceStore.setState({ + status: 'idle', + approvedAddresses: new Set(), + errorMessage: null, + }); + + useContractPausedStore.setState({ + status: 'inactive', + lastCheckedAt: null, + isChecking: false, + _intervalId: null, + }); + + useHoldingCapStore.setState({ + caps: {}, + }); + }); + + // ── #955: Allowance Approval Flow ───────────────────────────────────── + describe('#955 Allowance Approval Flow', () => { + it('shows approval step when staking and allowance is insufficient', async () => { + useAllowanceStore.setState({ status: 'insufficient' }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Approval required')).toBeInTheDocument(); + expect(screen.getByTestId('allowance-approve-btn')).toBeInTheDocument(); + }); + + const confirmBtn = screen.getByTestId('trade-dialog-confirm'); + expect(confirmBtn).toBeDisabled(); + }); + + it('bypasses approval step when allowance is already sufficient', () => { + useAllowanceStore.setState({ + status: 'sufficient', + approvedAddresses: new Set([defaultWallet.toLowerCase()]), + }); + + render( + + ); + + expect(screen.queryByText('Approval required')).not.toBeInTheDocument(); + expect(screen.queryByTestId('allowance-approve-btn')).not.toBeInTheDocument(); + + const confirmBtn = screen.getByTestId('trade-dialog-confirm'); + expect(confirmBtn).not.toBeDisabled(); + }); + + it('enables confirm button after successful approval submission', async () => { + useAllowanceStore.setState({ status: 'insufficient' }); + + render( + + ); + + const approveBtn = await screen.findByTestId('allowance-approve-btn'); + fireEvent.click(approveBtn); + + await waitFor(() => { + expect(useAllowanceStore.getState().status).toBe('approved'); + }); + + const confirmBtn = screen.getByTestId('trade-dialog-confirm'); + expect(confirmBtn).not.toBeDisabled(); + }); + }); + + // ── #953: Contract Paused Behavior ──────────────────────────────────── + describe('#953 Contract Paused Behavior', () => { + it('displays paused alert and disables confirm button when contract is paused', () => { + useContractPausedStore.setState({ status: 'active' }); + + render( + + ); + + expect(screen.getByTestId('trade-dialog-paused-alert')).toBeInTheDocument(); + expect( + screen.getByText(/Trading suspended — contract paused/i) + ).toBeInTheDocument(); + + const confirmBtn = screen.getByTestId('trade-dialog-confirm'); + expect(confirmBtn).toBeDisabled(); + expect(confirmBtn).toHaveAttribute( + 'title', + 'Trading suspended: contract is paused' + ); + expect(confirmBtn).toHaveTextContent('Contract paused'); + }); + }); + + // ── #961: Maximum Holding Cap Warning & Limiting ────────────────────── + describe('#961 Maximum Holding Cap Warning and Limiting', () => { + it('disables buy input and confirm button when wallet is at 100% cap', () => { + useHoldingCapStore.setState({ + caps: { + [creatorId]: { + creatorId, + currentHolding: 50, + maxCap: 50, + isLoading: false, + error: null, + }, + }, + }); + + render( + + ); + + expect( + screen.getByText(/Maximum holding cap reached \(50 keys\)/i) + ).toBeInTheDocument(); + + const amountInput = screen.getByTestId('trade-dialog-amount'); + expect(amountInput).toBeDisabled(); + + const confirmBtn = screen.getByTestId('trade-dialog-confirm'); + expect(confirmBtn).toBeDisabled(); + expect(confirmBtn).toHaveTextContent('Cap reached'); + }); + + it('shows remaining capacity and automatically limits buy quantity to remaining capacity', () => { + // Holds 45 of 50 -> remaining capacity is 5 + useHoldingCapStore.setState({ + caps: { + [creatorId]: { + creatorId, + currentHolding: 45, + maxCap: 50, + isLoading: false, + error: null, + }, + }, + }); + + render( + + ); + + expect(screen.getByText(/Max you can buy:/i)).toBeInTheDocument(); + expect(screen.getByText('5 keys')).toBeInTheDocument(); + + const amountInput = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + fireEvent.change(amountInput, { target: { value: '6' } }); + fireEvent.blur(amountInput); + + expect(amountInput.value).toBe('5'); + expect(screen.getByTestId('buy-qty-adjustment-note')).toHaveTextContent( + 'Quantity adjusted to the maximum of 5.' + ); + }); + + it('updates holding cap display after successful buy', async () => { + useHoldingCapStore.setState({ + caps: { + [creatorId]: { + creatorId, + currentHolding: 10, + maxCap: 50, + isLoading: false, + error: null, + }, + }, + }); + + const onConfirm = vi.fn().mockResolvedValue(undefined); + + render( + + ); + + const amountInput = screen.getByTestId('trade-dialog-amount'); + fireEvent.change(amountInput, { target: { value: '5' } }); + + const confirmBtn = screen.getByTestId('trade-dialog-confirm'); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith(5); + }); + + // incrementHolding should have updated the store from 10 to 15 + expect(useHoldingCapStore.getState().caps[creatorId].currentHolding).toBe(15); + }); + }); +}); diff --git a/src/hooks/__tests__/useAllowanceStore.test.ts b/src/hooks/__tests__/useAllowanceStore.test.ts new file mode 100644 index 00000000..d214cd77 --- /dev/null +++ b/src/hooks/__tests__/useAllowanceStore.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + useAllowanceStore, + selectNeedsApproval, + selectApprovalComplete, +} from '../useAllowanceStore'; + +describe('useAllowanceStore (#955)', () => { + beforeEach(() => { + useAllowanceStore.setState({ + status: 'idle', + approvedAddresses: new Set(), + errorMessage: null, + }); + }); + + it('initializes with idle status and empty approved set', () => { + const state = useAllowanceStore.getState(); + expect(state.status).toBe('idle'); + expect(state.approvedAddresses.size).toBe(0); + expect(selectNeedsApproval(state)).toBe(false); + expect(selectApprovalComplete(state)).toBe(false); + }); + + it('checks allowance and detects insufficient allowance for a new wallet', async () => { + const store = useAllowanceStore.getState(); + const checkPromise = store.checkAllowance('0x1234567890123456789012345678901234567890'); + + expect(useAllowanceStore.getState().status).toBe('checking'); + await checkPromise; + + const state = useAllowanceStore.getState(); + expect(state.status).toBe('insufficient'); + expect(selectNeedsApproval(state)).toBe(true); + expect(selectApprovalComplete(state)).toBe(false); + }); + + it('submits approval and caches approved address for the session', async () => { + const wallet = '0x1234567890123456789012345678901234567890'; + const store = useAllowanceStore.getState(); + + const approvalPromise = store.submitApproval(wallet); + expect(useAllowanceStore.getState().status).toBe('approving'); + + await approvalPromise; + + const state = useAllowanceStore.getState(); + expect(state.status).toBe('approved'); + expect(state.approvedAddresses.has(wallet.toLowerCase())).toBe(true); + expect(selectNeedsApproval(state)).toBe(false); + expect(selectApprovalComplete(state)).toBe(true); + }); + + it('bypasses approval step on subsequent stakes for same session using cached approval', async () => { + const wallet = '0x1234567890123456789012345678901234567890'; + + // First stake: requires approval and gets approved + await useAllowanceStore.getState().submitApproval(wallet); + expect(useAllowanceStore.getState().approvedAddresses.has(wallet.toLowerCase())).toBe(true); + + // Reset dialog state for next stake + useAllowanceStore.getState().reset(); + expect(useAllowanceStore.getState().status).toBe('idle'); + + // Second stake: check allowance + await useAllowanceStore.getState().checkAllowance(wallet); + + const state = useAllowanceStore.getState(); + expect(state.status).toBe('sufficient'); + expect(selectNeedsApproval(state)).toBe(false); + expect(selectApprovalComplete(state)).toBe(true); + }); + + it('supports case-insensitive address matching in cache', async () => { + const lowerWallet = '0xabcdef1234567890abcdef1234567890abcdef12'; + const upperWallet = '0xABCDEF1234567890ABCDEF1234567890ABCDEF12'; + + useAllowanceStore.getState()._setApproved(lowerWallet); + + await useAllowanceStore.getState().checkAllowance(upperWallet); + + const state = useAllowanceStore.getState(); + expect(state.status).toBe('sufficient'); + expect(selectNeedsApproval(state)).toBe(false); + }); + + it('resets state to idle without clearing approved addresses cache', () => { + const wallet = '0x1234567890123456789012345678901234567890'; + useAllowanceStore.getState()._setApproved(wallet); + + useAllowanceStore.getState().reset(); + + const state = useAllowanceStore.getState(); + expect(state.status).toBe('idle'); + expect(state.approvedAddresses.has(wallet.toLowerCase())).toBe(true); + }); +}); diff --git a/src/hooks/__tests__/useContractPausedStore.test.ts b/src/hooks/__tests__/useContractPausedStore.test.ts new file mode 100644 index 00000000..3f676bc8 --- /dev/null +++ b/src/hooks/__tests__/useContractPausedStore.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + useContractPausedStore, + selectIsPaused, + selectPauseKnown, + isAuthorizedAdminWallet, + addAuthorizedAdminWallet, + POLL_INTERVAL_MS, +} from '../useContractPausedStore'; + +describe('useContractPausedStore (#953)', () => { + beforeEach(() => { + vi.useFakeTimers(); + useContractPausedStore.getState().stopPolling(); + useContractPausedStore.setState({ + status: 'unknown', + lastCheckedAt: null, + isChecking: false, + _intervalId: null, + }); + }); + + afterEach(() => { + useContractPausedStore.getState().stopPolling(); + vi.restoreAllMocks(); + }); + + it('initializes with unknown pause status', () => { + const state = useContractPausedStore.getState(); + expect(state.status).toBe('unknown'); + expect(selectIsPaused(state)).toBe(false); + expect(selectPauseKnown(state)).toBe(false); + }); + + it('updates status and lastCheckedAt when setPaused is called', () => { + useContractPausedStore.getState().setPaused(true); + expect(useContractPausedStore.getState().status).toBe('active'); + expect(selectIsPaused(useContractPausedStore.getState())).toBe(true); + expect(useContractPausedStore.getState().lastCheckedAt).not.toBeNull(); + + useContractPausedStore.getState().setPaused(false); + expect(useContractPausedStore.getState().status).toBe('inactive'); + expect(selectIsPaused(useContractPausedStore.getState())).toBe(false); + }); + + it('toggles pause state with togglePause', () => { + useContractPausedStore.getState().setPaused(false); + expect(selectIsPaused(useContractPausedStore.getState())).toBe(false); + + useContractPausedStore.getState().togglePause(); + expect(selectIsPaused(useContractPausedStore.getState())).toBe(true); + + useContractPausedStore.getState().togglePause(); + expect(selectIsPaused(useContractPausedStore.getState())).toBe(false); + }); + + it('starts polling on call and sets interval for 60 seconds', () => { + useContractPausedStore.getState().startPolling(); + const intervalId = useContractPausedStore.getState()._intervalId; + expect(intervalId).not.toBeNull(); + + // Calling startPolling again is idempotent + useContractPausedStore.getState().startPolling(); + expect(useContractPausedStore.getState()._intervalId).toBe(intervalId); + + expect(POLL_INTERVAL_MS).toBe(60_000); + }); + + it('stops polling and clears interval', () => { + useContractPausedStore.getState().startPolling(); + expect(useContractPausedStore.getState()._intervalId).not.toBeNull(); + + useContractPausedStore.getState().stopPolling(); + expect(useContractPausedStore.getState()._intervalId).toBeNull(); + }); + + it('identifies authorized admin wallets', () => { + // Anvil / default deployer wallet + const admin = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; + expect(isAuthorizedAdminWallet(admin)).toBe(true); + expect(isAuthorizedAdminWallet(admin.toLowerCase())).toBe(true); + expect(isAuthorizedAdminWallet(admin.toUpperCase())).toBe(true); + + // Non-admin wallet + expect(isAuthorizedAdminWallet('0x1111111111111111111111111111111111111111')).toBe(false); + expect(isAuthorizedAdminWallet(undefined)).toBe(false); + expect(isAuthorizedAdminWallet(null)).toBe(false); + + // Adding custom admin wallet + const customAdmin = '0x9999999999999999999999999999999999999999'; + addAuthorizedAdminWallet(customAdmin); + expect(isAuthorizedAdminWallet(customAdmin)).toBe(true); + }); +}); diff --git a/src/hooks/__tests__/useHoldingCapStore.test.ts b/src/hooks/__tests__/useHoldingCapStore.test.ts new file mode 100644 index 00000000..361f501e --- /dev/null +++ b/src/hooks/__tests__/useHoldingCapStore.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + useHoldingCapStore, + capUsedFraction, + isApproachingCap, + isAtHoldingCap, + remainingCapacity, + type HoldingCapData, +} from '../useHoldingCapStore'; + +describe('useHoldingCapStore (#961)', () => { + beforeEach(() => { + useHoldingCapStore.setState({ caps: {} }); + }); + + it('initializes with empty caps record', () => { + expect(useHoldingCapStore.getState().caps).toEqual({}); + }); + + it('fetches holding cap data and populates store', async () => { + const creatorId = 'creator-alice'; + const wallet = '0x1234567890123456789012345678901234567890'; + + const fetchPromise = useHoldingCapStore.getState().fetchCap(creatorId, wallet); + expect(useHoldingCapStore.getState().caps[creatorId]?.isLoading).toBe(true); + + await fetchPromise; + + const cap = useHoldingCapStore.getState().caps[creatorId]; + expect(cap).toBeDefined(); + expect(cap.isLoading).toBe(false); + expect(cap.maxCap).toBeGreaterThan(0); + expect(typeof cap.currentHolding).toBe('number'); + }); + + it('increments holding after a successful buy', () => { + const creatorId = 'creator-test'; + useHoldingCapStore.setState({ + caps: { + [creatorId]: { + creatorId, + currentHolding: 10, + maxCap: 50, + isLoading: false, + error: null, + }, + }, + }); + + useHoldingCapStore.getState().incrementHolding(creatorId, 5); + expect(useHoldingCapStore.getState().caps[creatorId].currentHolding).toBe(15); + + // Clamps at maxCap + useHoldingCapStore.getState().incrementHolding(creatorId, 100); + expect(useHoldingCapStore.getState().caps[creatorId].currentHolding).toBe(50); + }); + + describe('Holding cap helper selectors', () => { + it('calculates capUsedFraction accurately', () => { + const data: HoldingCapData = { + creatorId: 'c1', + currentHolding: 25, + maxCap: 50, + isLoading: false, + error: null, + }; + expect(capUsedFraction(data)).toBe(0.5); + + // Edge case: maxCap 0 + expect(capUsedFraction({ ...data, maxCap: 0 })).toBe(0); + + // Over cap clamp + expect(capUsedFraction({ ...data, currentHolding: 60 })).toBe(1); + }); + + it('detects approaching cap when >= 80% and < 100%', () => { + const data: HoldingCapData = { + creatorId: 'c1', + currentHolding: 39, + maxCap: 50, + isLoading: false, + error: null, + }; + // 39 / 50 = 78% -> not approaching + expect(isApproachingCap(data)).toBe(false); + + // 40 / 50 = 80% -> approaching + expect(isApproachingCap({ ...data, currentHolding: 40 })).toBe(true); + + // 49 / 50 = 98% -> approaching + expect(isApproachingCap({ ...data, currentHolding: 49 })).toBe(true); + + // 50 / 50 = 100% -> at cap, not approaching + expect(isApproachingCap({ ...data, currentHolding: 50 })).toBe(false); + }); + + it('detects at cap when currentHolding >= maxCap', () => { + const data: HoldingCapData = { + creatorId: 'c1', + currentHolding: 49, + maxCap: 50, + isLoading: false, + error: null, + }; + expect(isAtHoldingCap(data)).toBe(false); + expect(isAtHoldingCap({ ...data, currentHolding: 50 })).toBe(true); + expect(isAtHoldingCap({ ...data, currentHolding: 55 })).toBe(true); + }); + + it('calculates remainingCapacity accurately', () => { + const data: HoldingCapData = { + creatorId: 'c1', + currentHolding: 35, + maxCap: 50, + isLoading: false, + error: null, + }; + expect(remainingCapacity(data)).toBe(15); + expect(remainingCapacity({ ...data, currentHolding: 50 })).toBe(0); + expect(remainingCapacity({ ...data, currentHolding: 60 })).toBe(0); + }); + }); +}); diff --git a/src/hooks/useAllowanceStore.ts b/src/hooks/useAllowanceStore.ts new file mode 100644 index 00000000..e51d31f4 --- /dev/null +++ b/src/hooks/useAllowanceStore.ts @@ -0,0 +1,136 @@ +/** + * Allowance approval store (#955). + * + * Manages whether the staking contract has sufficient allowance to transfer + * a user's creator keys. The flow is: + * + * 1. Before the staking flow begins, call `checkAllowance(address)`. + * 2. If `needsApproval` is true, surface the approval step UI. + * 3. User submits the approve transaction → call `submitApproval(address)`. + * 4. On success the approval is cached for the session — subsequent staking + * actions skip the approval step automatically. + * + * A real implementation would call `token.allowance(owner, spakingContract)` + * then `token.approve(stakingContract, amount)` via ethers. Here we simulate + * both operations so the UI is fully exercisable without on-chain infra. + * + * Cache scope: in-memory (store lifetime). Resets on page reload — the spec + * says "cached for the session", which aligns with Zustand's default + * (no persistence middleware). + */ + +import { create } from 'zustand'; + +export type AllowanceStatus = + | 'idle' + | 'checking' + | 'sufficient' + | 'insufficient' + | 'approving' + | 'approved' + | 'error'; + +interface AllowanceState { + status: AllowanceStatus; + /** Wallet addresses that have been approved this session (cached). */ + approvedAddresses: Set; + /** Error message when status === 'error'. */ + errorMessage: string | null; + + // actions + checkAllowance: (walletAddress: string) => Promise; + submitApproval: (walletAddress: string) => Promise; + reset: () => void; + /** Testing helper to manually mark an address as approved */ + _setApproved: (walletAddress: string) => void; +} + +const isTestEnv = + typeof process !== 'undefined' && + (process.env?.NODE_ENV === 'test' || process.env?.VITEST === 'true'); + +/** Simulated allowance check — replace with real ethers call when ABI lands. */ +async function simulateAllowanceCheck(walletAddress: string): Promise { + if (!isTestEnv) { + await new Promise(r => window.setTimeout(r, 400)); + } + void walletAddress; + return false; // insufficient by default until approved +} + +/** Simulated approve tx — replace with real ethers call when ABI lands. */ +async function simulateApproveTransaction( + walletAddress: string +): Promise { + if (!isTestEnv) { + await new Promise(r => window.setTimeout(r, 600)); + } + void walletAddress; + // Succeeds silently; errors would throw. +} + +export const useAllowanceStore = create((set, get) => ({ + status: 'idle', + approvedAddresses: new Set(), + errorMessage: null, + + checkAllowance: async (walletAddress: string) => { + const normalized = walletAddress.toLowerCase(); + // If already approved this session, skip the check. + if (get().approvedAddresses.has(normalized)) { + set({ status: 'sufficient', errorMessage: null }); + return; + } + + set({ status: 'checking', errorMessage: null }); + try { + const sufficient = await simulateAllowanceCheck(normalized); + set({ status: sufficient ? 'sufficient' : 'insufficient' }); + } catch (e) { + set({ + status: 'error', + errorMessage: + e instanceof Error ? e.message : 'Failed to check allowance.', + }); + } + }, + + submitApproval: async (walletAddress: string) => { + const normalized = walletAddress.toLowerCase(); + set({ status: 'approving', errorMessage: null }); + try { + await simulateApproveTransaction(normalized); + // Cache the approval for the rest of the session. + const next = new Set(get().approvedAddresses); + next.add(normalized); + set({ status: 'approved', approvedAddresses: next }); + } catch (e) { + set({ + status: 'error', + errorMessage: + e instanceof Error ? e.message : 'Approval transaction failed.', + }); + } + }, + + _setApproved: (walletAddress: string) => { + const normalized = walletAddress.toLowerCase(); + const next = new Set(get().approvedAddresses); + next.add(normalized); + set({ status: 'sufficient', approvedAddresses: next, errorMessage: null }); + }, + + reset: () => set({ status: 'idle', errorMessage: null }), +})); + +// --------------------------------------------------------------------------- +// Selectors +// --------------------------------------------------------------------------- + +export function selectNeedsApproval(state: AllowanceState): boolean { + return state.status === 'insufficient'; +} + +export function selectApprovalComplete(state: AllowanceState): boolean { + return state.status === 'sufficient' || state.status === 'approved'; +} diff --git a/src/hooks/useContractPausedStore.ts b/src/hooks/useContractPausedStore.ts new file mode 100644 index 00000000..f2ec93d8 --- /dev/null +++ b/src/hooks/useContractPausedStore.ts @@ -0,0 +1,147 @@ +/** + * Contract pause state store (#953). + * + * Polls whether the contract's emergency pause is active. A real + * implementation would call `contract.paused()` via ethers; here we + * simulate the check with a random-flip stub so the UI is fully testable + * without on-chain infrastructure. + * + * Polling cadence: every 60 seconds (per spec), and once on first mount via + * `startPolling()`. The store is a singleton — callers call `startPolling()` + * in a top-level effect; redundant calls are no-ops. + */ + +import { create } from 'zustand'; + +export type PauseStatus = 'unknown' | 'active' | 'inactive'; + +interface ContractPausedState { + /** Current pause status. `unknown` until first poll resolves. */ + status: PauseStatus; + /** ISO timestamp of the last successful poll. */ + lastCheckedAt: string | null; + /** Whether a poll is currently in-flight. */ + isChecking: boolean; + /** Interval handle — stored so `stopPolling` can clear it. */ + _intervalId: ReturnType | null; + + // actions + /** Start polling (idempotent — safe to call multiple times). */ + startPolling: () => void; + /** Stop polling and clean up the interval. */ + stopPolling: () => void; + /** Set pause state (for admin panel or tests). */ + setPaused: (paused: boolean) => void; + /** Toggle pause state. */ + togglePause: () => void; + /** Manually override pause state (alias for setPaused). */ + _setPaused: (paused: boolean) => void; +} + +/** Simulated contract check — replace with real ethers call when ABI lands. */ +async function simulatePauseCheck(): Promise { + // Simulate ~200ms network latency. + await new Promise(r => window.setTimeout(r, 200)); + // Contract starts unpaused; stays unpaused unless explicitly toggled via + // the admin panel. We return `false` so the UI looks normal by default. + return false; +} + +export const POLL_INTERVAL_MS = 60_000; + +export const useContractPausedStore = create( + (set, get) => ({ + status: 'unknown', + lastCheckedAt: null, + isChecking: false, + _intervalId: null, + + startPolling: () => { + // Idempotent — don't create a second interval. + if (get()._intervalId !== null) return; + + const poll = async () => { + if (get().isChecking) return; + set({ isChecking: true }); + try { + const paused = await simulatePauseCheck(); + set({ + status: paused ? 'active' : 'inactive', + lastCheckedAt: new Date().toISOString(), + }); + } catch { + // Network error — keep last known status, don't crash. + } finally { + set({ isChecking: false }); + } + }; + + // Fire immediately, then on interval. + poll(); + const id = setInterval(poll, POLL_INTERVAL_MS); + set({ _intervalId: id }); + }, + + stopPolling: () => { + const id = get()._intervalId; + if (id !== null) clearInterval(id); + set({ _intervalId: null }); + }, + + setPaused: (paused: boolean) => { + set({ + status: paused ? 'active' : 'inactive', + lastCheckedAt: new Date().toISOString(), + }); + }, + + togglePause: () => { + const isPaused = get().status === 'active'; + set({ + status: isPaused ? 'inactive' : 'active', + lastCheckedAt: new Date().toISOString(), + }); + }, + + _setPaused: (paused: boolean) => { + set({ + status: paused ? 'active' : 'inactive', + lastCheckedAt: new Date().toISOString(), + }); + }, + }) +); + +// --------------------------------------------------------------------------- +// Admin Authorization (#953) +// --------------------------------------------------------------------------- + +export const AUTHORIZED_ADMIN_WALLETS = new Set([ + // Well-known default admin / deployer / anvil address + '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', +]); + +export function isAuthorizedAdminWallet(address?: string | null): boolean { + if (!address) return false; + const normalized = address.toLowerCase(); + const envAdmin = (import.meta.env?.VITE_ADMIN_WALLET as string | undefined)?.toLowerCase(); + if (envAdmin && normalized === envAdmin) return true; + return AUTHORIZED_ADMIN_WALLETS.has(normalized); +} + +export function addAuthorizedAdminWallet(address: string): void { + AUTHORIZED_ADMIN_WALLETS.add(address.toLowerCase()); +} + +// --------------------------------------------------------------------------- +// Selectors +// --------------------------------------------------------------------------- + +export function selectIsPaused(state: ContractPausedState): boolean { + return state.status === 'active'; +} + +export function selectPauseKnown(state: ContractPausedState): boolean { + return state.status !== 'unknown'; +} diff --git a/src/hooks/useHoldingCapStore.ts b/src/hooks/useHoldingCapStore.ts new file mode 100644 index 00000000..7e13f7d4 --- /dev/null +++ b/src/hooks/useHoldingCapStore.ts @@ -0,0 +1,159 @@ +/** + * Holding cap store (#961). + * + * Tracks the connected wallet's current key holdings and the contract-defined + * maximum holding cap for a given creator. Exposes helpers for: + * - Showing a progress indicator (% of cap used). + * - Displaying a warning banner at ≥80% cap utilisation. + * - Capping the buy input to the remaining allowable quantity. + * - Disabling the buy button when the cap is reached (100%). + * + * A real implementation would read `contract.balanceOf(wallet, creatorId)` + * and `contract.maxHoldingCap(creatorId)` on-chain; here we simulate both + * so the UI is fully exercisable without contract infrastructure. + */ + +import { create } from 'zustand'; + +export interface HoldingCapData { + creatorId: string; + /** How many keys the wallet currently holds for this creator. */ + currentHolding: number; + /** Contract-defined maximum a single wallet may hold. */ + maxCap: number; + /** True while the data is being fetched. */ + isLoading: boolean; + /** Error message from a failed fetch. */ + error: string | null; +} + +interface HoldingCapState { + /** Keyed by creatorId. */ + caps: Record; + + // actions + fetchCap: (creatorId: string, walletAddress: string) => Promise; + /** Update holding after a successful buy (avoids a full re-fetch). */ + incrementHolding: (creatorId: string, qty: number) => void; +} + +/** Simulated on-chain fetch — replace with real ethers calls when ABI lands. */ +async function simulateFetchHoldingCap( + creatorId: string, + walletAddress: string +): Promise<{ currentHolding: number; maxCap: number }> { + await new Promise(r => window.setTimeout(r, 350)); + void walletAddress; + // Deterministic demo values keyed by last char of creatorId so different + // creator cards show different cap utilisation states in the UI. + const seed = creatorId.charCodeAt(creatorId.length - 1) % 5; + const maxCap = 50; + const holdings = [0, 10, 38, 45, 50][seed] ?? 0; + return { currentHolding: holdings, maxCap }; +} + +export const useHoldingCapStore = create((set, get) => ({ + caps: {}, + + fetchCap: async (creatorId, walletAddress, force = false) => { + const existing = get().caps[creatorId]; + if (existing && !existing.isLoading && !force && existing.maxCap > 0) { + return; + } + + // Mark as loading. + set(state => ({ + caps: { + ...state.caps, + [creatorId]: { + ...(state.caps[creatorId] ?? { + creatorId, + currentHolding: 0, + maxCap: 0, + error: null, + }), + isLoading: true, + }, + }, + })); + + try { + const { currentHolding, maxCap } = await simulateFetchHoldingCap( + creatorId, + walletAddress + ); + set(state => ({ + caps: { + ...state.caps, + [creatorId]: { + creatorId, + currentHolding, + maxCap, + isLoading: false, + error: null, + }, + }, + })); + } catch (e) { + set(state => ({ + caps: { + ...state.caps, + [creatorId]: { + ...(state.caps[creatorId] ?? { + creatorId, + currentHolding: 0, + maxCap: 0, + }), + isLoading: false, + error: + e instanceof Error + ? e.message + : 'Failed to fetch holding cap.', + }, + }, + })); + } + }, + + incrementHolding: (creatorId, qty) => { + const existing = get().caps[creatorId]; + if (!existing) return; + set(state => ({ + caps: { + ...state.caps, + [creatorId]: { + ...existing, + currentHolding: Math.min( + existing.currentHolding + qty, + existing.maxCap + ), + }, + }, + })); + }, +})); + +// --------------------------------------------------------------------------- +// Selectors +// --------------------------------------------------------------------------- + +/** Fraction of the cap consumed (0–1). */ +export function capUsedFraction(data: HoldingCapData): number { + if (data.maxCap === 0) return 0; + return Math.min(data.currentHolding / data.maxCap, 1); +} + +/** True when ≥80% of the cap is consumed. */ +export function isApproachingCap(data: HoldingCapData): boolean { + return capUsedFraction(data) >= 0.8 && data.currentHolding < data.maxCap; +} + +/** True when the wallet is at 100% of the cap. */ +export function isAtHoldingCap(data: HoldingCapData): boolean { + return data.currentHolding >= data.maxCap && data.maxCap > 0; +} + +/** How many more keys the wallet may buy. */ +export function remainingCapacity(data: HoldingCapData): number { + return Math.max(0, data.maxCap - data.currentHolding); +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 8c1abcd0..df4287b9 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; import { useDebounce } from '@/hooks/useDebounce'; import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; import { LayoutGroup, motion } from 'framer-motion'; @@ -46,6 +45,11 @@ import type { SlippageBounds } from '@/utils/slippageTolerance.utils'; import TradePanelErrorBoundary from '@/components/common/TradePanelErrorBoundary'; import NetworkMismatchBanner from '@/components/common/NetworkMismatchBanner'; import StellarConnectionQualityBadge from '@/components/common/StellarConnectionQualityBadge'; +import AdminPauseControl from '@/components/common/AdminPauseControl'; +import { + useContractPausedStore, + selectIsPaused, +} from '@/hooks/useContractPausedStore'; import KeyAnalyticsPanel from '@/components/common/KeyAnalyticsPanel'; import TimelockAdminPanel from '@/components/common/TimelockAdminPanel'; import MultiKeyStakingVault from '@/components/common/MultiKeyStakingVault'; @@ -73,23 +77,8 @@ import { calculatePnLSummary, formatPnLDisplay, formatPnLPercentage, - getPnLToneClassName, - resolveAveragePurchasePriceStroops, type HeldKeyPosition, } from '@/utils/portfolioValue.utils'; -import TradeCooldownButton from '@/components/common/TradeCooldownButton'; -import type { ActiveTradeCooldown } from '@/utils/tradeCooldown.utils'; -import { - useTradeCooldownStatus, - invalidateTradeCooldownStatus, - resolveActiveTradeCooldown, -} from '@/hooks/useTradeCooldownStatus'; -import { - averagePurchasePriceFromCostBasis, - resolveCostBasisWalletKey, - useKeyCostBasis, - type KeyCostBasisEntry, -} from '@/hooks/useKeyCostBasis'; import PrecisionModeToggle, { type PrecisionMode, } from '@/components/common/PrecisionModeToggle'; @@ -97,7 +86,6 @@ import ScrollToTop from '@/components/common/ScrollToTop'; import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; import StaleDataWarning from '@/components/common/StaleDataWarning'; import { useScrollPreservation } from '@/hooks/useScrollPreservation'; -import { useKeyConfig } from '@/hooks/useKeyConfig'; import { useStaleData } from '@/hooks/useStaleData'; import { useIdleRefreshPrompt } from '@/hooks/useIdleRefreshPrompt'; import IdleRefreshPrompt from '@/components/common/IdleRefreshPrompt'; @@ -117,7 +105,7 @@ import { useNavigationTiming } from '@/hooks/useNavigationTiming'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { CREATOR_LIST_SORT_LAYOUT_TRANSITION } from '@/utils/creatorListSortTransition'; import { creatorListKey } from '@/utils/creatorListKey.utils'; -import { Check, ChevronDown, Copy, RefreshCw, ArrowLeftRight, Share2 } from 'lucide-react'; +import { Check, ChevronDown, Copy, RefreshCw, Share2 } from 'lucide-react'; import ClearedFiltersEmptyState from '@/components/common/ClearedFiltersEmptyState'; import CreatorListPagination from '@/components/common/CreatorListPagination'; import CreatorListGroupSeparator from '@/components/common/CreatorListGroupSeparator'; @@ -132,8 +120,6 @@ import { import { copyTextToClipboard } from '@/utils/clipboard.utils'; import SelfFreezeDialog from '@/components/common/SelfFreezeDialog'; import SharePortfolioModal from '@/components/common/SharePortfolioModal'; -import PortfolioPerformanceChart from '@/components/common/PortfolioPerformanceChart'; -import { usePortfolioHistory } from '@/hooks/usePortfolioHistory'; const FEATURED_CREATOR_FACTS = [ { label: 'Membership', value: 'Collectors Circle' }, @@ -247,13 +233,6 @@ const PAGE_SIZE = 6; const FETCH_RETRY_ACTION_LABEL = 'Try again'; const DEMO_HELD_KEY_QUANTITIES = [0, 2, 1] as const; const DEMO_WALLET_ADDRESS = 'demo-wallet-address'; - -/** - * Stable empty cost-basis map returned by the `useKeyCostBasis` selector for - * wallets with no recorded positions. A shared reference keeps the selector - * referentially stable so it does not re-render the holdings list. - */ -const EMPTY_COST_BASIS_ENTRIES: Record = {}; const FINAL_FETCH_ERROR_COPY = 'Unable to load live creators right now. Showing fallback creators.'; const CREATOR_REFRESH_SHORTCUT_LABEL = 'Ctrl/Cmd + Alt + R'; @@ -784,22 +763,6 @@ function LandingPage() { // fall back to the demo featured creator. This keeps the profile panel // reactive to backend updates (supply, price, etc.). const featuredCreator = creators.length > 0 ? creators[0] : DEMO_CREATORS[0]; - // Live key config powers the bid-ask spread in the quick-trade modal - // (#951) and refetches as the key configuration changes. - const { data: featuredKeyConfig, isLoading: isFeaturedKeyConfigLoading } = - useKeyConfig(featuredCreator?.id); - - // #998 — trade cooldown for the featured key: fetched on page load and - // refetched after each trade (see handleConfirmTrade), so the buy/sell - // buttons show a live countdown and stay disabled until it expires. - const { data: featuredCooldownStatus } = useTradeCooldownStatus( - featuredCreator?.id ?? '' - ); - const featuredTradeCooldown: ActiveTradeCooldown | null = - resolveActiveTradeCooldown( - featuredCooldownStatus, - featuredCreator?.nextBuyAllowedAt ?? null - ); useEffect(() => { if (pendingScrollRestoreRef.current == null) return; @@ -877,28 +840,6 @@ function LandingPage() { const redeemMutation = useRedeemDeprecatedKeyMutation(activeWalletAddress); const { data: cachedHoldings = [] } = useWalletHoldings(activeWalletAddress); - // #1052 — the wallet's portfolio value over time, plotted by the performance - // chart in the holdings overview. The full series is fetched once and the - // chart slices it per range selector. - const { - data: portfolioHistory = [], - isLoading: isPortfolioHistoryLoading, - isError: isPortfolioHistoryError, - refetch: refetchPortfolioHistory, - } = usePortfolioHistory(activeWalletAddress); - - // #935 — the wallet's persisted per-key cost basis, used as the average - // purchase price each position's unrealised P&L is measured against. The - // selector returns a stable reference (either the stored map or a shared - // empty object) so it never re-renders on unrelated store writes. - // Keyed on `activeWalletAddress` so demo trades — which the mutation records - // under the demo address — resolve to the same bucket the trades were - // written to. - const costBasisWalletKey = resolveCostBasisWalletKey(activeWalletAddress); - const costBasisByCreatorId = useKeyCostBasis( - state => state.entriesByWallet[costBasisWalletKey] ?? EMPTY_COST_BASIS_ENTRIES - ); - // Merged: keep total-value sorting (feature/holdings-sorting-tests) while // also zeroing out the demo baseline quantities once a real wallet is // connected (dev), so a connected wallet only shows genuine cached @@ -915,39 +856,18 @@ function LandingPage() { ? featuredHoldings : (DEMO_HELD_KEY_QUANTITIES[index] ?? 0); const baseQuantity = connectedAddress ? 0 : defaultBaseQuantity; - const basePosition = { + return { creatorId: creator.id, quantity: cached?.quantity ?? baseQuantity, priceStroops: creator.priceStroops, price: creator.price, - // #935 — live supply so the current value can be valued on - // the bonding curve rather than a cached snapshot. - currentSupply: creator.creatorShareSupply, - frozenQuantity: cached?.frozenQuantity ?? 0, - liquidQuantity: - cached?.liquidQuantity ?? cached?.quantity ?? baseQuantity, + frozenQuantity: cached?.frozenQuantity ?? 0, + liquidQuantity: + cached?.liquidQuantity ?? cached?.quantity ?? baseQuantity, isPriceLoading: isPriceRefreshing, isPriceStale: creatorsAreStale, pending: cached?.pending ?? false, unclaimedDividend: cached?.unclaimedDividend ?? 0, - // #935 — cost basis reported by the backend, which wins over - // the locally tracked basis when both are available. - averagePurchasePriceStroops: - cached?.averagePurchasePriceStroops ?? null, - }; - - return { - ...basePosition, - // #935 — average purchase price: server-provided cost basis - // first, then the locally tracked basis, then seeded from the - // current curve price so a position synced without cost - // history starts at break-even instead of hiding its P&L. - averagePurchasePriceStroops: resolveAveragePurchasePriceStroops( - basePosition, - averagePurchasePriceFromCostBasis( - costBasisByCreatorId[creator.id] - ) - ), }; }) ), @@ -958,7 +878,6 @@ function LandingPage() { isPriceRefreshing, cachedHoldings, connectedAddress, - costBasisByCreatorId, ] ); const portfolioValue = useMemo( @@ -983,19 +902,13 @@ function LandingPage() { displayedPortfolioValue ); + const isPaused = useContractPausedStore(selectIsPaused); + const openTradeDialog = useCallback((side: TradeSide) => { setTradeSide(side); setTradeDialogOpen(true); }, []); - const queryClient = useQueryClient(); - const refreshTradeCooldown = useCallback( - (keyId: string) => { - if (keyId) invalidateTradeCooldownStatus(queryClient, keyId); - }, - [queryClient] - ); - const handleConfirmTradeViaShortcut = useCallback(() => { const confirmButton = document.querySelector( '[data-testid="trade-dialog-confirm"]' @@ -1014,6 +927,17 @@ function LandingPage() { if (!selfFreezeDialog) return; const { action, position } = selfFreezeDialog; try { + const actionVerb = + tradeSide === 'buy' + ? 'buy' + : tradeSide === 'sell' + ? 'sell' + : tradeSide === 'stake' + ? 'stake' + : 'transfer'; + showToast.loading( + `Submitting ${actionVerb} for ${amount} key${amount === 1 ? '' : 's'}...` + ); await selfFreezeMutation.mutateAsync({ creatorId: position.creatorId, amount, @@ -1105,9 +1029,6 @@ function LandingPage() { price: featuredCreator?.price, ref: urlRef, maxPriceStroops: slippage?.maxPriceStroops ?? null, - // #935 — live supply so the buy's cost basis is recorded at the - // price the curve actually charges across the buy range. - currentSupply: featuredCreator?.creatorShareSupply ?? null, }); setFeaturedHoldings(current => current + amount); showToast.transactionSuccess( @@ -1124,10 +1045,6 @@ function LandingPage() { priceStroops: resolveCreatorKeyPriceStroops(featuredCreator), price: featuredCreator?.price, minPriceStroops: slippage?.minPriceStroops ?? null, - // #935 — the sell releases the sold keys' share of the position's - // cost basis; the average purchase price of the remaining keys is - // preserved. - currentSupply: featuredCreator?.creatorShareSupply ?? null, }); setFeaturedHoldings(current => Math.max(0, current - amount)); showToast.transactionSuccess( @@ -1153,9 +1070,6 @@ function LandingPage() { showToast.error(getSignatureErrorMessage(error)); } } finally { - // #998 — after any settled trade, refetch the cooldown so the - // buy/sell buttons reflect the freshly committed cooldown window. - refreshTradeCooldown('1'); setTradeSubmitting(false); } }; @@ -1212,8 +1126,9 @@ function LandingPage() { -
+
+
@@ -1706,17 +1621,6 @@ function LandingPage() { {displayedPortfolioValue.heldPositionCount}
- -
-
{pnlSummary.status === 'ready' && pnlSummary.totalInvested > 0 && ( @@ -1729,10 +1633,7 @@ function LandingPage() { Total Invested - + {formatPnLDisplay(pnlSummary.totalInvested)} @@ -1740,10 +1641,7 @@ function LandingPage() { Current Value - + {formatPnLDisplay(pnlSummary.currentValue)} @@ -1752,12 +1650,15 @@ function LandingPage() { Unrealised PnL 0 + ? 'text-emerald-400' + : pnlSummary.unrealisedPnL < 0 + ? 'text-red-400' + : 'text-white' + }`} > - {formatPnLDisplay(pnlSummary.unrealisedPnL)}  + {formatPnLDisplay(pnlSummary.unrealisedPnL)}{' '} ( {formatPnLPercentage( pnlSummary.pnlPercentage @@ -1766,43 +1667,17 @@ function LandingPage() { -
- -

- Valued at the current bonding curve sell price across  - {pnlSummary.costBasisPositionCount}  - {pnlSummary.costBasisPositionCount === 1 - ? 'position' - : 'positions'}  - with a tracked average purchase price. -

-
+ )} - { - void refetchPortfolioHistory(); - }} - className="mt-6" - /> {isLoading ? ( ) : heldKeyPositions.filter( @@ -1821,20 +1696,13 @@ function LandingPage() { const creator = creators.find( item => item.id === position.creatorId ); - // #998 — per-position trade cooldown: the position's - // own nextBuyAllowedAt (refreshed with the holdings - // query after each trade) drives the countdown. return ( - openTradeDialog('buy')} - onSell={() => openTradeDialog('sell')} + onSell={() => openTradeDialog('sell')} onReinvest={async creatorId => { const heldPosition = heldKeyPositions.find( item => item.creatorId === creatorId @@ -2093,44 +1961,60 @@ function LandingPage() { )} - {isNetworkMismatch && }
-
} +
+
+
+ Buy + + + + +
{tradeSubmitting && (
@@ -2192,44 +2076,56 @@ function LandingPage() { )}
-
-
+
+
+
+ Buy + + + + +
{tradeSubmitting && (
@@ -2267,15 +2163,14 @@ function LandingPage() { currentLedger={featuredCreator?.currentLedger} launchPenaltyBps={featuredCreator?.launchPenaltyBps} maxBuyQuantity={featuredCreator?.maxBuyQuantity ?? null} - holdingCap={featuredCreator?.holdingCap ?? featuredCreator?.maxHoldingCap ?? null} - keyConfig={featuredKeyConfig} - isKeyConfigLoading={isFeaturedKeyConfigLoading} isSubmitting={tradeSubmitting} onOpenChange={setTradeDialogOpen} onConfirm={handleConfirmTrade} /> - + {(tradeSide === 'buy' || tradeSide === 'sell') && ( + + )}