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
63 changes: 63 additions & 0 deletions src/components/common/ClearedFiltersEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { SlidersHorizontal, ArrowRight } from 'lucide-react';

interface ClearedFiltersEmptyStateProps {
/**
* Called when the user clicks "Browse all creators".
* Typically resets sort to 'featured' and clears any residual state.
*/
onBrowseAll?: () => void;
className?: string;
}

/**
* Shown when all filters have been cleared but the creator list is still
* empty. Distinct from the search-no-results empty state — this state means
* the marketplace itself has no listings, not that a specific query failed.
*/
const ClearedFiltersEmptyState: React.FC<ClearedFiltersEmptyStateProps> = ({
onBrowseAll,
className,
}) => (
<div
className={cn(
'flex flex-col items-center justify-center rounded-[2rem] border border-white/10 bg-white/5 px-8 py-14 text-center backdrop-blur-xl',
className
)}
role="status"
aria-label="No creators available"
>
<div className="relative mb-6 flex size-20 items-center justify-center">
<div className="absolute inset-0 size-full rounded-full bg-amber-500/10 blur-2xl" />
<span className="relative z-10 flex size-16 items-center justify-center rounded-full border border-white/10 bg-white/5">
<SlidersHorizontal
className="size-7 text-white/40"
aria-hidden="true"
/>
</span>
</div>

<h2 className="font-grotesque text-2xl font-black tracking-tight text-white mb-2">
Nothing here yet
</h2>
<p className="max-w-[300px] font-jakarta text-sm leading-relaxed text-white/50 mb-8">
Your filters are cleared, but no creators are available right now.
Check back soon or try browsing all categories.
</p>

{onBrowseAll && (
<Button
onClick={onBrowseAll}
variant="outline"
aria-label="Browse all creators"
className="rounded-xl border-white/10 bg-white/5 px-6 font-bold text-white transition-all hover:border-amber-500/30 hover:bg-amber-500/10"
>
Browse all creators
<ArrowRight className="ml-2 size-4" aria-hidden="true" />
</Button>
)}
</div>
);

export default ClearedFiltersEmptyState;
74 changes: 73 additions & 1 deletion src/components/common/CreatorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@ import { useRef, useState } from 'react';
import { useAccount } from 'wagmi';
import type { Course } from '@/services/course.service';
import { cn } from '@/lib/utils';
import { ShoppingCart, Link as LinkIcon, TrendingUp } from 'lucide-react';
import { ShoppingCart, Link as LinkIcon, TrendingUp, MoreVertical, Copy, Share2, ExternalLink } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import RecentActivityBadge from '@/components/common/RecentActivityBadge';
import toast from 'react-hot-toast';
import showToast from '@/utils/toast.util';
import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils';
Expand Down Expand Up @@ -105,6 +114,28 @@ const CreatorCard: React.FC<CreatorCardProps> = ({
}, 1500);
};

const isRecentlyActive = (creator.volume24h ?? 0) > 0;

const handleCopyLink = () => {
const url = `${window.location.origin}/creator/${creator.id}`;
navigator.clipboard
.writeText(url)
.then(() => toast.success('Profile link copied'))
.catch(() => toast.error('Could not copy link'));
};

const handleShare = () => {
const url = `${window.location.origin}/creator/${creator.id}`;
if (navigator.share) {
navigator.share({ title: creator.title, url }).catch(() => {});
} else {
navigator.clipboard
.writeText(url)
.then(() => toast.success('Link copied to clipboard'))
.catch(() => toast.error('Could not share'));
}
};

const handleBuy = () => {
if (!isConnected) {
toast.error('Please connect your wallet to purchase keys', {
Expand Down Expand Up @@ -134,6 +165,46 @@ const CreatorCard: React.FC<CreatorCardProps> = ({
className
)}
>
<div className="absolute right-3 top-3 z-20">
<DropdownMenu>
<DropdownMenuTrigger
aria-label={`More actions for ${creator.title}`}
className="flex size-8 items-center justify-center rounded-full text-white/40 transition-colors hover:bg-white/10 hover:text-white/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/60"
>
<MoreVertical className="size-4" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48 border-white/10 bg-slate-900/95 backdrop-blur-xl"
>
<DropdownMenuLabel className="text-xs text-white/50">
{creator.title}
</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-white/10" />
<DropdownMenuItem
onSelect={handleCopyLink}
className="cursor-pointer gap-2 text-white/70 focus:bg-white/10 focus:text-white"
>
<Copy className="size-3.5" aria-hidden="true" />
Copy profile link
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleShare}
className="cursor-pointer gap-2 text-white/70 focus:bg-white/10 focus:text-white"
>
<Share2 className="size-3.5" aria-hidden="true" />
Share creator
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {}}
className="cursor-pointer gap-2 text-white/70 focus:bg-white/10 focus:text-white"
>
<ExternalLink className="size-3.5" aria-hidden="true" />
View profile
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div
className="relative mb-4 aspect-square overflow-hidden rounded-xl"
role="img"
Expand Down Expand Up @@ -176,6 +247,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({
/>
<Change24hBadge change={creator.change24h} />
<KeySupplyBadge supply={creator.creatorShareSupply} />
{isRecentlyActive && <RecentActivityBadge />}
</div>
<p className="marketplace-label-muted font-jakarta text-sm">
{displayInstructorHandle}
Expand Down
78 changes: 78 additions & 0 deletions src/components/common/CreatorListPagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight } from 'lucide-react';

interface CreatorListPaginationProps {
/** 0-indexed current page. */
page: number;
totalPages: number;
onPageChange: (page: number) => void;
className?: string;
}

const CreatorListPagination: React.FC<CreatorListPaginationProps> = ({
page,
totalPages,
onPageChange,
className,
}) => {
const displayPage = page + 1;

return (
<nav
aria-label="Creator list pagination"
className={cn('flex items-center justify-center gap-3', className)}
>
{/*
* Live region announces the new page to screen readers on every
* navigation. aria-atomic ensures the full "Page X of Y" phrase
* is read rather than just the changed number.
*/}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{`Page ${displayPage} of ${totalPages}`}
</div>

<Button
type="button"
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => onPageChange(Math.max(0, page - 1))}
aria-label="Go to previous page"
>
<ChevronLeft className="size-4" aria-hidden="true" />
<span aria-hidden="true">Previous</span>
</Button>

{/*
* aria-hidden suppresses the visual "Page X of Y" from screen
* readers — the sr-only live region above handles announcements
* to avoid duplicate readout.
*/}
<span
className="marketplace-label-muted text-xs tabular-nums"
aria-hidden="true"
>
Page {displayPage} of {totalPages}
</span>

<Button
type="button"
variant="outline"
size="sm"
disabled={page >= totalPages - 1}
onClick={() => onPageChange(Math.min(totalPages - 1, page + 1))}
aria-label="Go to next page"
>
<span aria-hidden="true">Next</span>
<ChevronRight className="size-4" aria-hidden="true" />
</Button>
</nav>
);
};

export default CreatorListPagination;
34 changes: 34 additions & 0 deletions src/components/common/RecentActivityBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { cn } from '@/lib/utils';

interface RecentActivityBadgeProps {
className?: string;
}

/**
* Visible indicator for creator cards with recent trading activity
* (volume24h > 0). Renders a pulsing green dot with an accessible label
* so screen readers understand its meaning.
*/
const RecentActivityBadge: React.FC<RecentActivityBadgeProps> = ({
className,
}) => (
<span
className={cn('inline-flex items-center gap-1.5', className)}
title="Recently active"
>
{/* Pulsing dot — decorative, announced via the sr-only sibling */}
<span
aria-hidden="true"
className="relative flex size-2 shrink-0"
>
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-60" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
</span>
<span className="text-[0.65rem] font-bold uppercase tracking-[0.18em] text-emerald-400">
Active
</span>
<span className="sr-only">Recently active</span>
</span>
);

export default RecentActivityBadge;
66 changes: 27 additions & 39 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import {
creatorCardEntryStyle,
} from '@/utils/cardEntryAnimation.utils';
import { AlertCircle, RefreshCw } from 'lucide-react';
import ClearedFiltersEmptyState from '@/components/common/ClearedFiltersEmptyState';
import CreatorListPagination from '@/components/common/CreatorListPagination';

const FEATURED_CREATOR_FACTS = [
{ label: 'Membership', value: 'Collectors Circle' },
Expand Down Expand Up @@ -641,35 +643,12 @@ function LandingPage() {
</div>
))}
</div>
<div className="mt-8 flex items-center justify-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
disabled={safePage === 0}
onClick={() =>
handlePageChange(Math.max(0, safePage - 1))
}
>
Previous
</Button>
<span className="marketplace-label-muted text-xs">
Page {safePage + 1} of {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={safePage >= totalPages - 1}
onClick={() =>
handlePageChange(
Math.min(totalPages - 1, safePage + 1)
)
}
>
Next
</Button>
</div>
<CreatorListPagination
page={safePage}
totalPages={totalPages}
onPageChange={handlePageChange}
className="mt-8"
/>
{safePage >= totalPages - 1 && (
<p
role="status"
Expand All @@ -682,18 +661,27 @@ function LandingPage() {
</div>
) : (
<div className="flex flex-col items-center gap-6 py-12">
<EmptyState
image="/images/no-results.png"
title="No creators found"
description={`We couldn't find any creators matching "${searchQuery}". Try a different name or handle.`}
onReset={handleResetSearch}
/>
{!hasInvalidSearchInput && (
<EmptySearchSuggestions
{trimmedSearchQuery.length === 0 ? (
<ClearedFiltersEmptyState
onBrowseAll={handleResetSearch}
className="w-full max-w-xl"
suggestions={searchSuggestions}
onSelect={setSearchQuery}
/>
) : (
<>
<EmptyState
image="/images/no-results.png"
title="No creators found"
description={`We couldn't find any creators matching "${searchQuery}". Try a different name or handle.`}
onReset={handleResetSearch}
/>
{!hasInvalidSearchInput && (
<EmptySearchSuggestions
className="w-full max-w-xl"
suggestions={searchSuggestions}
onSelect={setSearchQuery}
/>
)}
</>
)}
</div>
)}
Expand Down
Loading