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

interface ClearedFiltersEmptyStateProps {
/**
Expand All @@ -13,51 +14,35 @@ interface ClearedFiltersEmptyStateProps {

/**
* 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.
* empty (#545). Standardised on top of the unified EmptyState component.
*/
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
)}
<EmptyState
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" />
className={cn('w-full', className)}
icon={
<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>
}
title="Nothing here yet"
message="Your filters are cleared, but no creators are available right now. Check back soon or try browsing all categories."
cta={
onBrowseAll
? {
label: 'Browse all creators',
onClick: onBrowseAll,
}
: undefined
}
/>
);

export default ClearedFiltersEmptyState;
12 changes: 8 additions & 4 deletions src/components/common/CreatorCardSkeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ interface CreatorCardSkeletonProps {
}

const skeletonBlockClass =
'rounded-md bg-white/12 skeleton-shimmer motion-reduce:bg-white/18 motion-reduce:ring-1 motion-reduce:ring-white/15';
'rounded-md bg-white/12 skeleton-shimmer animate-pulse motion-reduce:bg-white/18 motion-reduce:ring-1 motion-reduce:ring-white/15 motion-reduce:animate-none';
const skeletonStaticBlockClass =
'rounded-md bg-white/16 ring-1 ring-white/15';
const dividerClass = 'h-px w-full bg-white/10';

/**
* Loading skeleton for a single creator card (#421).
* Loading skeleton for a single creator card (#421, #545).
*
* Block dimensions mirror `CreatorCard`'s populated layout so replacing
* skeletons with real cards does not produce visible layout shift:
Expand All @@ -32,7 +32,7 @@ const dividerClass = 'h-px w-full bg-white/10';
* - Sparkline: h-10 w-full rounded-lg (matches CreatorCard's price
* chart sparkline placeholder)
* - Mini stat chips: 3 placeholders (Price / Category / Level)
* - Meta rows: 3 label+value rows (Join Date / Handle / Key Price)
* - Meta rows: 4 label+value rows (Join Date / Handle / Key Price / Holders)
* separated by `CreatorListRowDivider` equivalents
* - Social links: row of icon placeholders matching
* `CreatorSocialLinksList` height
Expand Down Expand Up @@ -122,7 +122,7 @@ const CreatorCardSkeleton: React.FC<CreatorCardSkeletonProps> = ({
{/* Divider (CreatorListRowDivider equivalent) */}
<div className={cn('my-4', dividerClass)} />

{/* Meta rows: Join Date / Handle / Key Price */}
{/* Meta rows: Join Date / Handle / Key Price / Holders */}
<div className="mt-3 space-y-2">
<div className="flex items-center justify-between gap-3">
<div className={cn('h-3 w-20', blockClass)} />
Expand All @@ -136,6 +136,10 @@ const CreatorCardSkeleton: React.FC<CreatorCardSkeletonProps> = ({
<div className={cn('h-3 w-16', blockClass)} />
<div className={cn('h-6 w-24', blockClass)} />
</div>
<div className="flex items-center justify-between gap-3">
<div className={cn('h-3 w-16', blockClass)} />
<div className={cn('h-3 w-20', blockClass)} />
</div>
</div>

{/* Divider */}
Expand Down
2 changes: 1 addition & 1 deletion src/components/common/CreatorSkeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ interface CreatorSkeletonProps {
}

const skeletonBlockClass =
'rounded-md bg-white/12 skeleton-shimmer motion-reduce:bg-white/18 motion-reduce:ring-1 motion-reduce:ring-white/15';
'rounded-md bg-white/12 skeleton-shimmer animate-pulse motion-reduce:bg-white/18 motion-reduce:ring-1 motion-reduce:ring-white/15 motion-reduce:animate-none';
const skeletonStaticBlockClass =
'rounded-md bg-white/16 ring-1 ring-white/15';

Expand Down
57 changes: 49 additions & 8 deletions src/components/common/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from 'react';
import { Link } from 'react-router';
import { ArrowRight, RotateCcw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { RotateCcw } from 'lucide-react';
import { EMPTY_STATE_ILLUSTRATION_SIZES } from './emptyStateIllustration.config';

export interface EmptyStateCTAConfig {
Expand All @@ -13,47 +14,73 @@ export interface EmptyStateCTAConfig {

export interface EmptyStateProps {
image?: string;
icon?: React.ReactNode;
title?: string;
description?: string;
message?: string;
cta?: EmptyStateCTAConfig | string;
ctaHref?: string;
ctaLabel?: string;
ctaText?: string;
onCtaClick?: () => void;
className?: string;
onReset?: () => void;
'data-testid'?: string;
role?: string;
'aria-label'?: string;
children?: React.ReactNode;
}

/**
* Unified empty-state component (#545).
* Used across holdings, creator list, marketplace, and other list pages.
* Displays an optional icon/illustration, title, descriptive message,
* and an optional call-to-action button or link.
*/
const EmptyState: React.FC<EmptyStateProps> = ({
image,
icon,
title,
description,
message,
cta,
ctaHref,
ctaLabel: explicitCtaLabel,
ctaText: explicitCtaText,
onCtaClick,
className,
onReset,
'data-testid': testId,
role = 'status',
'aria-label': ariaLabel,
children,
}: EmptyStateProps) => {
const displayTitle = title;
const displayMessage = message ?? description;
const ariaLabelText = title || message || description || 'Empty state';
const ariaLabelText =
ariaLabel || title || message || description || 'Empty state';

const ctaObject = typeof cta === 'object' ? cta : undefined;
const ctaLabel =
explicitCtaLabel ??
explicitCtaText ??
ctaObject?.label ??
ctaObject?.text ??
(typeof cta === 'string' ? cta : undefined);
const resolvedHref = ctaObject?.href ?? ctaHref;
const resolvedOnClick = ctaObject?.onClick;
const resolvedOnClick = ctaObject?.onClick ?? onCtaClick;

return (
<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"
role={role}
aria-label={ariaLabelText}
data-testid={testId}
>
{image && (
{image ? (
<div
className={cn(
'relative mb-6 flex items-center justify-center',
Expand All @@ -71,14 +98,26 @@ const EmptyState: React.FC<EmptyStateProps> = ({
aria-hidden="true"
/>
</div>
)}
) : icon ? (
<div
className={cn(
'relative mb-6 flex items-center justify-center',
EMPTY_STATE_ILLUSTRATION_SIZES.heroFrame
)}
>
<div className="absolute inset-0 size-full rounded-full bg-amber-500/10 blur-2xl" />
{icon}
</div>
) : null}

{displayTitle && (
<h2 className="font-grotesque text-2xl font-black tracking-tight text-white mb-2">
{displayTitle}
</h2>
)}

{displayMessage !== undefined && (
<p className="max-w-[280px] font-jakarta text-sm leading-relaxed text-white/50 mb-8">
<p className="max-w-[300px] font-jakarta text-sm leading-relaxed text-white/50 mb-8">
{displayMessage}
</p>
)}
Expand All @@ -90,6 +129,7 @@ const EmptyState: React.FC<EmptyStateProps> = ({
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/5 px-6 py-2.5 text-sm font-bold text-white transition-all hover:border-amber-500/30 hover:bg-amber-500/10"
>
{ctaLabel}
<ArrowRight className="ml-2 size-4" aria-hidden="true" />
</Link>
) : ctaLabel ? (
<Button
Expand All @@ -111,9 +151,10 @@ const EmptyState: React.FC<EmptyStateProps> = ({
Reset Search
</Button>
) : null}

{children}
</div>
);
};

export default EmptyState;

58 changes: 18 additions & 40 deletions src/components/common/HoldingsEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -1,63 +1,41 @@
import { ArrowRight, KeyRound } from 'lucide-react';
import { Link } from 'react-router';
import React from 'react';
import { KeyRound } from 'lucide-react';
import EmptyState from '@/components/common/EmptyState';
import { cn } from '@/lib/utils';
import { EMPTY_STATE_ILLUSTRATION_SIZES } from './emptyStateIllustration.config';

interface HoldingsEmptyStateProps {
export interface HoldingsEmptyStateProps {
className?: string;
/** Creator discovery path — defaults to marketplace creators route. */
browseHref?: string;
}

/**
* Shown when the holdings query has settled with zero creator keys.
* Distinct from loading (skeleton) and from marketplace search empty states.
* Shown when the holdings query has settled with zero creator keys (#545).
* Standardised on top of the unified EmptyState component.
* Links the CTA to the creator discovery page.
*/
const HoldingsEmptyState: React.FC<HoldingsEmptyStateProps> = ({
className,
browseHref = '/creators',
}) => (
<div
className={cn(
'mt-6 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 holdings"
<EmptyState
data-testid="holdings-empty-state"
>
<div
className={cn(
'relative mb-6 flex items-center justify-center',
EMPTY_STATE_ILLUSTRATION_SIZES.heroFrame
)}
>
<div className="absolute inset-0 size-full rounded-full bg-amber-500/10 blur-2xl" />
className={cn('mt-6', className)}
icon={
<span className="relative z-10 flex size-16 items-center justify-center rounded-full border border-white/10 bg-white/5 sm:size-20">
<KeyRound
className="size-7 text-white/40 sm:size-8"
aria-hidden="true"
/>
</span>
</div>

<h2 className="mb-2 font-grotesque text-2xl font-black tracking-tight text-white">
No creator keys yet
</h2>
<p className="mb-8 max-w-[300px] font-jakarta text-sm leading-relaxed text-white/50">
This wallet doesn&apos;t hold any creator keys right now. Browse
creators and secure your first key.
</p>

<Link
to={browseHref}
aria-label="Browse creators"
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/5 px-6 py-2.5 text-sm font-bold text-white transition-all hover:border-amber-500/30 hover:bg-amber-500/10"
>
Browse creators
<ArrowRight className="ml-2 size-4" aria-hidden="true" />
</Link>
</div>
}
title="No creator keys yet"
message="This wallet doesn't hold any creator keys right now. Browse creators and secure your first key."
cta={{
label: 'Browse creators',
href: browseHref,
}}
/>
);

export default HoldingsEmptyState;
Loading