diff --git a/src/components/common/CreatorCardSkeleton.tsx b/src/components/common/CreatorCardSkeleton.tsx new file mode 100644 index 0000000..e32f67d --- /dev/null +++ b/src/components/common/CreatorCardSkeleton.tsx @@ -0,0 +1,196 @@ +import { cn } from '@/lib/utils'; +import { CREATOR_CARD_MEDIA_RADIUS_CLASS } from '@/utils/creatorCardTokens'; + +interface CreatorCardSkeletonProps { + className?: string; + /** + * Disable the shimmer animation and render a static block instead. + * Mirrors `CreatorSkeleton`'s API so callers that already suppress + * shimmer at a higher level can opt out per-instance. + */ + disableShimmer?: boolean; +} + +const skeletonBlockClass = + 'rounded-md bg-white/12 skeleton-shimmer motion-reduce:bg-white/18 motion-reduce:ring-1 motion-reduce:ring-white/15'; +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). + * + * Block dimensions mirror `CreatorCard`'s populated layout so replacing + * skeletons with real cards does not produce visible layout shift: + * - Avatar: aspect-square (matches CreatorCard avatar block) + * - Title row + badges: h-6 name with inline badge placeholders + * (verified, change, supply, recent-activity) + * - Handle: h-4 (matches CreatorCard's `.marketplace-label-muted` + * handle line) + * - Bio: two-line placeholder (matches CreatorCard's `CreatorBio` + * block under the handle) + * - 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) + * separated by `CreatorListRowDivider` equivalents + * - Social links: row of icon placeholders matching + * `CreatorSocialLinksList` height + * - Action row: NetworkFeeHint placeholder + h-9 w-24 rounded-xl + * Buy Key button placeholder + * - Helper text bar (matches `BuyActionHelperText` height) + * + * The top-right dropdown-menu trigger is preserved as a circular block + * so the card has the same absolute-pos landmark in loading state. + * + * Respects `prefers-reduced-motion` via the shared `.skeleton-shimmer` + * CSS rule which falls back to a static block + ring when motion is + * reduced. Callers can pass `disableShimmer` to suppress the animation + * outright. + */ +const CreatorCardSkeleton: React.FC = ({ + className, + disableShimmer = false, +}) => { + const blockClass = disableShimmer ? skeletonStaticBlockClass : skeletonBlockClass; + + return ( +
+ Loading creator card + + {/* + * Top-right dropdown trigger placeholder. CreatorCard + * absolutely-positions a size-8 trigger at right-3 top-3, + * so the skeleton matches that footprint to avoid the + * sibling controls shifting when real cards render. + */} +
+
+
+ + {/* Avatar block */} +
+ +
+ {/* Title row: name + verified + change + supply badges */} +
+
+
+
+
+
+ + {/* Handle line (marketplace-label-muted) */} +
+ + {/* Bio — two short lines */} +
+
+
+
+ + {/* Sparkline placeholder (matches CreatorCard's price chart placeholder) */} +
+ + + {/* Mini stat chips (Price / Category / Level) */} +
+
+
+
+
+
+ + {/* Divider (CreatorListRowDivider equivalent) */} +
+ + {/* Meta rows: Join Date / Handle / Key Price */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Divider */} +
+ + {/* Social links row */} +
+
+
+
+
+
+ + {/* + * Action row: NetworkFeeHint + Buy Key button placeholders. + * Widths (w-24) mirror `CreatorSkeleton`'s convention so the + * skeleton matches CreatorCard's h-9 "Buy Key" button plus + * the compact `.font-mono text-[9px]` NetworkFeeHint chip. + */} +
+
+
+
+ + {/* Helper text bar (matches BuyActionHelperText height) */} +
+
+
+
+ ); +}; + +/** + * Grid of creator card skeletons shown while the creator list is + * loading (#421). Defaults to `count = 6` so the placeholder grid + * matches the live first-page footprint on `LandingPage`. + */ +export const CreatorCardGridSkeleton: React.FC<{ + count?: number; + disableShimmer?: boolean; + className?: string; +}> = ({ count = 6, disableShimmer = false, className }) => { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); +}; + +export default CreatorCardSkeleton; diff --git a/src/components/common/__tests__/CreatorCardSkeleton.test.tsx b/src/components/common/__tests__/CreatorCardSkeleton.test.tsx new file mode 100644 index 0000000..8612dca --- /dev/null +++ b/src/components/common/__tests__/CreatorCardSkeleton.test.tsx @@ -0,0 +1,92 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import CreatorCardSkeleton, { + CreatorCardGridSkeleton, +} from '../CreatorCardSkeleton'; + +describe('CreatorCardSkeleton', () => { + it('renders shimmer blocks for the loading affordance', () => { + const { container, getByRole, getAllByTestId } = render( + + ); + + // role="status" announces loading to assistive tech. + expect( + getByRole('status', { name: 'Loading creator card' }) + ).toBeInTheDocument(); + + // Sanity-check that the card test id is exposed so other + // components / tests can target a single placeholder card. + expect(getAllByTestId('creator-card-skeleton')).toHaveLength(1); + + // Animated shimmer is present by default — many blocks are + // expected because CreatorCardSkeleton mirrors the full card + // (avatar, title, badges, handle, bio, sparkline, chips, meta + // rows, social links, action row, helper text). + const shimmerBlocks = container.querySelectorAll('.skeleton-shimmer'); + expect(shimmerBlocks.length).toBeGreaterThanOrEqual(10); + }); + + it('disables the shimmer with `disableShimmer` and falls back to the static block', () => { + const { container } = render(); + + expect(container.querySelectorAll('.skeleton-shimmer')).toHaveLength(0); + expect( + container.querySelectorAll('.ring-white\\/15').length + ).toBeGreaterThanOrEqual(10); + }); + + it('merges additional className onto the card surface', () => { + const { container } = render( + + ); + + const card = container.querySelector('[data-testid="creator-card-skeleton"]'); + expect(card).not.toBeNull(); + expect(card).toHaveClass('custom-class'); + expect(card).toHaveClass('rounded-2xl'); + }); +}); + +describe('CreatorCardGridSkeleton', () => { + it('renders 6 skeletons by default (#421 acceptance criterion)', () => { + const { container, getAllByTestId, getByTestId } = render( + + ); + + expect( + getByTestId('creator-card-grid-skeleton') + ).toBeInTheDocument(); + expect(getAllByTestId('creator-card-skeleton')).toHaveLength(6); + // Each card contributes at least 10 shimmer blocks, so the grid + // should expose 60+ shimmer nodes. + const shimmerBlocks = container.querySelectorAll('.skeleton-shimmer'); + expect(shimmerBlocks.length).toBeGreaterThanOrEqual(60); + }); + + it('renders the requested number of cards', () => { + const { getAllByTestId } = render(); + expect(getAllByTestId('creator-card-skeleton')).toHaveLength(3); + }); + + it('propagates `disableShimmer` to every card in the grid', () => { + const { container, getAllByTestId } = render( + + ); + + expect(getAllByTestId('creator-card-skeleton')).toHaveLength(2); + expect(container.querySelectorAll('.skeleton-shimmer')).toHaveLength(0); + expect( + container.querySelectorAll('.ring-white\\/15').length + ).toBeGreaterThanOrEqual(20); + }); + + it('applies extra className to the grid wrapper', () => { + const { getByTestId } = render( + + ); + const grid = getByTestId('creator-card-grid-skeleton'); + expect(grid).toHaveClass('extra-grid-class'); + expect(grid).toHaveClass('grid-cols-1'); + }); +}); diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index b61b52c..121daec 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -8,10 +8,10 @@ import SearchBar from '@/components/common/SearchBar'; import StickyFilterBar from '@/components/common/StickyFilterBar'; import CreatorCard from '@/components/common/CreatorCard'; import { - CreatorGridSkeleton, CreatorHoldingsListSkeleton, CreatorProfileHeaderSkeleton, } from '@/components/common/CreatorSkeleton'; +import { CreatorCardGridSkeleton } from '@/components/common/CreatorCardSkeleton'; import EmptyState from '@/components/common/EmptyState'; import EmptySearchSuggestions from '@/components/common/EmptySearchSuggestions'; import SectionDivider from '@/components/common/SectionDivider'; @@ -859,7 +859,11 @@ function LandingPage() { /> {isLoading ? ( - + // #421: replace the generic grid skeleton with + // CreatorCardGridSkeleton so each placeholder + // mirrors CreatorCard's dimensions and prevents + // layout shift when real cards arrive. + ) : isFilterLoading ? (