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
3 changes: 2 additions & 1 deletion app/[communitySlug]/admin/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
EmptyState,
safeErrorMessage,
} from "@/components/ui/api-states";
import { AnalyticsSkeleton } from "@/components/admin/analytics-skeleton";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";

Expand Down Expand Up @@ -351,7 +352,7 @@ function AnalyticsContent() {
{isSessionExpired ? (
<SessionExpiredState />
) : isLoading ? (
<LoadingState message="Loading analytics…" />
<AnalyticsSkeleton />
) : isError && !(isApiError(error) && error.code === "aborted") ? (
<ErrorState
title="Error loading analytics"
Expand Down
3 changes: 2 additions & 1 deletion app/[communitySlug]/admin/rewards/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
LoadingState,
safeErrorMessage,
} from "@/components/ui/api-states";
import { RewardsSkeleton } from "@/components/admin/rewards-skeleton";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
Expand Down Expand Up @@ -205,7 +206,7 @@ function RewardsContent() {
{isSessionExpired ? (
<SessionExpiredState />
) : isLoading ? (
<LoadingState message="Loading rewards…" />
<RewardsSkeleton />
) : isError && !(isApiError(error) && error.code === "aborted") ? (
<ErrorState
title="Error loading rewards"
Expand Down
96 changes: 96 additions & 0 deletions components/admin/analytics-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

/**
* Skeleton placeholder for the Analytics admin page.
*
* Mirrors the layout of the loaded page — summary stat cards in a row,
* a membership growth chart, and a two-column grid of distribution bars.
*/
export function AnalyticsSkeleton() {
return (
<div
role="status"
aria-live="polite"
aria-busy="true"
className="space-y-6 p-4 sm:p-6"
>
<span className="sr-only">Loading analytics</span>

{/* Page header */}
<div className="flex flex-col gap-1">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-4 w-80" />
</div>

<Skeleton className="h-px w-full" />

{/* Stat cards row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{[0, 1, 2].map((i) => (
<Card key={i} aria-hidden="true">
<CardHeader className="pb-1">
<CardTitle>
<Skeleton className="h-4 w-20" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-3 w-28" />
</CardContent>
</Card>
))}
</div>

{/* Membership Growth Chart skeleton */}
<Card aria-hidden="true">
<CardHeader>
<CardTitle>
<Skeleton className="h-5 w-52" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-end gap-[6px]">
{[60, 80, 45, 70, 90, 55, 75, 65, 85, 50, 72, 62].map((h, i) => (
<Skeleton
key={i}
className="w-4 rounded-t"
style={{ height: `${h}px` }}
/>
))}
</div>
<Skeleton className="mt-3 h-3 w-40" />
</CardContent>
</Card>

{/* Two-column distribution grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{[0, 1].map((col) => (
<Card key={col} aria-hidden="true">
<CardHeader>
<CardTitle>
<Skeleton className="h-5 w-36" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{[0, 1, 2].map((row) => (
<div key={row} className="space-y-1">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-12" />
</div>
<Skeleton className="h-2 w-full rounded-full" />
</div>
))}
</CardContent>
</Card>
))}
</div>

{/* Generated-at footer */}
<div className="flex justify-end">
<Skeleton className="h-3 w-36" />
</div>
</div>
);
}
102 changes: 102 additions & 0 deletions components/admin/rewards-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

/**
* Skeleton placeholder for the Rewards admin page.
*
* Mirrors the layout of the loaded page — page header, deferred-features
* notice, member count header, and a list of member reward cards.
*/
export function RewardsSkeleton() {
return (
<div
role="status"
aria-live="polite"
aria-busy="true"
className="space-y-6 p-4 sm:p-6"
>
<span className="sr-only">Loading rewards</span>

{/* Page header */}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-5 w-14 rounded-full" />
</div>
<Skeleton className="h-4 w-3/4 max-w-lg" />
</div>

<Skeleton className="h-px w-full" />

{/* Deferred features notice */}
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3.5 dark:border-amber-900/30 dark:bg-amber-900/10">
<Skeleton className="mb-2 h-4 w-48" />
<div className="space-y-1">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-3 w-64" />
))}
</div>
</div>

{/* Member count header */}
<Skeleton className="h-6 w-36" />

{/* Member reward cards */}
<div className="space-y-3">
{[0, 1, 2].map((i) => (
<Card key={i} aria-hidden="true">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1 space-y-2">
<div className="flex items-baseline gap-2">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-3 w-52" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-5 w-14 rounded-full" />
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Roles section */}
<div>
<Skeleton className="mb-2 h-3 w-10" />
<div className="flex flex-wrap gap-2">
{[0, 1, 2].map((j) => (
<Skeleton key={j} className="h-5 w-16 rounded-full" />
))}
</div>
</div>

<Skeleton className="h-px w-full" />

{/* Deferred sections */}
<div className="space-y-1.5">
{[0, 1, 2, 3].map((k) => (
<div key={k} className="flex items-center gap-2">
<Skeleton className="h-1.5 w-1.5 rounded-full" />
<Skeleton className="h-3 w-64" />
</div>
))}
</div>
</div>
</CardContent>
</Card>
))}
</div>

{/* Implementation notes */}
<div className="mt-8 rounded-lg border border-border bg-muted/30 p-4">
<Skeleton className="mb-2 h-3 w-36" />
<div className="space-y-1">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-3 w-72" />
))}
</div>
</div>
</div>
);
}
35 changes: 35 additions & 0 deletions test/skeleton.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { Skeleton } from '../components/ui/skeleton'
import { MembershipCardSkeleton } from '../components/dashboard/membership-card-skeleton'
import { AnalyticsSkeleton } from '../components/admin/analytics-skeleton'
import { RewardsSkeleton } from '../components/admin/rewards-skeleton'

test('skeleton uses theme tokens and stays hidden from assistive technology', () => {
const html = renderToStaticMarkup(
Expand All @@ -30,3 +32,36 @@ test('membership skeleton reserves the loaded layout and exposes one loading sta
assert.match(html, /Loading membership details/)
assert.equal((html.match(/aria-hidden="true"/g) ?? []).length, 7)
})

test('analytics skeleton reserves the analytics layout and exposes one loading status', () => {
const html = renderToStaticMarkup(
React.createElement(AnalyticsSkeleton),
)

assert.match(html, /role="status"/)
assert.match(html, /aria-busy="true"/)
assert.match(html, /Loading analytics/)
// Should have stat cards (3 skeleton cards)
assert.equal((html.match(/class="[^"]*rounded-lg border[^"]*"/g) ?? []).length, 6)
// Should have skeleton bars inside each card
assert.ok(html.includes('h-9'))
assert.ok(html.includes('h-2'))
// All inner skeleton elements should be hidden from AT
assert.ok(html.includes('aria-hidden="true"'))
})

test('rewards skeleton reserves the rewards layout and exposes one loading status', () => {
const html = renderToStaticMarkup(
React.createElement(RewardsSkeleton),
)

assert.match(html, /role="status"/)
assert.match(html, /aria-busy="true"/)
assert.match(html, /Loading rewards/)
// Should have member reward cards
assert.ok(html.includes('rounded-lg border'))
// Should have skeleton elements for roles badges
assert.ok(html.includes('rounded-full'))
// All inner skeleton elements should be hidden from AT
assert.ok(html.includes('aria-hidden="true"'))
})
2 changes: 2 additions & 0 deletions test/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"../components/ui/skeleton.tsx",
"../components/ui/card.tsx",
"../components/dashboard/membership-card-skeleton.tsx",
"../components/admin/analytics-skeleton.tsx",
"../components/admin/rewards-skeleton.tsx",
"../lib/api/mappers.ts",
"../lib/api/optimistic.ts",
"../lib/api/types.ts",
Expand Down