From c3cb1b412222cf0202582058b0aa8b7fd9a16c65 Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Sun, 26 Jul 2026 20:26:34 +0100 Subject: [PATCH] feat: generic EmptyState CTA + wire zero-holdings empty state (closes #545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skeleton loading states (CreatorGridSkeleton, CreatorHoldingsListSkeleton, CreatorProfileHeaderSkeleton) already existed and were already wired for the creator list and holdings sections' isLoading branch. The genuine gap: once loading settled, a zero-result holdings list silently rendered an empty grid with no empty-state messaging or CTA. - Extended EmptyState with a generic `cta: {label, onClick}` prop alongside the existing `onReset` (kept for the search-reset caller; onReset takes precedence if somehow both are passed, so no existing behavior changes). - Wired an EmptyState into the holdings section for the zero-result case (after loading settles, before the populated grid renders), with a CTA that smooth-scrolls to the existing #main-creator-list section — this app doesn't have a separate discovery route to link to. - Skeleton and empty state remain mutually exclusive: the loading / empty / populated branches are an if/else-if/else chain. - Added src/components/common/__tests__/EmptyState.test.tsx covering title/description rendering, no-button-when-neither-prop, the legacy onReset button, the new generic cta button, and onReset taking precedence over cta. Co-Authored-By: Claude Sonnet 5 --- src/components/common/EmptyState.tsx | 19 +++++ .../common/__tests__/EmptyState.test.tsx | 75 +++++++++++++++++++ src/pages/LandingPage.tsx | 17 +++++ 3 files changed, 111 insertions(+) create mode 100644 src/components/common/__tests__/EmptyState.test.tsx diff --git a/src/components/common/EmptyState.tsx b/src/components/common/EmptyState.tsx index c1349413..82b4ee26 100644 --- a/src/components/common/EmptyState.tsx +++ b/src/components/common/EmptyState.tsx @@ -3,12 +3,20 @@ import { Button } from '@/components/ui/button'; import { RotateCcw } from 'lucide-react'; import { EMPTY_STATE_ILLUSTRATION_SIZES } from './emptyStateIllustration.config'; +interface EmptyStateCta { + label: string; + onClick: () => void; +} + interface EmptyStateProps { image: string; title: string; description: string; className?: string; + /** @deprecated Use `cta` — kept for the existing search-reset callers. */ onReset?: () => void; + /** Generic optional call-to-action, e.g. linking a zero-result list back to discovery. */ + cta?: EmptyStateCta; } const EmptyState: React.FC = ({ @@ -17,6 +25,7 @@ const EmptyState: React.FC = ({ description, className, onReset, + cta, }: EmptyStateProps) => { return (
= ({ Reset Search )} + + {!onReset && cta && ( + + )}
); }; diff --git a/src/components/common/__tests__/EmptyState.test.tsx b/src/components/common/__tests__/EmptyState.test.tsx new file mode 100644 index 00000000..f5744e87 --- /dev/null +++ b/src/components/common/__tests__/EmptyState.test.tsx @@ -0,0 +1,75 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import EmptyState from '../EmptyState'; + +describe('EmptyState', () => { + it('renders the title and description', () => { + render( + + ); + + expect(screen.getByText('No holdings yet')).toBeInTheDocument(); + expect( + screen.getByText("You don't currently hold any creator keys.") + ).toBeInTheDocument(); + }); + + it('renders no action button when neither onReset nor cta is supplied', () => { + render( + + ); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('renders the legacy reset button and calls onReset when clicked', () => { + const onReset = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /reset search results/i })); + expect(onReset).toHaveBeenCalledTimes(1); + }); + + it('renders a generic cta button and calls its onClick when clicked', () => { + const onClick = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Discover creators' })); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('prefers onReset over cta when both are somehow supplied', () => { + const onReset = vi.fn(); + const onClick = vi.fn(); + render( + + ); + + expect(screen.getByRole('button', { name: /reset search results/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Discover creators' })).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 9c26ce8b..220fd6ce 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -1078,6 +1078,23 @@ function LandingPage() { {isLoading ? ( + ) : heldKeyPositions.filter( + position => position.quantity && position.quantity > 0 + ).length === 0 ? ( + { + document + .getElementById('main-creator-list') + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, + }} + /> ) : (
{heldKeyPositions