Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/components/common/CreatorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-transparent to-transparent opacity-0 transition-opacity duration-300 md:group-hover:opacity-100" />
{creator.volume24h !== undefined && (
<div className="absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-full bg-slate-950/75 border border-white/10 px-2.5 py-1 backdrop-blur-md">
<TrendingUp className="size-3 text-emerald-400" />
<TrendingUp className="creator-action-icon text-emerald-400" />
<span className="text-xs font-bold text-white/90">
{creator.volume24h > 0
? `${formatCompactNumber(creator.volume24h)} ETH`
Expand Down Expand Up @@ -158,7 +158,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {

{creator.socialHandle ? (
<div className="marketplace-label-muted mt-2 flex items-center gap-1.5 text-xs">
<LinkIcon className="size-3 text-amber-500/70" />
<LinkIcon className="creator-action-icon text-amber-500/70" />
<span className="truncate">@{creator.socialHandle}</span>
</div>
) : (
Expand All @@ -168,7 +168,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
'text-xs text-white/30 italic'
)}
>
<LinkIcon className="size-3 opacity-50" />
<LinkIcon className="creator-action-icon opacity-50" />
<span>No public handle</span>
</div>
)}
Expand All @@ -191,7 +191,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
<CardMetaRow
label={
<span className="inline-flex items-center gap-1.5">
<LinkIcon className="size-3 text-amber-500/70" />
<LinkIcon className="creator-action-icon text-amber-500/70" />
Handle
</span>
}
Expand Down Expand Up @@ -249,7 +249,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
{transactionState === 'failed' && (
<TransactionStatusIcon status="failed" />
)}
<ShoppingCart className="size-4" />
<ShoppingCart className="creator-action-icon" />
{transactionState === 'submitting'
? 'Processing...'
: transactionState === 'success'
Expand Down
17 changes: 17 additions & 0 deletions src/components/common/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ interface SearchBarProps {
placeholder?: string;
className?: string;
validationMessage?: string;
isLoading?: boolean;
}

const SearchBar: React.FC<SearchBarProps> = ({
Expand All @@ -16,7 +17,23 @@ const SearchBar: React.FC<SearchBarProps> = ({
placeholder = 'Search creators by name or handle...',
className,
validationMessage,
isLoading = false,
}) => {
if (isLoading) {
return (
<div className={cn('w-full max-w-md', className)}>
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<div className="size-5 rounded bg-white/20 skeleton-shimmer" />
</div>
<div className="block w-full rounded-xl border border-white/10 bg-white/5 py-3 pl-10 pr-3">
<div className="h-5 w-48 rounded bg-white/20 skeleton-shimmer" />
</div>
</div>
</div>
);
}

return (
<div className={cn('w-full max-w-md', className)}>
<div className="relative">
Expand Down
87 changes: 87 additions & 0 deletions src/hooks/useScrollPreservation.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const restoreTimeoutRef = useRef<NodeJS.Timeout | null>(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]);
}
6 changes: 6 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
34 changes: 34 additions & 0 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -140,6 +141,7 @@ function LandingPage() {
const [creators, setCreators] = useState<Course[]>([]);
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';
Expand Down Expand Up @@ -171,6 +173,13 @@ function LandingPage() {
});
const pendingScrollRestoreRef = useRef<number | null>(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
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -416,6 +437,7 @@ function LandingPage() {
value={searchQuery}
onChange={setSearchQuery}
validationMessage={searchValidationMessage}
isLoading={isLoading}
className="max-w-none shadow-2xl shadow-black/20"
/>
<div className="flex items-center gap-3">
Expand Down Expand Up @@ -455,6 +477,18 @@ function LandingPage() {

{isLoading ? (
<CreatorGridSkeleton count={6} />
) : isFilterLoading ? (
<div className="space-y-4">
<div className="flex items-center justify-center gap-2 py-8">
<div className="size-4 animate-spin rounded-full border-2 border-amber-400/20 border-t-amber-400" />
<span className="text-sm text-white/60">Updating results...</span>
</div>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3 opacity-50">
{pagedCreators.map(creator => (
<CreatorCard key={creator.id} creator={creator} />
))}
</div>
</div>
) : filteredCreators.length > 0 ? (
<div className="space-y-4">
{showRetryBanner && (
Expand Down
Loading