From a3da0b9ae6fa7319b55070d368e61af3901edfc6 Mon Sep 17 00:00:00 2001 From: muokwejosh-cloud Date: Fri, 16 Oct 2026 12:54:15 +0100 Subject: [PATCH] feat(ui): add unified data state UI system for loading skeletons and empty states (#545) --- .../common/ClearedFiltersEmptyState.tsx | 53 ++++++----------- src/components/common/CreatorCardSkeleton.tsx | 12 ++-- src/components/common/CreatorSkeleton.tsx | 2 +- src/components/common/EmptyState.tsx | 57 +++++++++++++++--- src/components/common/HoldingsEmptyState.tsx | 58 ++++++------------- 5 files changed, 95 insertions(+), 87 deletions(-) diff --git a/src/components/common/ClearedFiltersEmptyState.tsx b/src/components/common/ClearedFiltersEmptyState.tsx index c88cd0e7..f9088c7b 100644 --- a/src/components/common/ClearedFiltersEmptyState.tsx +++ b/src/components/common/ClearedFiltersEmptyState.tsx @@ -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 { /** @@ -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 = ({ onBrowseAll, className, }) => ( -
-
-
+ className={cn('w-full', className)} + icon={ -
- -

- Nothing here yet -

-

- Your filters are cleared, but no creators are available right now. - Check back soon or try browsing all categories. -

- - {onBrowseAll && ( - - )} -
+ } + 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; diff --git a/src/components/common/CreatorCardSkeleton.tsx b/src/components/common/CreatorCardSkeleton.tsx index e32f67d3..a9ce2284 100644 --- a/src/components/common/CreatorCardSkeleton.tsx +++ b/src/components/common/CreatorCardSkeleton.tsx @@ -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: @@ -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 @@ -122,7 +122,7 @@ const CreatorCardSkeleton: React.FC = ({ {/* Divider (CreatorListRowDivider equivalent) */}
- {/* Meta rows: Join Date / Handle / Key Price */} + {/* Meta rows: Join Date / Handle / Key Price / Holders */}
@@ -136,6 +136,10 @@ const CreatorCardSkeleton: React.FC = ({
+
+
+
+
{/* Divider */} diff --git a/src/components/common/CreatorSkeleton.tsx b/src/components/common/CreatorSkeleton.tsx index 9d2a6b81..7c51d1ed 100644 --- a/src/components/common/CreatorSkeleton.tsx +++ b/src/components/common/CreatorSkeleton.tsx @@ -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'; diff --git a/src/components/common/EmptyState.tsx b/src/components/common/EmptyState.tsx index e448a3e6..2216e662 100644 --- a/src/components/common/EmptyState.tsx +++ b/src/components/common/EmptyState.tsx @@ -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 { @@ -13,36 +14,61 @@ 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 = ({ 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 (
= ({ '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 ? (
= ({ aria-hidden="true" />
- )} + ) : icon ? ( +
+
+ {icon} +
+ ) : null} + {displayTitle && (

{displayTitle}

)} + {displayMessage !== undefined && ( -

+

{displayMessage}

)} @@ -90,6 +129,7 @@ const EmptyState: React.FC = ({ 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} +
); }; export default EmptyState; - diff --git a/src/components/common/HoldingsEmptyState.tsx b/src/components/common/HoldingsEmptyState.tsx index e0254e31..0a475a0a 100644 --- a/src/components/common/HoldingsEmptyState.tsx +++ b/src/components/common/HoldingsEmptyState.tsx @@ -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 = ({ className, browseHref = '/creators', }) => ( -
-
-
+ className={cn('mt-6', className)} + icon={ -
- -

- No creator keys yet -

-

- This wallet doesn't hold any creator keys right now. Browse - creators and secure your first key. -

- - - Browse creators -
+ } + 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;