diff --git a/src/components/common/IdleRefreshPrompt.tsx b/src/components/common/IdleRefreshPrompt.tsx new file mode 100644 index 0000000..7078f6e --- /dev/null +++ b/src/components/common/IdleRefreshPrompt.tsx @@ -0,0 +1,71 @@ +import { RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +export interface IdleRefreshPromptProps { + /** Whether the prompt is currently visible. */ + visible: boolean; + /** Called when the user clicks "Refresh". */ + onRefresh: () => void; + /** Called when the user dismisses without refreshing. */ + onDismiss: () => void; +} + +/** + * A subtle bottom-of-viewport banner that appears after an inactivity + * threshold and offers to refresh the creator list. + * + * Rendered into the normal DOM flow but positioned fixed so it floats above + * page content without shifting layout. Hidden via `aria-hidden` and + * `pointer-events-none` when not visible so it never interferes with + * keyboard navigation. + */ +const IdleRefreshPrompt: React.FC = ({ + visible, + onRefresh, + onDismiss, +}) => { + return ( +
+ + + The creator list may be out of date. + + +
+ ); +}; + +export default IdleRefreshPrompt; diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index a4bdd52..728850b 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -15,13 +15,7 @@ import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; import PercentageBadge from '@/components/common/PercentageBadge'; import NetworkFeeHint from '@/components/common/NetworkFeeHint'; import { TRADE_FEE_ESTIMATE } from '@/constants/fees'; -import { clampBuyQuantity } from '@/utils/buyQuantity'; -import { - fetchTradeNetworkFeeEstimate, - formatTransactionFeeDisplay, - type NetworkFeeDataProvider, -} from '@/utils/transactionFee.utils'; -import { normalizeCreatorDisplayName } from '@/utils/creatorDisplayName.utils'; +import { formatTransactionFeeDisplay } from '@/utils/transactionFee.utils'; export type TradeSide = 'buy' | 'sell'; @@ -35,13 +29,8 @@ export interface TradeDialogProps { onOpenChange: (open: boolean) => void; onConfirm: (amount: number) => Promise | void; isSubmitting?: boolean; - networkFeeEstimateProvider?: NetworkFeeDataProvider; } -type NetworkFeeEstimateState = - | { status: 'idle' | 'loading' | 'error'; fee: null } - | { status: 'success'; fee: number }; - const TradeDialog: React.FC = ({ open, side, @@ -51,111 +40,43 @@ const TradeDialog: React.FC = ({ onOpenChange, onConfirm, isSubmitting = false, - networkFeeEstimateProvider, }) => { const [amountText, setAmountText] = useState('1'); - const [networkFeeEstimate, setNetworkFeeEstimate] = - useState({ status: 'idle', fee: null }); - const [adjustmentNote, setAdjustmentNote] = useState(null); + const [touched, setTouched] = useState(false); const amountInputRef = useRef(null); useEffect(() => { if (open) { setAmountText('1'); - setAdjustmentNote(null); + setTouched(false); } }, [open]); - const handleBlur = () => { - if (side !== 'buy') return; - - const trimmed = amountText.trim(); - const res = clampBuyQuantity(trimmed); - - 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); - } - }; - const parsedAmount = useMemo(() => { const normalized = amountText.trim(); if (!normalized) return NaN; return Number(normalized); }, [amountText]); - const amountValid = - Number.isFinite(parsedAmount) && - parsedAmount > 0 && - (side !== 'sell' || parsedAmount <= availableHoldings); + 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 (parsedAmount <= 0) return 'Amount must be greater than zero.'; + if (side === 'sell' && parsedAmount > availableHoldings) + return `You can't sell more than your holdings (${formatNumber(availableHoldings)} keys).`; + return null; + }, [amountText, parsedAmount, side, availableHoldings]); + + const amountValid = validationError === null; + const showError = touched && validationError !== null; - const displayCreatorName = - normalizeCreatorDisplayName(creatorName) || 'Unnamed creator'; const title = side === 'buy' ? 'Buy keys' : 'Sell keys'; const confirmLabel = side === 'buy' ? 'Confirm buy' : 'Confirm sell'; const estimatedNetworkFee = formatTransactionFeeDisplay( - networkFeeEstimate.status === 'success' - ? networkFeeEstimate.fee - : TRADE_FEE_ESTIMATE.DEFAULT_NETWORK_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; - - 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 }); - - fetchTradeNetworkFeeEstimate(networkFeeEstimateProvider, { - side, - 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, - ]); return ( = ({ {title} {side === 'buy' - ? `Purchase creator keys for ${displayCreatorName}.` - : `Sell creator keys for ${displayCreatorName}.`} + ? `Purchase creator keys for ${creatorName}.` + : `Sell creator keys for ${creatorName}.`} @@ -203,25 +124,30 @@ const TradeDialog: React.FC = ({ value={amountText} onChange={event => { setAmountText(event.target.value); - setAdjustmentNote(null); + setTouched(true); }} - onBlur={handleBlur} + onBlur={() => setTouched(true)} disabled={isSubmitting} className={cn( 'w-full rounded-xl border bg-white/[0.04] px-3 py-2 text-white outline-none transition-colors', 'border-white/10 focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15', - !amountValid && amountText.trim() - ? 'border-red-500/40' - : '' + showError ? 'border-red-500/60' : '' )} aria-label="Trade amount" + aria-describedby={showError ? 'trade-amount-error' : undefined} + aria-invalid={showError || undefined} data-focus-order="1" data-testid="trade-dialog-amount" /> - {side === 'buy' && adjustmentNote && ( -
- {adjustmentNote} -
+ {showError && ( + )}
= ({ /> )}
- - {side === 'sell' && parsedAmount > availableHoldings && ( -
- You can’t sell more than your current holdings. -
+ {side === 'buy' && ( + )} diff --git a/src/hooks/useIdleRefreshPrompt.ts b/src/hooks/useIdleRefreshPrompt.ts new file mode 100644 index 0000000..1f1c92d --- /dev/null +++ b/src/hooks/useIdleRefreshPrompt.ts @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export interface UseIdleRefreshPromptOptions { + /** Milliseconds of inactivity before the prompt appears. Default: 5 minutes. */ + thresholdMs?: number; + /** Called when the threshold is crossed and the prompt should be shown. */ + onIdle?: () => void; +} + +export interface UseIdleRefreshPromptReturn { + /** Whether the idle prompt is currently visible. */ + isPromptVisible: boolean; + /** Call this to show the prompt (e.g. when the threshold fires). */ + showPrompt: () => void; + /** Dismiss the prompt without refreshing. */ + dismissPrompt: () => void; + /** Reset the idle timer — call this after a successful refresh. */ + resetTimer: () => void; +} + +const INTERACTION_EVENTS = [ + 'mousemove', + 'keydown', + 'pointerdown', + 'touchstart', + 'scroll', +] as const; + +const DEFAULT_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Detects user inactivity relative to the last creator-list refresh and + * surfaces a prompt after `thresholdMs` of no interaction. + * + * The timer resets whenever the user interacts with the page, so the prompt + * only appears during genuine idle periods. Dismissing the prompt (without + * refreshing) also resets the timer so it doesn't immediately re-appear. + */ +export function useIdleRefreshPrompt( + options: UseIdleRefreshPromptOptions = {} +): UseIdleRefreshPromptReturn { + const { thresholdMs = DEFAULT_THRESHOLD_MS, onIdle } = options; + + const [isPromptVisible, setIsPromptVisible] = useState(false); + const timerRef = useRef | null>(null); + // Keep a stable ref to onIdle so the interaction handler never goes stale. + const onIdleRef = useRef(onIdle); + useEffect(() => { + onIdleRef.current = onIdle; + }, [onIdle]); + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const startTimer = useCallback(() => { + clearTimer(); + timerRef.current = setTimeout(() => { + setIsPromptVisible(true); + onIdleRef.current?.(); + }, thresholdMs); + }, [clearTimer, thresholdMs]); + + // Dismiss on any user interaction while the prompt is visible. + const handleInteraction = useCallback(() => { + if (isPromptVisible) { + setIsPromptVisible(false); + } + // Always restart the timer on interaction so the clock resets. + startTimer(); + }, [isPromptVisible, startTimer]); + + // Attach / re-attach interaction listeners whenever handleInteraction changes. + useEffect(() => { + INTERACTION_EVENTS.forEach(event => + window.addEventListener(event, handleInteraction, { passive: true }) + ); + return () => { + INTERACTION_EVENTS.forEach(event => + window.removeEventListener(event, handleInteraction) + ); + }; + }, [handleInteraction]); + + // Start the timer on mount. + useEffect(() => { + startTimer(); + return clearTimer; + }, [startTimer, clearTimer]); + + const showPrompt = useCallback(() => setIsPromptVisible(true), []); + + const dismissPrompt = useCallback(() => { + setIsPromptVisible(false); + // Reset the timer so the prompt doesn't immediately re-appear. + startTimer(); + }, [startTimer]); + + const resetTimer = useCallback(() => { + setIsPromptVisible(false); + startTimer(); + }, [startTimer]); + + return { isPromptVisible, showPrompt, dismissPrompt, resetTimer }; +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 80d8066..cf9d462 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -30,7 +30,6 @@ import EmptyTransactionTimelineState from '@/components/common/EmptyTransactionT import TradeDialog, { type TradeSide } from '@/components/common/TradeDialog'; import NetworkMismatchBanner from '@/components/common/NetworkMismatchBanner'; import StellarConnectionQualityBadge from '@/components/common/StellarConnectionQualityBadge'; -import { useEthersProvider } from '@/hooks/useEthersProvider'; import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; import showToast from '@/utils/toast.util'; import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; @@ -44,6 +43,8 @@ import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; import StaleDataWarning from '@/components/common/StaleDataWarning'; import { useScrollPreservation } from '@/hooks/useScrollPreservation'; import { useStaleData } from '@/hooks/useStaleData'; +import { useIdleRefreshPrompt } from '@/hooks/useIdleRefreshPrompt'; +import IdleRefreshPrompt from '@/components/common/IdleRefreshPrompt'; import { CREATOR_CARD_ENTRY_CLASS, creatorCardEntryStyle, @@ -265,7 +266,6 @@ function LandingPage() { const [tradeSide, setTradeSide] = useState('buy'); const [tradeDialogOpen, setTradeDialogOpen] = useState(false); const [tradeSubmitting, setTradeSubmitting] = useState(false); - const tradeFeeEstimateProvider = useEthersProvider(); const prefersReducedMotion = usePrefersReducedMotion(); const [sortOption, setSortOption] = useState(() => { if (typeof window === 'undefined') return 'featured'; @@ -510,6 +510,20 @@ function LandingPage() { } ); + // Idle-refresh prompt: after 5 minutes of inactivity, show a subtle + // banner offering to refresh the creator list. Any user interaction + // dismisses it automatically without refreshing. + const { + isPromptVisible: isIdlePromptVisible, + dismissPrompt: dismissIdlePrompt, + resetTimer: resetIdleTimer, + } = useIdleRefreshPrompt({ thresholdMs: 5 * 60 * 1000 }); + + const handleIdleRefresh = () => { + resetIdleTimer(); + handleRetryCreatorFetch(); + }; + const heldKeyPositions = useMemo( () => creators.map((creator, index) => ({ @@ -1246,11 +1260,15 @@ function LandingPage() { availableHoldings={featuredHoldings} keyPriceStroops={resolveCreatorKeyPriceStroops(featuredCreator)} isSubmitting={tradeSubmitting} - networkFeeEstimateProvider={tradeFeeEstimateProvider} onOpenChange={setTradeDialogOpen} onConfirm={handleConfirmTrade} /> + ); }