From a66b645b45d1665a704bd99f78cde97d0c70c27a Mon Sep 17 00:00:00 2001 From: CAESAR MCMXCVII Date: Mon, 1 Jun 2026 07:22:15 +0100 Subject: [PATCH 1/3] Add-inline-validation-for-trade-amount-input-field --- src/components/common/TradeDialog.tsx | 49 +++++++++++++++++++-------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index 218c366..728850b 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -42,10 +42,14 @@ const TradeDialog: React.FC = ({ isSubmitting = false, }) => { const [amountText, setAmountText] = useState('1'); + const [touched, setTouched] = useState(false); const amountInputRef = useRef(null); useEffect(() => { - if (open) setAmountText('1'); + if (open) { + setAmountText('1'); + setTouched(false); + } }, [open]); const parsedAmount = useMemo(() => { @@ -54,10 +58,18 @@ const TradeDialog: React.FC = ({ 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 title = side === 'buy' ? 'Buy keys' : 'Sell keys'; const confirmLabel = side === 'buy' ? 'Confirm buy' : 'Confirm sell'; @@ -110,19 +122,33 @@ const TradeDialog: React.FC = ({ ref={amountInputRef} inputMode="decimal" value={amountText} - onChange={event => setAmountText(event.target.value)} + onChange={event => { + setAmountText(event.target.value); + setTouched(true); + }} + 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" /> + {showError && ( + + )}
= ({ className="text-white/45" /> )} - {side === 'sell' && parsedAmount > availableHoldings && ( -
- You can’t sell more than your current holdings. -
- )}
{/* From 096a80ef2bf19fd9861519d24cf7c7d1c47b17ec Mon Sep 17 00:00:00 2001 From: CAESAR MCMXCVII Date: Mon, 1 Jun 2026 07:30:11 +0100 Subject: [PATCH 2/3] Add-inline-validation-for-trade-amount-input-field --- src/pages/LandingPage.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 80d8066..a00488b 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'; @@ -265,7 +264,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'; @@ -1246,7 +1244,6 @@ function LandingPage() { availableHoldings={featuredHoldings} keyPriceStroops={resolveCreatorKeyPriceStroops(featuredCreator)} isSubmitting={tradeSubmitting} - networkFeeEstimateProvider={tradeFeeEstimateProvider} onOpenChange={setTradeDialogOpen} onConfirm={handleConfirmTrade} /> From 04a6a5a38000911336707974ab20da3d7ea05f08 Mon Sep 17 00:00:00 2001 From: CAESAR MCMXCVII Date: Mon, 1 Jun 2026 07:38:05 +0100 Subject: [PATCH 3/3] Add-idle-refresh-prompt-for-creator-list-after-inactivity-threshold --- src/components/common/IdleRefreshPrompt.tsx | 71 +++++++++++++ src/hooks/useIdleRefreshPrompt.ts | 108 ++++++++++++++++++++ src/pages/LandingPage.tsx | 21 ++++ 3 files changed, 200 insertions(+) create mode 100644 src/components/common/IdleRefreshPrompt.tsx create mode 100644 src/hooks/useIdleRefreshPrompt.ts 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/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 a00488b..cf9d462 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -43,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, @@ -508,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) => ({ @@ -1248,6 +1264,11 @@ function LandingPage() { onConfirm={handleConfirmTrade} /> + ); }