Skip to content

Commit b9ed7b6

Browse files
committed
feat(frontend): standardize application states
1 parent 1c9541b commit b9ed7b6

9 files changed

Lines changed: 230 additions & 84 deletions

File tree

frontend/app/analytics/page.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import { useRouter } from "next/navigation";
55
import DauMauChart from "@/components/analytics/DauMauChart";
66
import OnboardingFunnelChart from "@/src/components/analytics/OnboardingFunnelChart";
77
import { useAuth } from "@/hooks/useAuth";
8+
import { LoadingState } from "@/components/ui/StateDisplay";
89

910
function AnalyticsPageSkeleton() {
1011
return (
11-
<div className="space-y-6" aria-label="Loading analytics dashboard">
12+
<div className="min-h-[34rem] space-y-6" aria-label="Loading analytics dashboard">
1213
<div className="animate-pulse rounded-2xl border border-slate-800 bg-[#0F172A]/70 p-6">
1314
<div className="h-4 w-32 rounded bg-slate-700" />
1415
<div className="mt-4 h-3 w-48 rounded bg-slate-800" />
@@ -64,7 +65,14 @@ export default function AnalyticsPage() {
6465
}, []);
6566

6667
if (isCheckingAccess || isLoadingContent) {
67-
return <AnalyticsPageSkeleton />;
68+
return (
69+
<div className="relative">
70+
<AnalyticsPageSkeleton />
71+
<div className="absolute inset-0 flex items-center justify-center">
72+
<LoadingState message="Loading analytics..." />
73+
</div>
74+
</div>
75+
);
6876
}
6977

7078
return (

frontend/app/dashboard/page.tsx

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import CategoryCard from "@/components/dashboard/CategoryCard";
66
import Image from "next/image";
77
import { Flame, Gem, User } from "lucide-react";
88
import { useDashboard } from "@/features/dashboard";
9+
import { EmptyState, ErrorState, LoadingState } from "@/components/ui/StateDisplay";
910

1011
const Dashboard = () => {
1112
const router = useRouter();
@@ -62,15 +63,11 @@ const Dashboard = () => {
6263

6364
<main className="mx-auto w-full max-w-6xl px-4 pt-20 pb-8 sm:max-w-6xl sm:px-6">
6465
{isLoading && (
65-
<div className="flex items-center justify-center py-8">
66-
<div className="text-slate-400">Loading dashboard...</div>
67-
</div>
66+
<LoadingState message="Loading dashboard..." />
6867
)}
6968

7069
{error && (
71-
<div className="flex items-center justify-center py-8">
72-
<div className="text-red-400">Error: {error}</div>
73-
</div>
70+
<ErrorState message={String(error)} onRetry={() => window.location.reload()} />
7471
)}
7572

7673
<div className="flex flex-col items-center gap-4">
@@ -96,9 +93,11 @@ const Dashboard = () => {
9693
<h2 className="text-base font-semibold text-white">Categories</h2>
9794
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-2">
9895
{categories.length === 0 && !isLoading && (
99-
<div className="col-span-full text-center text-slate-400 py-4">
100-
No categories available
101-
</div>
96+
<EmptyState
97+
title="No categories available"
98+
message="Check back soon for a new path to explore."
99+
className="col-span-full"
100+
/>
102101
)}
103102
{categories.map((category) => (
104103
<CategoryCard

frontend/app/globals.css

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,54 @@ body {
147147
.animate-zoom-in {
148148
animation: zoom-in 0.5s ease-out forwards;
149149
}
150+
151+
/* Shared feedback states keep page geometry stable while data changes. */
152+
.state-display {
153+
min-height: 13rem;
154+
display: flex;
155+
flex-direction: column;
156+
align-items: center;
157+
justify-content: center;
158+
gap: 0.65rem;
159+
padding: 2rem 1rem;
160+
text-align: center;
161+
color: #e2e8f0;
162+
}
163+
164+
.state-display__icon {
165+
display: grid;
166+
place-items: center;
167+
width: 3.5rem;
168+
height: 3.5rem;
169+
border: 1px solid rgb(148 163 184 / 0.18);
170+
border-radius: 1rem;
171+
color: #7dd3fc;
172+
background: rgb(14 116 144 / 0.12);
173+
}
174+
175+
.state-display__title { font-size: 1rem; font-weight: 700; }
176+
.state-display__message { max-width: 28rem; color: #94a3b8; font-size: 0.875rem; }
177+
.state-display__action {
178+
display: inline-flex;
179+
align-items: center;
180+
gap: 0.5rem;
181+
margin-top: 0.5rem;
182+
border-radius: 0.6rem;
183+
padding: 0.65rem 1rem;
184+
background: #2563eb;
185+
color: white;
186+
font-size: 0.875rem;
187+
font-weight: 700;
188+
transition: background-color 150ms ease;
189+
}
190+
.state-display__action:hover { background: #1d4ed8; }
191+
.state-display--loading .state-display__icon { animation: state-pulse 1.8s ease-in-out infinite; }
192+
.state-display--success .state-display__icon { color: #86efac; background: rgb(22 163 74 / 0.12); }
193+
.state-display--error .state-display__icon { color: #fca5a5; background: rgb(220 38 38 / 0.12); }
194+
.state-display--locked .state-display__icon { color: #cbd5e1; background: rgb(71 85 105 / 0.2); }
195+
.state-display--expired .state-display__icon { color: #fcd34d; background: rgb(217 119 6 / 0.12); }
196+
@keyframes state-pulse { 50% { box-shadow: 0 0 1.5rem rgb(56 189 248 / 0.3); transform: scale(1.04); } }
197+
@media (prefers-reduced-motion: reduce) {
198+
.state-display--loading .state-display__icon { animation: none; }
199+
.state-display__action { transition: none; }
200+
}

frontend/app/puzzle-list/page.tsx

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { usePuzzles } from '@/hooks/usePuzzles';
77
import type { PuzzleDifficulty, PuzzleFilters } from '@/lib/types/puzzles';
88
import { Puzzle as PuzzleIcon, Clock, Zap } from 'lucide-react';
99
import Pagination from '@/components/ui/Pagination';
10+
import { EmptyState, ErrorState, LoadingState } from '@/components/ui/StateDisplay';
1011

1112
// ─── Helpers ────────────────────────────────────────────────────────────────
1213

@@ -229,34 +230,34 @@ function PuzzleListContent() {
229230

230231
{/* Loading skeleton */}
231232
{isLoading && (
232-
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3" aria-label="Loading puzzles">
233+
<div className="relative min-h-[26rem] grid gap-3 sm:grid-cols-2 lg:grid-cols-3" aria-label="Loading puzzles">
233234
{Array.from({ length: 6 }).map((_, i) => (
234235
<div
235236
key={i}
236237
className="h-32 rounded-xl border border-white/5 bg-white/[0.03] animate-pulse"
237238
aria-hidden="true"
238239
/>
239240
))}
241+
<div className="absolute inset-0 flex items-center justify-center">
242+
<LoadingState message="Loading puzzles..." />
243+
</div>
240244
</div>
241245
)}
242246

243247
{/* Error state */}
244248
{isError && (
245-
<div className="flex flex-col items-center justify-center py-16 text-center gap-2">
246-
<p className="text-red-400 font-medium">Failed to load puzzles</p>
247-
<p className="text-xs text-slate-500">
248-
{error instanceof Error ? error.message : 'Please try again later.'}
249-
</p>
250-
</div>
249+
<ErrorState
250+
message={error instanceof Error ? error.message : 'Please try again later.'}
251+
onRetry={() => window.location.reload()}
252+
/>
251253
)}
252254

253255
{/* Empty state */}
254256
{!isLoading && !isError && puzzles?.length === 0 && (
255-
<div className="flex flex-col items-center justify-center py-16 text-center gap-2">
256-
<PuzzleIcon className="h-10 w-10 text-slate-600" />
257-
<p className="font-medium text-slate-400">No puzzles found</p>
258-
<p className="text-xs text-slate-600">Try clearing your filters or adjusting your search.</p>
259-
</div>
257+
<EmptyState
258+
title="No puzzles found"
259+
message="Try clearing your filters or adjusting your search."
260+
/>
260261
)}
261262

262263
{/* Puzzle grid */}

frontend/app/puzzles/[id]/page.tsx

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { usePuzzle } from "@/hooks/usePuzzles";
66
import HintsSection from "@/components/puzzles/HintsSection";
77
import PuzzleHeader from "@/components/puzzles/PuzzleHeader";
88
import PuzzleInfoCard from "@/components/puzzles/PuzzleInfoCard";
9+
import { ErrorState, LoadingState } from "@/components/ui/StateDisplay";
910

1011
export default function PuzzleDetailPage() {
1112
const params = useParams();
@@ -16,31 +17,11 @@ export default function PuzzleDetailPage() {
1617
const { data: puzzle, isLoading, error } = usePuzzle(puzzleId);
1718

1819
if (isLoading) {
19-
return (
20-
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
21-
<div className="text-center">
22-
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mx-auto mb-4"></div>
23-
<p>Loading puzzle...</p>
24-
</div>
25-
</div>
26-
);
20+
return <div className="min-h-screen bg-slate-950 text-slate-100"><LoadingState message="Loading puzzle..." /></div>;
2721
}
2822

2923
if (error || !puzzle) {
30-
return (
31-
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
32-
<div className="text-center">
33-
<h2 className="text-2xl font-bold text-red-500 mb-4">Error</h2>
34-
<p className="text-slate-400 mb-6">Failed to load puzzle details</p>
35-
<button
36-
onClick={() => router.back()}
37-
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg font-semibold transition-colors"
38-
>
39-
Go Back
40-
</button>
41-
</div>
42-
</div>
43-
);
24+
return <div className="min-h-screen bg-slate-950 text-slate-100"><ErrorState message="Failed to load puzzle details." onRetry={() => router.back()} /></div>;
4425
}
4526

4627
return (

frontend/app/puzzles/page.tsx

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { usePuzzles } from '@/hooks/usePuzzles';
55
import PuzzleCard from '@/components/puzzles/PuzzleCard';
66
import FilterBar from '@/components/puzzles/FilterBar';
77
import type { PuzzleFilters } from '@/lib/types/puzzles';
8+
import { EmptyState, ErrorState } from '@/components/ui/StateDisplay';
89

910
const DEFAULT_FILTERS: PuzzleFilters = {
1011
categoryId: '',
@@ -63,20 +64,12 @@ const PuzzleListPage: React.FC = () => {
6364

6465
{/* Error state */}
6566
{isError && (
66-
<div className="flex flex-col items-center justify-center py-20 gap-4">
67-
<p className="text-gray-400 text-sm">Failed to load puzzles.</p>
68-
<button
69-
onClick={() => refetch()}
70-
className="px-5 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors"
71-
>
72-
Try again
73-
</button>
74-
</div>
67+
<ErrorState message="Failed to load puzzles." onRetry={() => refetch()} />
7568
)}
7669

7770
{/* Skeleton grid while loading */}
7871
{isLoading && (
79-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
72+
<div className="grid min-h-[26rem] grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4" aria-label="Loading puzzles">
8073
{Array.from({ length: 6 }).map((_, i) => (
8174
<PuzzleCardSkeleton key={i} />
8275
))}
@@ -98,11 +91,10 @@ const PuzzleListPage: React.FC = () => {
9891
</div>
9992
</>
10093
) : (
101-
<div className="flex flex-col items-center justify-center py-20 gap-3">
102-
<span className="text-4xl" aria-hidden="true">🔍</span>
103-
<p className="text-[#E6E6E6] font-medium">No puzzles found</p>
104-
<p className="text-gray-400 text-sm">Try adjusting your filters.</p>
105-
</div>
94+
<EmptyState
95+
title="No puzzles found"
96+
message="Try adjusting your filters to discover another challenge."
97+
/>
10698
)}
10799
</>
108100
)}

frontend/app/quiz/page.tsx

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { LevelComplete } from "@/components/quiz/LevelComplete";
88
import { QuizCompletionStats } from "../../components/quiz/QuizCompletionStats";
99
import { useQuiz } from "../../hooks/useQuiz";
1010
import { useAppSelector } from "../../lib/reduxHooks";
11+
import { EmptyState, ErrorState, LoadingState } from "@/components/ui/StateDisplay";
1112

1213
const nunito = Nunito({
1314
subsets: ["latin"],
@@ -70,35 +71,17 @@ export default function QuizPage() {
7071

7172
// Loading state
7273
if (isLoading) {
73-
return (
74-
<div
75-
className={`${nunito.className} min-h-screen bg-[#050C16] text-white flex flex-col items-center justify-center p-6`}
76-
>
77-
<div className="text-xl">Loading questions...</div>
78-
</div>
79-
);
74+
return <div className={`${nunito.className} min-h-screen bg-[#050C16] text-white`}><LoadingState message="Loading questions..." /></div>;
8075
}
8176

8277
// Error state
8378
if (error && questions.length === 0) {
84-
return (
85-
<div
86-
className={`${nunito.className} min-h-screen bg-[#050C16] text-white flex flex-col items-center justify-center p-6`}
87-
>
88-
<div className="text-xl text-red-500">Error: {error}</div>
89-
</div>
90-
);
79+
return <div className={`${nunito.className} min-h-screen bg-[#050C16] text-white`}><ErrorState message={String(error)} onRetry={() => window.location.reload()} /></div>;
9180
}
9281

9382
// No questions state
9483
if (!currentQuestion && !isLoading) {
95-
return (
96-
<div
97-
className={`${nunito.className} min-h-screen bg-[#050C16] text-white flex flex-col items-center justify-center p-6`}
98-
>
99-
<div className="text-xl">No questions available</div>
100-
</div>
101-
);
84+
return <div className={`${nunito.className} min-h-screen bg-[#050C16] text-white`}><EmptyState title="No questions available" message="Check back later for your next daily quest." /></div>;
10285
}
10386

10487
if (!currentQuestion) {

frontend/app/streak/page.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { DayData } from "@/components/WeeklyCalendar";
44
import { useRouter } from "next/navigation";
55
import { useStreak } from "@/hooks/useStreak";
66
import { useMemo } from "react";
7+
import { LoadingState } from "@/components/ui/StateDisplay";
78

89
const DAYS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
910

@@ -33,9 +34,7 @@ export default function StreakPage() {
3334

3435
if (isLoading) {
3536
return (
36-
<div className="min-h-screen bg-[#050C16] flex items-center justify-center">
37-
<div className="text-white">Loading streak...</div>
38-
</div>
37+
<div className="min-h-screen bg-[#050C16] text-white"><LoadingState message="Loading streak..." /></div>
3938
);
4039
}
4140

0 commit comments

Comments
 (0)