From 484e415da1441cb03f6d5920f6828c6d0cf16603 Mon Sep 17 00:00:00 2001 From: Emmanuel ogheneovo Date: Wed, 27 May 2026 22:50:49 +0000 Subject: [PATCH] feat: UI improvements for marketplace filtering, search, icons, and scroll preservation - Add visible loading indicator for marketplace filter application (#283) - Add skeleton for creator search input while data loads (#288) - Add consistent icon size token for creator card action buttons (#285) - Add helper for preserving scroll position on creator profile tab switch (#289) Closes #283, #288, #285, #289 --- src/components/common/CreatorCard.tsx | 10 +-- src/components/common/SearchBar.tsx | 17 ++++++ src/hooks/useScrollPreservation.ts | 87 +++++++++++++++++++++++++++ src/index.css | 6 ++ src/pages/LandingPage.tsx | 34 +++++++++++ 5 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 src/hooks/useScrollPreservation.ts diff --git a/src/components/common/CreatorCard.tsx b/src/components/common/CreatorCard.tsx index 7139fd45..47efc042 100644 --- a/src/components/common/CreatorCard.tsx +++ b/src/components/common/CreatorCard.tsx @@ -128,7 +128,7 @@ const CreatorCard: React.FC = ({ creator, className }) => {
{creator.volume24h !== undefined && (
- + {creator.volume24h > 0 ? `${formatCompactNumber(creator.volume24h)} ETH` @@ -158,7 +158,7 @@ const CreatorCard: React.FC = ({ creator, className }) => { {creator.socialHandle ? (
- + @{creator.socialHandle}
) : ( @@ -168,7 +168,7 @@ const CreatorCard: React.FC = ({ creator, className }) => { 'text-xs text-white/30 italic' )} > - + No public handle
)} @@ -191,7 +191,7 @@ const CreatorCard: React.FC = ({ creator, className }) => { - + Handle } @@ -249,7 +249,7 @@ const CreatorCard: React.FC = ({ creator, className }) => { {transactionState === 'failed' && ( )} - + {transactionState === 'submitting' ? 'Processing...' : transactionState === 'success' diff --git a/src/components/common/SearchBar.tsx b/src/components/common/SearchBar.tsx index cb6bd623..fd892de4 100644 --- a/src/components/common/SearchBar.tsx +++ b/src/components/common/SearchBar.tsx @@ -8,6 +8,7 @@ interface SearchBarProps { placeholder?: string; className?: string; validationMessage?: string; + isLoading?: boolean; } const SearchBar: React.FC = ({ @@ -16,7 +17,23 @@ const SearchBar: React.FC = ({ placeholder = 'Search creators by name or handle...', className, validationMessage, + isLoading = false, }) => { + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + return (
diff --git a/src/hooks/useScrollPreservation.ts b/src/hooks/useScrollPreservation.ts new file mode 100644 index 00000000..ecb2e125 --- /dev/null +++ b/src/hooks/useScrollPreservation.ts @@ -0,0 +1,87 @@ +import { useEffect, useRef } from 'react'; + +interface UseScrollPreservationOptions { + /** + * Storage key prefix for saving scroll positions + */ + storageKey: string; + /** + * Whether to enable scroll preservation + */ + enabled?: boolean; + /** + * Delay in milliseconds before restoring scroll position + */ + restoreDelay?: number; +} + +/** + * Hook for preserving scroll position when switching between tabs or states + */ +export function useScrollPreservation( + currentTab: string, + options: UseScrollPreservationOptions +) { + const { storageKey, enabled = true, restoreDelay = 0 } = options; + const previousTabRef = useRef(null); + const restoreTimeoutRef = useRef(null); + + useEffect(() => { + if (!enabled || typeof window === 'undefined') return; + + const fullStorageKey = `${storageKey}.${currentTab}`; + + // Save scroll position for the previous tab + if (previousTabRef.current && previousTabRef.current !== currentTab) { + const previousKey = `${storageKey}.${previousTabRef.current}`; + window.sessionStorage.setItem(previousKey, String(window.scrollY)); + } + + // Restore scroll position for the current tab + const savedScroll = window.sessionStorage.getItem(fullStorageKey); + if (savedScroll) { + const scrollY = Number(savedScroll); + if (Number.isFinite(scrollY)) { + // Clear any pending restore timeout + if (restoreTimeoutRef.current) { + clearTimeout(restoreTimeoutRef.current); + } + + // Restore scroll position with optional delay + if (restoreDelay > 0) { + restoreTimeoutRef.current = setTimeout(() => { + window.scrollTo({ top: scrollY, behavior: 'instant' }); + }, restoreDelay); + } else { + // Use requestAnimationFrame to ensure DOM is ready + requestAnimationFrame(() => { + window.scrollTo({ top: scrollY, behavior: 'instant' }); + }); + } + } + } + + // Update the previous tab reference + previousTabRef.current = currentTab; + + // Cleanup timeout on unmount + return () => { + if (restoreTimeoutRef.current) { + clearTimeout(restoreTimeoutRef.current); + } + }; + }, [currentTab, storageKey, enabled, restoreDelay]); + + // Save scroll position on scroll events + useEffect(() => { + if (!enabled || typeof window === 'undefined') return; + + const handleScroll = () => { + const fullStorageKey = `${storageKey}.${currentTab}`; + window.sessionStorage.setItem(fullStorageKey, String(window.scrollY)); + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => window.removeEventListener('scroll', handleScroll); + }, [currentTab, storageKey, enabled]); +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index a84b3ee7..d3599a13 100644 --- a/src/index.css +++ b/src/index.css @@ -226,6 +226,12 @@ color: var(--marketplace-label-strong); } + /* Consistent icon size token for creator card action buttons */ + .creator-action-icon { + width: 1rem; /* size-4 equivalent */ + height: 1rem; + } + @media (prefers-reduced-motion: reduce) { .skeleton-shimmer { animation: none; diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index af03e972..b47d9848 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -29,6 +29,7 @@ import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; import PrecisionModeToggle, { type PrecisionMode } from '@/components/common/PrecisionModeToggle'; import ScrollToTop from '@/components/common/ScrollToTop'; import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; +import { useScrollPreservation } from '@/hooks/useScrollPreservation'; const FEATURED_CREATOR_FACTS = [ { label: 'Membership', value: 'Collectors Circle' }, @@ -140,6 +141,7 @@ function LandingPage() { const [creators, setCreators] = useState([]); const { isMismatch: isNetworkMismatch } = useNetworkMismatch(); const [isLoading, setIsLoading] = useState(true); + const [isFilterLoading, setIsFilterLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [activeProfileTab, setActiveProfileTab] = useState(() => { if (typeof window === 'undefined') return 'overview'; @@ -171,6 +173,13 @@ function LandingPage() { }); const pendingScrollRestoreRef = useRef(null); + // Use scroll preservation for profile tabs + useScrollPreservation(activeProfileTab, { + storageKey: 'accesslayer.profile-tab-scroll', + enabled: true, + restoreDelay: 100, // Small delay to ensure tab content is rendered + }); + const trimmedSearchQuery = searchQuery.trim(); const hasInvalidSearchInput = /[^a-zA-Z0-9_\s-]/.test(trimmedSearchQuery); const searchValidationMessage = hasInvalidSearchInput @@ -296,6 +305,18 @@ function LandingPage() { return sorted; }, [creators, trimmedSearchQuery, hasInvalidSearchInput, sortOption]); + // Add loading state for filter changes + useEffect(() => { + if (creators.length === 0) return; // Don't show filter loading during initial load + + setIsFilterLoading(true); + const timer = setTimeout(() => { + setIsFilterLoading(false); + }, 300); // Short delay to show loading indicator + + return () => clearTimeout(timer); + }, [trimmedSearchQuery, sortOption, creators.length]); + useEffect(() => { setPage(0); }, [trimmedSearchQuery, sortOption]); @@ -416,6 +437,7 @@ function LandingPage() { value={searchQuery} onChange={setSearchQuery} validationMessage={searchValidationMessage} + isLoading={isLoading} className="max-w-none shadow-2xl shadow-black/20" />
@@ -455,6 +477,18 @@ function LandingPage() { {isLoading ? ( + ) : isFilterLoading ? ( +
+
+
+ Updating results... +
+
+ {pagedCreators.map(creator => ( + + ))} +
+
) : filteredCreators.length > 0 ? (
{showRetryBanner && (