diff --git a/src/app/(app)/creators/[username]/page.tsx b/src/app/(app)/creators/[username]/page.tsx index 8b64ff1..28855f1 100644 --- a/src/app/(app)/creators/[username]/page.tsx +++ b/src/app/(app)/creators/[username]/page.tsx @@ -322,7 +322,12 @@ export default function CreatorProfilePage(): JSX.Element { )} {/* Recent Tips */} - {history.transactions.length > 0 && ( + {history.loading && history.transactions.length === 0 ? ( +
+

Recent Tips

+ +
+ ) : history.transactions.length > 0 ? (

Recent Tips

@@ -389,7 +394,7 @@ export default function CreatorProfilePage(): JSX.Element {
)}
- )} + ) : null} {history.transactions.length === 0 && !history.loading && (
diff --git a/src/components/sections/activity-feed.test.tsx b/src/components/sections/activity-feed.test.tsx index 962509d..346becc 100644 --- a/src/components/sections/activity-feed.test.tsx +++ b/src/components/sections/activity-feed.test.tsx @@ -56,6 +56,13 @@ describe('ActivityFeed', () => { expect(screen.getByText('No recent activity')).toBeInTheDocument(); }); + it('renders ActivityFeedSkeleton when loading and items are empty', () => { + render(); + + expect(screen.getByRole('status', { name: /loading activity feed/i })).toBeInTheDocument(); + expect(screen.queryByText('No recent activity')).not.toBeInTheDocument(); + }); + it('filters items by type', () => { render(); diff --git a/src/components/sections/activity-feed.tsx b/src/components/sections/activity-feed.tsx index caca089..4892514 100644 --- a/src/components/sections/activity-feed.tsx +++ b/src/components/sections/activity-feed.tsx @@ -20,6 +20,7 @@ import { ChevronDown, Loader2, } from 'lucide-react'; +import { ActivityFeedSkeleton } from '@/components/shared/creator-skeletons'; export interface ActivityFeedProps { items: ActivityFeedItem[]; @@ -126,7 +127,9 @@ export function ActivityFeed({ {/* Activity List */} - {filteredItems.length === 0 && !loading ? ( + {loading && filteredItems.length === 0 ? ( + + ) : filteredItems.length === 0 ? (

No recent activity

) : (
diff --git a/src/components/sections/creator-collaborations.tsx b/src/components/sections/creator-collaborations.tsx index 8230987..14f3969 100644 --- a/src/components/sections/creator-collaborations.tsx +++ b/src/components/sections/creator-collaborations.tsx @@ -59,6 +59,9 @@ export function CreatorCollaborations({ useEffect(() => { let active = true; + if (typeof client?.getCreatorProfile !== 'function') { + return; + } client .getCreatorProfile(username) .then((profile) => { diff --git a/src/components/sections/creator-commission-requests.tsx b/src/components/sections/creator-commission-requests.tsx index d9e7607..4b7f6a6 100644 --- a/src/components/sections/creator-commission-requests.tsx +++ b/src/components/sections/creator-commission-requests.tsx @@ -41,6 +41,9 @@ export function CreatorCommissionRequests({ useEffect(() => { let active = true; + if (typeof client?.getCreatorProfile !== 'function') { + return; + } client .getCreatorProfile(username) .then((profile) => { diff --git a/src/components/sections/top-tippers-table.test.tsx b/src/components/sections/top-tippers-table.test.tsx index dbf4e5c..e236b50 100644 --- a/src/components/sections/top-tippers-table.test.tsx +++ b/src/components/sections/top-tippers-table.test.tsx @@ -46,4 +46,11 @@ describe('TopTippersTable', () => { expect(screen.queryByTestId('top-tippers-table')).not.toBeInTheDocument(); expect(screen.getByText(/no tippers for this period/i)).toBeInTheDocument(); }); + + it('renders skeleton placeholder when loading is true', () => { + render(); + + expect(screen.getByRole('status', { name: /loading top tippers/i })).toBeInTheDocument(); + expect(screen.queryByText(/no tippers for this period/i)).not.toBeInTheDocument(); + }); }); diff --git a/src/components/sections/top-tippers-table.tsx b/src/components/sections/top-tippers-table.tsx index 6359a70..8163795 100644 --- a/src/components/sections/top-tippers-table.tsx +++ b/src/components/sections/top-tippers-table.tsx @@ -5,12 +5,18 @@ import type { TopTipper } from '@/types'; import { formatCurrency, formatDate } from '@/utils/formatters'; +import { TopTippersTableSkeleton } from '@/components/shared/creator-skeletons'; export interface TopTippersTableProps { data: TopTipper[]; + loading?: boolean; } -export function TopTippersTable({ data }: TopTippersTableProps): JSX.Element { +export function TopTippersTable({ data, loading = false }: TopTippersTableProps): JSX.Element { + if (loading) { + return ; + } + if (data.length === 0) { return (
diff --git a/src/components/sections/transaction-filter-bar.tsx b/src/components/sections/transaction-filter-bar.tsx index 1c8c921..73a0935 100644 --- a/src/components/sections/transaction-filter-bar.tsx +++ b/src/components/sections/transaction-filter-bar.tsx @@ -181,7 +181,7 @@ export function TransactionFilterBar({ className="flex items-center gap-2 px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded hover:bg-primary/90 transition disabled:opacity-50 disabled:cursor-not-allowed" >
); } + +/** + * Matches the top tippers table: header row (Rank, Tipper, Total Tipped, Tips, Last Tip, Latest Message) + * plus `rows` body rows, one skeleton block per column. + */ +export function TopTippersTableSkeleton({ rows = 5 }: { rows?: number }): JSX.Element { + return ( +
+ + + + + + + + + + + + + {Array.from({ length: rows }).map((_, i) => ( + + + + + + + + + ))} + +
RankTipperTotal TippedTipsLast TipLatest Message
+ + + + + + + + + + + +
+
+ ); +} + +/** + * Matches the activity feed item layout: circular icon/avatar + title + badge + description + timestamp. + */ +export function ActivityFeedSkeleton({ count = 3 }: { count?: number }): JSX.Element { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+ +
+
+ + +
+ + +
+ +
+ ))} +
+ ); +} diff --git a/src/components/shared/skeleton.tsx b/src/components/shared/skeleton.tsx index 3edf129..ed7eb54 100644 --- a/src/components/shared/skeleton.tsx +++ b/src/components/shared/skeleton.tsx @@ -59,3 +59,44 @@ export function SkeletonGrid({ count = 6, cols = 3, ...props }: SkeletonGridProp
); } + +/** + * Skeleton List Item + * Reusable placeholder for avatar/icon + multi-line list rows + */ +export interface SkeletonListItemProps extends React.HTMLAttributes { + hasAvatar?: boolean; +} + +export function SkeletonListItem({ + hasAvatar = true, + className, + ...props +}: SkeletonListItemProps): JSX.Element { + return ( +
+ {hasAvatar && } +
+ + +
+ +
+ ); +} + +export function SkeletonList({ + count = 4, + ...props +}: { count?: number } & SkeletonListItemProps): JSX.Element { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); +} diff --git a/src/test/unit/loading-skeleton-screens.test.tsx b/src/test/unit/loading-skeleton-screens.test.tsx new file mode 100644 index 0000000..6da26b9 --- /dev/null +++ b/src/test/unit/loading-skeleton-screens.test.tsx @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SkeletonList, SkeletonListItem } from '@/components/shared/skeleton'; +import { TopTippersTable } from '@/components/sections/top-tippers-table'; +import { ActivityFeed } from '@/components/sections/activity-feed'; +import { + TopTippersTableSkeleton, + ActivityFeedSkeleton, + TransactionTableSkeleton, +} from '@/components/shared/creator-skeletons'; +import type { TopTipper, ActivityFeedItem } from '@/types'; + +describe('Loading Skeleton Screens (#163)', () => { + describe('Reusable SkeletonList and SkeletonListItem', () => { + it('renders a list of skeleton items with status role', () => { + render(); + const list = screen.getByRole('status', { name: /loading items/i }); + expect(list).toBeInTheDocument(); + expect(list.children).toHaveLength(3); + }); + + it('supports disabling avatar in skeleton list items', () => { + const { container } = render(); + expect(container.querySelector('.rounded-full')).not.toBeInTheDocument(); + }); + }); + + describe('ActivityFeed loading behavior', () => { + const sampleItems: ActivityFeedItem[] = [ + { + id: 'act-1', + type: 'tip', + creatorId: 'c1', + creatorName: 'Alice', + title: 'Tipped 20 XLM', + amount: 20, + createdAt: '2026-01-01T00:00:00Z', + }, + ]; + + it('shows skeleton while loading is true and data is empty', () => { + render(); + expect(screen.getByRole('status', { name: /loading activity feed/i })).toBeInTheDocument(); + expect(screen.queryByText('No recent activity')).not.toBeInTheDocument(); + }); + + it('removes skeleton and renders data when items arrive', () => { + const { rerender } = render(); + expect(screen.getByRole('status', { name: /loading activity feed/i })).toBeInTheDocument(); + + // Data arrives, loading completes + rerender(); + expect(screen.queryByRole('status', { name: /loading activity feed/i })).not.toBeInTheDocument(); + expect(screen.getByText('Tipped 20 XLM')).toBeInTheDocument(); + }); + }); + + describe('TopTippersTable loading behavior', () => { + const sampleTippers: TopTipper[] = [ + { + id: 'tip-1', + name: 'Bob', + totalAmount: 150, + tipCount: 5, + lastTipAt: '2026-01-02T00:00:00Z', + }, + ]; + + it('shows skeleton during data fetch', () => { + render(); + expect(screen.getByRole('status', { name: /loading top tippers/i })).toBeInTheDocument(); + expect(screen.queryByText(/no tippers for this period/i)).not.toBeInTheDocument(); + }); + + it('removes skeleton and displays rows once data arrives', () => { + const { rerender } = render(); + expect(screen.getByRole('status', { name: /loading top tippers/i })).toBeInTheDocument(); + + rerender(); + expect(screen.queryByRole('status', { name: /loading top tippers/i })).not.toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + }); + }); + + describe('TransactionTableSkeleton layout matching', () => { + it('visually resembles transaction table layout with all columns', () => { + render(); + expect(screen.getByRole('status', { name: /loading transaction history/i })).toBeInTheDocument(); + expect(screen.getByText('Date')).toBeInTheDocument(); + expect(screen.getByText('Amount')).toBeInTheDocument(); + expect(screen.getByText('From')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Tx ID')).toBeInTheDocument(); + }); + }); +});