Skip to content
Open
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
9 changes: 7 additions & 2 deletions src/app/(app)/creators/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,12 @@ export default function CreatorProfilePage(): JSX.Element {
)}

{/* Recent Tips */}
{history.transactions.length > 0 && (
{history.loading && history.transactions.length === 0 ? (
<section>
<h2 className="text-xl font-bold mb-6">Recent Tips</h2>
<TransactionTableSkeleton />
</section>
) : history.transactions.length > 0 ? (
<section>
<h2 className="text-xl font-bold mb-6">Recent Tips</h2>
<div className="border rounded-lg overflow-hidden">
Expand Down Expand Up @@ -389,7 +394,7 @@ export default function CreatorProfilePage(): JSX.Element {
</div>
)}
</section>
)}
) : null}

{history.transactions.length === 0 && !history.loading && (
<section className="text-center py-12">
Expand Down
7 changes: 7 additions & 0 deletions src/components/sections/activity-feed.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ describe('ActivityFeed', () => {
expect(screen.getByText('No recent activity')).toBeInTheDocument();
});

it('renders ActivityFeedSkeleton when loading and items are empty', () => {
render(<ActivityFeed items={[]} loading={true} />);

expect(screen.getByRole('status', { name: /loading activity feed/i })).toBeInTheDocument();
expect(screen.queryByText('No recent activity')).not.toBeInTheDocument();
});

it('filters items by type', () => {
render(<ActivityFeed items={mockItems} filters={{ type: 'tip' }} />);

Expand Down
5 changes: 4 additions & 1 deletion src/components/sections/activity-feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
ChevronDown,
Loader2,
} from 'lucide-react';
import { ActivityFeedSkeleton } from '@/components/shared/creator-skeletons';

export interface ActivityFeedProps {
items: ActivityFeedItem[];
Expand Down Expand Up @@ -126,7 +127,9 @@ export function ActivityFeed({
</div>

{/* Activity List */}
{filteredItems.length === 0 && !loading ? (
{loading && filteredItems.length === 0 ? (
<ActivityFeedSkeleton />
) : filteredItems.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">No recent activity</p>
) : (
<div className="space-y-3">
Expand Down
3 changes: 3 additions & 0 deletions src/components/sections/creator-collaborations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ export function CreatorCollaborations({

useEffect(() => {
let active = true;
if (typeof client?.getCreatorProfile !== 'function') {
return;
}
client
.getCreatorProfile(username)
.then((profile) => {
Expand Down
3 changes: 3 additions & 0 deletions src/components/sections/creator-commission-requests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export function CreatorCommissionRequests({

useEffect(() => {
let active = true;
if (typeof client?.getCreatorProfile !== 'function') {
return;
}
client
.getCreatorProfile(username)
.then((profile) => {
Expand Down
7 changes: 7 additions & 0 deletions src/components/sections/top-tippers-table.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TopTippersTable data={[]} loading={true} />);

expect(screen.getByRole('status', { name: /loading top tippers/i })).toBeInTheDocument();
expect(screen.queryByText(/no tippers for this period/i)).not.toBeInTheDocument();
});
});
8 changes: 7 additions & 1 deletion src/components/sections/top-tippers-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <TopTippersTableSkeleton />;
}

if (data.length === 0) {
return (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
Expand Down
2 changes: 1 addition & 1 deletion src/components/sections/transaction-filter-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<Download className="h-4 w-4" aria-hidden="true" />
CSV ({resultCount})
Export CSV ({resultCount})
</button>
{onExportExcel && (
<button
Expand Down
47 changes: 47 additions & 0 deletions src/components/shared/creator-skeletons.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
EarningsCardSkeleton,
ProfileHeaderSkeleton,
TransactionTableSkeleton,
TopTippersTableSkeleton,
ActivityFeedSkeleton,
} from './creator-skeletons';

describe('ProfileHeaderSkeleton (#8)', () => {
Expand Down Expand Up @@ -77,3 +79,48 @@ describe('TransactionTableSkeleton (#8)', () => {
});
});
});

describe('TopTippersTableSkeleton (#163)', () => {
it('is accessible as a status region', () => {
render(<TopTippersTableSkeleton />);
expect(screen.getByRole('status', { name: /loading top tippers/i })).toBeInTheDocument();
});

it('renders column headers matching TopTippersTable layout', () => {
render(<TopTippersTableSkeleton />);
expect(screen.getByText('Rank')).toBeInTheDocument();
expect(screen.getByText('Tipper')).toBeInTheDocument();
expect(screen.getByText('Total Tipped')).toBeInTheDocument();
expect(screen.getByText('Tips')).toBeInTheDocument();
expect(screen.getByText('Last Tip')).toBeInTheDocument();
expect(screen.getByText('Latest Message')).toBeInTheDocument();
});

it('renders default number of rows with skeleton blocks in each cell', () => {
const { container } = render(<TopTippersTableSkeleton rows={3} />);
const rows = container.querySelectorAll('tbody tr');
expect(rows).toHaveLength(3);
const cells = rows[0].querySelectorAll('td');
expect(cells).toHaveLength(6);
cells.forEach((cell) => {
expect(cell.querySelector('.animate-pulse')).toBeInTheDocument();
});
});
});

describe('ActivityFeedSkeleton (#163)', () => {
it('is accessible as a status region', () => {
render(<ActivityFeedSkeleton />);
expect(screen.getByRole('status', { name: /loading activity feed/i })).toBeInTheDocument();
});

it('renders specified number of item placeholders', () => {
const { container } = render(<ActivityFeedSkeleton count={4} />);
const items = container.querySelectorAll('[role="status"] > div');
expect(items).toHaveLength(4);
// each item has avatar circle + text placeholders
items.forEach((item) => {
expect(item.querySelector('.rounded-full')).toBeInTheDocument();
});
});
});
82 changes: 82 additions & 0 deletions src/components/shared/creator-skeletons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,85 @@ export function TransactionTableSkeleton({ rows = 5 }: { rows?: number }): JSX.E
</div>
);
}

/**
* 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 (
<div
aria-label="Loading top tippers"
className="border rounded-lg overflow-x-auto"
role="status"
>
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-semibold">Rank</th>
<th className="px-4 py-3 text-left font-semibold">Tipper</th>
<th className="px-4 py-3 text-left font-semibold">Total Tipped</th>
<th className="px-4 py-3 text-left font-semibold">Tips</th>
<th className="px-4 py-3 text-left font-semibold">Last Tip</th>
<th className="px-4 py-3 text-left font-semibold">Latest Message</th>
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<tr className="border-b" key={i}>
<td className="px-4 py-3">
<Skeleton className="h-4 w-6" />
</td>
<td className="px-4 py-3">
<Skeleton className="h-4 w-28" />
</td>
<td className="px-4 py-3">
<Skeleton className="h-4 w-20" />
</td>
<td className="px-4 py-3">
<Skeleton className="h-4 w-10" />
</td>
<td className="px-4 py-3">
<Skeleton className="h-4 w-20" />
</td>
<td className="px-4 py-3">
<Skeleton className="h-4 w-36" />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

/**
* Matches the activity feed item layout: circular icon/avatar + title + badge + description + timestamp.
*/
export function ActivityFeedSkeleton({ count = 3 }: { count?: number }): JSX.Element {
return (
<div
aria-label="Loading activity feed"
className="space-y-3"
role="status"
>
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="flex items-start gap-4 p-4 rounded-lg bg-muted/30 border border-muted"
>
<Skeleton className="w-10 h-10 shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16 rounded-full" />
</div>
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
<Skeleton className="h-3 w-16 shrink-0" />
</div>
))}
</div>
);
}
41 changes: 41 additions & 0 deletions src/components/shared/skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,44 @@ export function SkeletonGrid({ count = 6, cols = 3, ...props }: SkeletonGridProp
</div>
);
}

/**
* Skeleton List Item
* Reusable placeholder for avatar/icon + multi-line list rows
*/
export interface SkeletonListItemProps extends React.HTMLAttributes<HTMLDivElement> {
hasAvatar?: boolean;
}

export function SkeletonListItem({
hasAvatar = true,
className,
...props
}: SkeletonListItemProps): JSX.Element {
return (
<div
className={cn('flex items-center gap-4 p-4 rounded-lg border border-border bg-card', className)}
{...props}
>
{hasAvatar && <Skeleton className="h-10 w-10 shrink-0 rounded-full" />}
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-3/4" />
</div>
<Skeleton className="h-4 w-16 shrink-0" />
</div>
);
}

export function SkeletonList({
count = 4,
...props
}: { count?: number } & SkeletonListItemProps): JSX.Element {
return (
<div className="space-y-3" role="status" aria-label="Loading items">
{Array.from({ length: count }).map((_, i) => (
<SkeletonListItem key={i} {...props} />
))}
</div>
);
}
Loading