diff --git a/src/components/common/HoldingsEmptyState.tsx b/src/components/common/HoldingsEmptyState.tsx new file mode 100644 index 0000000..e0254e3 --- /dev/null +++ b/src/components/common/HoldingsEmptyState.tsx @@ -0,0 +1,63 @@ +import { ArrowRight, KeyRound } from 'lucide-react'; +import { Link } from 'react-router'; +import { cn } from '@/lib/utils'; +import { EMPTY_STATE_ILLUSTRATION_SIZES } from './emptyStateIllustration.config'; + +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. + */ +const HoldingsEmptyState: React.FC = ({ + className, + browseHref = '/creators', +}) => ( +
+
+
+ + +
+ +

+ No creator keys yet +

+

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

+ + + Browse creators +
+); + +export default HoldingsEmptyState; diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 39d94bb..fb66cbb 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -14,6 +14,7 @@ import { } from '@/components/common/CreatorSkeleton'; import { CreatorCardGridSkeleton } from '@/components/common/CreatorCardSkeleton'; import EmptyState from '@/components/common/EmptyState'; +import HoldingsEmptyState from '@/components/common/HoldingsEmptyState'; import EmptySearchSuggestions from '@/components/common/EmptySearchSuggestions'; import SectionDivider from '@/components/common/SectionDivider'; import { Button } from '@/components/ui/button'; @@ -1184,6 +1185,11 @@ function LandingPage() {
{isLoading ? ( + ) : heldKeyPositions.filter( + position => position.quantity && position.quantity > 0 + ).length === 0 ? ( + // Settled empty only — skeleton covers loading so this never flashes. + ) : (
{heldKeyPositions @@ -1303,7 +1309,11 @@ function LandingPage() { /> Promise.resolve(FEATURED_CREATOR_KEY_HOLDER_COUNT)} + fetchHolderCount={() => + Promise.resolve( + FEATURED_CREATOR_KEY_HOLDER_COUNT + ) + } /> ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const getHoldingsOverviewSection = () => { + const heading = screen.getByRole('heading', { + name: 'Total portfolio value', + }); + const section = heading.closest( + '[aria-labelledby="holdings-overview-heading"]' + ); + expect(section).not.toBeNull(); + + return section as HTMLElement; +}; + +describe('LandingPage holdings empty state (#539)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + }); + + it('shows empty state with CTA after holdings query settles empty', async () => { + mockGetCourses.mockResolvedValue([]); + + render( + + + + ); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalled()); + + const empty = await screen.findByTestId('holdings-empty-state'); + expect(empty).toBeInTheDocument(); + expect( + within(getHoldingsOverviewSection()).getByRole('heading', { + name: 'No creator keys yet', + }) + ).toBeInTheDocument(); + expect( + within(getHoldingsOverviewSection()).getByRole('link', { + name: 'Browse creators', + }) + ).toHaveAttribute('href', '/creators'); + // no holding cards + expect( + within(getHoldingsOverviewSection()).queryAllByText(/\d+ keys ·/) + .length + ).toBe(0); + }); + + it('keeps skeleton during loading and does not flash empty state early', async () => { + let resolveCourses!: (value: never[]) => void; + mockGetCourses.mockImplementation( + () => + new Promise(resolve => { + resolveCourses = resolve; + }) + ); + + render( + + + + ); + + // While loading: empty state must not be present + expect( + screen.queryByTestId('holdings-empty-state') + ).not.toBeInTheDocument(); + + resolveCourses([]); + expect( + await screen.findByTestId('holdings-empty-state') + ).toBeInTheDocument(); + }); +});