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/HoldingsEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -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<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"
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" />
<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>
);

export default HoldingsEmptyState;
12 changes: 11 additions & 1 deletion src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1184,6 +1185,11 @@ function LandingPage() {
</div>
{isLoading ? (
<CreatorHoldingsListSkeleton className="mt-6" />
) : heldKeyPositions.filter(
position => position.quantity && position.quantity > 0
).length === 0 ? (
// Settled empty only — skeleton covers loading so this never flashes.
<HoldingsEmptyState />
) : (
<div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{heldKeyPositions
Expand Down Expand Up @@ -1303,7 +1309,11 @@ function LandingPage() {
/>
<FeaturedCreatorAudienceChip
creatorId="featured-creator"
fetchHolderCount={() => Promise.resolve(FEATURED_CREATOR_KEY_HOLDER_COUNT)}
fetchHolderCount={() =>
Promise.resolve(
FEATURED_CREATOR_KEY_HOLDER_COUNT
)
}
/>
<MiniStatChip
label="Access"
Expand Down
177 changes: 177 additions & 0 deletions src/pages/__tests__/LandingPage.holdingsEmptyState.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* Holdings empty-state UI (#539).
*
* When the holdings query settles with zero creator keys, the holdings
* overview must show an empty state (illustration + copy + CTA) instead of
* a blank grid. Loading must keep the skeleton so the empty state never
* flashes mid-fetch.
*/
import type { ComponentProps, ReactNode } from 'react';
import { render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import LandingPage from '@/pages/LandingPage';
import { courseService } from '@/services/course.service';

vi.mock('@/services/course.service', () => ({
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(
<MemoryRouter>
<LandingPage />
</MemoryRouter>
);

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(
<MemoryRouter>
<LandingPage />
</MemoryRouter>
);

// 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();
});
});
Loading