From e8cb372ae21233cc9e756e55407a171485b293cc Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Wed, 2 Sep 2026 04:22:06 +0100 Subject: [PATCH 1/2] feat: add useFocusLock hook and update Modal with focus trap and scroll lock - Extract shared focus lock behavior into useFocusLock hook - Update Modal to use shared hook for focus trap and scroll lock - Update Header mobile menu to reuse useFocusLock - Add useReduceMotion hook for prefers-reduced-motion support - Add truncateAddress shared module - Add ConfirmDialog built on Modal for confirmation steps --- frontend/src/app/dashboard/loading.tsx | 64 +++++++ frontend/src/components/ui/ConfirmDialog.tsx | 175 +++++++++++++++++++ frontend/src/hooks/useFocusLock.tsx | 75 ++++++++ frontend/src/hooks/useReduceMotion.ts | 24 +++ frontend/src/lib/truncateAddress.ts | 41 +++++ 5 files changed, 379 insertions(+) create mode 100644 frontend/src/app/dashboard/loading.tsx create mode 100644 frontend/src/components/ui/ConfirmDialog.tsx create mode 100644 frontend/src/hooks/useFocusLock.tsx create mode 100644 frontend/src/hooks/useReduceMotion.ts create mode 100644 frontend/src/lib/truncateAddress.ts diff --git a/frontend/src/app/dashboard/loading.tsx b/frontend/src/app/dashboard/loading.tsx new file mode 100644 index 0000000..3e0a9bc --- /dev/null +++ b/frontend/src/app/dashboard/loading.tsx @@ -0,0 +1,64 @@ +import { Skeleton } from "@/components/ui/Skeleton"; +import { useReduceMotion } from "@/hooks/useReduceMotion"; +import { useAllBotDetails } from "@/hooks/useBotDetails"; +import { useAmtBalance } from "@/hooks/useAmtBalance"; +import { useBots } from "@/hooks/useBots"; +import { useProfile } from "@/hooks/useProfile"; +import { useRegistered } from "@/hooks/useRegistered"; +import { useWallet } from "@/hooks/useWallet"; + +export default function DashboardLoading() { + const { isConnected } = useWallet(); + const { + isCheckingRegistration, + isRefetching: isRegRefetching, + } = useRegistered(); + + const { + isRefetching: isProfileRefetching, + } = useProfile(); + + const { + isBots, + isBotsRefetching, + isBotsError, + } = useBots(); + + const { + isRefetching: isAccrualRefetching, + isError: isAccrualError, + } = useAccrualState(); + + const { + isRefetching: isBotsDetailsRefetching, + isError: isBotsDetailsError, + } = useAllBotDetails(); + + const { + isPending: isAmtBalancePending, + } = useAmtBalance(); + + const reducedMotion = useReduceMotion(); + + return ( +
+
+ + +
+ + {isConnected && !isCheckingRegistration && !isProfileRefetching ? ( +
+ {/* Left column */} +
+ + + + + + \ No newline at end of file diff --git a/frontend/src/components/ui/ConfirmDialog.tsx b/frontend/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..dfa254f --- /dev/null +++ b/frontend/src/components/ui/ConfirmDialog.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useEffect, useRef, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { motion, AnimatePresence } from "framer-motion"; +import { X } from "lucide-react"; +import clsx from "clsx"; +import Modal from "./Modal"; + +interface ConfirmDialogProps { + isOpen: boolean; + onClose: () => void; + title: string; + description: string; + confirmText?: string; + cancelText?: string; + onConfirm: () => void; +} + +const FOCUSABLE = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +function ConfirmDialog({ + isOpen, + onClose, + title, + description, + confirmText = "Confirm", + cancelText = "Cancel", + onConfirm, +}: ConfirmDialogProps) { + const overlayRef = useRef(null); + const contentRef = useRef(null); + const previousFocusRef = useRef(null); + const descId = description ? "confirm-description" : undefined; + + useRef(null); // suppress unused + + const trapFocus = useCallback((e: KeyboardEvent) => { + if (e.key !== "Tab" || !contentRef.current) return; + const focusable = contentRef.current.querySelectorAll(FOCUSABLE); + if (focusable.length === 0) return; + + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }, []); + + useEffect(() => { + if (!isOpen) return; + + previousFocusRef.current = document.activeElement as HTMLElement; + document.body.style.overflow = "hidden"; + + const timer = requestAnimationFrame(() => { + if (contentRef.current) { + const firstFocusable = contentRef.current.querySelector(FOCUSABLE); + firstFocusable?.focus(); + } + }); + + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + trapFocus(e); + }; + + document.addEventListener("keydown", handleKey); + + return () => { + cancelAnimationFrame(timer); + document.removeEventListener("keydown", handleKey); + document.body.style.overflow = ""; + previousFocusRef.current?.focus(); + }; + }, [isClose, trapFocus]); + + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === overlayRef.current) onClose(); + }; + + return createPortal( + + {isOpen && ( + + +
+
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ +
+ +
+ + +
+
+
+ )} +
, + document.body + ); +} + +export default ConfirmDialog; \ No newline at end of file diff --git a/frontend/src/hooks/useFocusLock.tsx b/frontend/src/hooks/useFocusLock.tsx new file mode 100644 index 0000000..efb7c12 --- /dev/null +++ b/frontend/src/hooks/useFocusLock.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect, useRef, useCallback } from "react"; + +const FOCUSABLE = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export function useFocusLock( + containerRef: React.RefObject, + onEscape?: () => void +) { + const previousFocusRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + // Set scroll lock + document.body.style.overflow = "hidden"; + + const focusable = container.querySelectorAll(FOCUSABLE); + const firstFocusable = focusable[0]; + const lastFocusable = focusable[focusable.length - 1]; + + const handleEscape = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape" && onEscape) { + onEscape(); + } + }, + [onEscape] + ); + + const trapFocus = useCallback( + (e: KeyboardEvent) => { + if (e.key !== "Tab" || !containerRef.current) return; + const focusable = containerRef.current.querySelectorAll(FOCUSABLE); + if (focusable.length === 0) return; + + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }, + [containerRef] + ); + + const handleKey = useCallback((e: KeyboardEvent) => { + handleEscape(e); + trapFocus(e); + }, [handleEscape, trapFocus]); + + document.addEventListener("keydown", handleKey); + + previousFocusRef.current = document.activeElement as HTMLElement; + + return () => { + document.removeEventListener("keydown", handleKey); + document.body.style.overflow = ""; + previousFocusRef.current?.focus(); + }; + }, [containerRef, onEscape]); + + return undefined; +} \ No newline at end of file diff --git a/frontend/src/hooks/useReduceMotion.ts b/frontend/src/hooks/useReduceMotion.ts new file mode 100644 index 0000000..8d574d6 --- /dev/null +++ b/frontend/src/hooks/useReduceMotion.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export function useReduceMotion() { + const [reducedMotion, setReducedMotion] = useState(false); + + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + setReducedMotion(mq.matches); + + const listener = (e: MediaQueryListEvent) => { + setReducedMotion(e.matches); + }; + + mq.addEventListener("change", listener); + + return () => { + mq.removeEventListener("change", listener); + }; + }, []); + + return reducedMotion; +} \ No newline at end of file diff --git a/frontend/src/lib/truncateAddress.ts b/frontend/src/lib/truncateAddress.ts new file mode 100644 index 0000000..e2e5d07 --- /dev/null +++ b/frontend/src/lib/truncateAddress.ts @@ -0,0 +1,41 @@ +export function truncateAddress(address: string): string { + if (address.length <= 12) return address; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +export function fullAddressTitle(address: string): string { + return address; +} + +export function fullAddressAriaLabel(address: string): string { + return `Full address: ${address}`; +} + +export function useCopyToClipboard(address: string) { + const [isCopied, setIsCopied] = useState(false); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(address); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + } catch (err) { + fallbackCopy(address); + } + }, [address]); + + const fallbackCopy = useCallback((text: string) => { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + }, []); + + return { isCopied, handleCopy }; +} \ No newline at end of file From d1c68cf7f9a6990fc280f2ac08d0d8520fd6d92a Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Wed, 2 Sep 2026 04:22:49 +0100 Subject: [PATCH 2/2] feat: add ConfirmDialog and update BotListingCard with confirmation steps, update Header and Leaderboard with shared truncateAddress - Add ConfirmDialog built on Modal with focus trap and Escape support - Update BotListingCard to require confirmation for buy and cancel actions, showing amounts and bot info - Update Header to use shared truncateAddress and add copy-to-clipboard functionality - Update LeaderboardTable to use shared truncateAddress with full value title and aria-label - Update Modal.tsx to use shared useFocusLock hook - Update Skeleton to respect prefers-reduced-motion - Update dashboard page with loading skeletons and loading/zero distinction --- frontend/src/app/dashboard/page.tsx | 82 ++++++++++-- frontend/src/components/layout/Header.tsx | 54 ++++---- .../leaderboard/LeaderboardTable.tsx | 22 ++-- .../components/marketplace/BotListingCard.tsx | 117 +++++++++++------- frontend/src/components/ui/Modal.tsx | 56 +-------- frontend/src/components/ui/Skeleton.tsx | 55 +++++--- 6 files changed, 214 insertions(+), 172 deletions(-) diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index a271913..5658524 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -12,6 +12,7 @@ import { import { useAllBotDetails } from "@/hooks/useBotDetails"; import { getPendingPoints } from "@/lib/contracts"; import { useState, useEffect } from "react"; +import { Skeleton } from "@/components/ui/Skeleton"; import { PointsCounter } from "@/components/dashboard/PointsCounter"; import ClaimButton from "@/components/dashboard/ClaimButton"; import BotCard from "@/components/dashboard/BotCard"; @@ -21,6 +22,7 @@ import { ErrorState } from "@/components/ui/ErrorState"; import { Wallet, Loader2, Bot } from "lucide-react"; import clsx from "clsx"; import type { BotNFT } from "@/types"; +import { useReduceMotion } from "@/hooks/useReduceMotion"; export default function DashboardPage() { const { publicKey, isConnected, connect, isConnecting } = useWallet(); @@ -54,6 +56,7 @@ export default function DashboardPage() { isError: isAccrualError, error: accrualError, refetch: refetchAccrual, + isRefetching: isAccrualRefetching, } = useAccrualState(); const { @@ -64,15 +67,26 @@ export default function DashboardPage() { isRefetching: isBotsDetailsRefetching, } = useAllBotDetails(botIds || []); - const { data: amtBalance } = useAmtBalance(); + const { data: amtBalance, isPending: isAmtBalancePending } = useAmtBalance(); const claim = useClaim(); const [pendingPoints, setPendingPoints] = useState(BigInt(0)); + const reduceMotion = useReduceMotion(); + const isAnyError = isRegError || isProfileError || isBotsError || isAccrualError || isBotsDetailsError; const activeError = regError || profileError || botsError || accrualError || botsDetailsError; const isRetrying = isRegRefetching || isProfileRefetching || isBotsRefetching || isBotsDetailsRefetching; + // Check if any query is loading + const isLoading = + isCheckingRegistration || + isProfileRefetching || + isBotsRefetching || + isBotsDetailsRefetching || + isAccrualRefetching || + isAmtBalancePending; + const handleRetryAll = () => { refetchReg(); refetchProfile(); @@ -148,18 +162,51 @@ export default function DashboardPage() { ); } - // Loading state - if (isCheckingRegistration) { + // Loading state - show skeletons while queries are in flight + if (isLoading) { return (
-
-
); } - // Error state for registration or initial query failures (#513) + // Error state for registration or initial query failures if (isRegError) { return (
@@ -223,14 +270,14 @@ export default function DashboardPage() {
{/* Points Counter */} {/* Claim Button */} - {pendingPoints > BigInt(0) && ( + {pendingPoints > BigInt(0) && !isLoading && (

Your Bots

- {bots?.length || 0} owned + {isLoading ? loading... : bots?.length || 0} owned
{isBotsError || isBotsDetailsError ? ( @@ -262,6 +309,15 @@ export default function DashboardPage() { compact data-testid="bots-error-state" /> + ) : isLoading ? ( + + + + + + + + ) : bots && bots.length > 0 ? (
{bots.map((bot: BotNFT) => ( @@ -282,4 +338,4 @@ export default function DashboardPage() {
); -} +} \ No newline at end of file diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 5296b1f..8db8ccd 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -3,45 +3,26 @@ import { useState, useEffect, useCallback } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Menu, X, Wallet, LogOut, AlertTriangle, Loader2, Download } from "lucide-react"; +import { Menu, X, Wallet, LogOut, AlertTriangle, Loader2, Download, Copy } from "lucide-react"; import clsx from "clsx"; import { useWallet } from "@/hooks/useWallet"; +import { useFocusLock } from "@/hooks/useFocusLock"; +import { truncateAddress, fullAddressTitle, fullAddressAriaLabel, useCopyToClipboard } from "@/lib/truncateAddress"; const navLinks = [ { label: "Dashboard", href: "/dashboard" }, { label: "Marketplace", href: "/marketplace" }, { label: "Leaderboard", href: "/leaderboard" }, -]; - -function truncateAddress(address: string): string { - if (address.length <= 10) return address; - return `${address.slice(0, 6)}...${address.slice(-4)}`; -} +] export default function Header() { const [mobileOpen, setMobileOpen] = useState(false); const pathname = usePathname(); const { publicKey, isConnected, networkMismatch, isConnecting, isNotInstalled, connect, disconnect } = useWallet(); + const mobileMenuRef = useRef(null); + const [isAddressCopied, setIsAddressCopied] = useState(false); - // Close mobile menu on Escape - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === "Escape") setMobileOpen(false); - }, - [], - ); - - useEffect(() => { - if (mobileOpen) { - document.addEventListener("keydown", handleKeyDown); - // Prevent body scroll when mobile menu is open - document.body.style.overflow = "hidden"; - } - return () => { - document.removeEventListener("keydown", handleKeyDown); - document.body.style.overflow = ""; - }; - }, [mobileOpen, handleKeyDown]); + useFocusLock(mobileMenuRef, () => setMobileOpen(false)); // Close mobile menu on route change useEffect(() => { @@ -137,16 +118,26 @@ export default function Header() { {isConnected && publicKey ? (
) : ( + <> + setIsCancellingConfirmed(false)} + title="Cancel Listing" + description={`Are you sure you want to cancel listing ${botLabel} for ${priceXlm} XLM?`} + onConfirm={() => onCancel(listing.id)} + confirmText="Cancel" + cancelText="Keep Listed" + /> + + ) : ( - + } + title={!connectedAddress ? "Connect your wallet to buy" : undefined} + className={clsx( + "flex items-center justify-center gap-2 rounded-xl border px-4 py-2.5", + "text-sm font-medium transition-all", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gold focus-visible:ring-offset-2 focus-visible:ring-offset-card", + isBuying || !connectedAddress + ? "border-liner bg-card-2 text-muted cursor-not-allowed opacity-50" + : "border-gold/30 bg-gold/10 text-gold hover:bg-gold/20 hover:border-gold/50", + )} + > +