From aa73532e2496d60e6e5649c8101cca49e8d163c6 Mon Sep 17 00:00:00 2001 From: johnsmccain Date: Thu, 2 Apr 2026 16:31:52 +0100 Subject: [PATCH] feat(design): loading skeletons and async state patterns - Add MarketCardSkeleton / MarketsListSkeleton for markets widget - Add EventDetailSkeleton for event detail page - EventsTableSkeleton already existed; wired via loading.tsx - Add AsyncButton: inline spinner + loading text, no layout shift - Add ErrorBanner: destructive alert with optional Retry action - Wire EventDetailSkeleton + ErrorBanner into event-page/page.tsx - Wire ErrorBanner + retry into EventsSection (events-store error state) - Add /loading-patterns demo page covering all 3 core surfaces --- app/(dashboard)/events/event-page/page.tsx | 59 +++++++++---- app/(dashboard)/loading-patterns/page.tsx | 88 +++++++++++++++++++ components/events/events-section.tsx | 12 ++- .../skeletons/event-detail-skeleton.tsx | 52 +++++++++++ components/skeletons/markets-skeleton.tsx | 42 +++++++++ components/ui/async-button.tsx | 31 +++++++ components/ui/error-banner.tsx | 44 ++++++++++ 7 files changed, 310 insertions(+), 18 deletions(-) create mode 100644 app/(dashboard)/loading-patterns/page.tsx create mode 100644 components/skeletons/event-detail-skeleton.tsx create mode 100644 components/skeletons/markets-skeleton.tsx create mode 100644 components/ui/async-button.tsx create mode 100644 components/ui/error-banner.tsx diff --git a/app/(dashboard)/events/event-page/page.tsx b/app/(dashboard)/events/event-page/page.tsx index 9693f1c..b95e62e 100644 --- a/app/(dashboard)/events/event-page/page.tsx +++ b/app/(dashboard)/events/event-page/page.tsx @@ -18,6 +18,9 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Separator } from "@/components/ui/separator"; import { Clock, DollarSign, Users, BarChart2, Loader2 } from "lucide-react"; import { formatDistanceToNowStrict, parseISO, isValid } from "date-fns"; +import { EventDetailSkeleton } from "@/components/skeletons/event-detail-skeleton"; +import { ErrorBanner } from "@/components/ui/error-banner"; +import { AsyncButton } from "@/components/ui/async-button"; interface EventOption { id: string; @@ -104,6 +107,8 @@ export default function EventDetailsPage() { const [timeLeft, setTimeLeft] = useState(() => calculateTimeLeft(eventData.deadline) ); + const [isLoading, setIsLoading] = useState(true); + const [fetchError, setFetchError] = useState(null); const [selectedOption, setSelectedOption] = useState(null); const [betAmount, setBetAmount] = useState(""); const [isSubmittingBet, setIsSubmittingBet] = useState(false); @@ -112,6 +117,14 @@ export default function EventDetailsPage() { string | null >(null); + // Simulate initial data fetch + useEffect(() => { + setIsLoading(true); + setFetchError(null); + const t = setTimeout(() => setIsLoading(false), 800); + return () => clearTimeout(t); + }, [eventId]); + useEffect(() => { if (eventId && eventId !== eventData.id) { console.warn( @@ -216,6 +229,29 @@ export default function EventDetailsPage() { const potentialPayout = currentOdds && betAmount ? parseFloat(betAmount || "0") * currentOdds : 0; + if (isLoading) { + return ( +
+ +
+ ); + } + + if (fetchError) { + return ( +
+ { + setIsLoading(true); + setFetchError(null); + setTimeout(() => setIsLoading(false), 800); + }} + /> +
+ ); + } + return (
@@ -501,26 +537,15 @@ export default function EventDetailsPage() { )} - + Place Bet + diff --git a/app/(dashboard)/loading-patterns/page.tsx b/app/(dashboard)/loading-patterns/page.tsx new file mode 100644 index 0000000..2a0e175 --- /dev/null +++ b/app/(dashboard)/loading-patterns/page.tsx @@ -0,0 +1,88 @@ +"use client" + +import { useState } from "react" +import { MarketsListSkeleton } from "@/components/skeletons/markets-skeleton" +import { EventDetailSkeleton } from "@/components/skeletons/event-detail-skeleton" +import { EventsTableSkeleton } from "@/components/events/events-table-skeleton" +import { ErrorBanner } from "@/components/ui/error-banner" +import { AsyncButton } from "@/components/ui/async-button" +import { Separator } from "@/components/ui/separator" + +export default function LoadingPatternsPage() { + const [btnLoading, setBtnLoading] = useState(false) + + function simulateAction() { + setBtnLoading(true) + setTimeout(() => setBtnLoading(false), 2000) + } + + return ( +
+
+

Loading & Async State Patterns

+

Design reference for skeletons, inline loading, and error/retry states.

+
+ + + + {/* Surface 1 – Markets list skeleton */} +
+

1. Markets List Skeleton

+

Shown while the markets widget fetches data.

+
+ +
+
+ + + + {/* Surface 2 – Event detail skeleton */} +
+

2. Event Detail Skeleton

+

Shown while the event detail page loads.

+ +
+ + + + {/* Surface 3 – Events table skeleton */} +
+

3. Events Table Skeleton

+

Shown via Next.js loading.tsx on the events list page.

+ +
+ + + + {/* Inline loading button */} +
+

Inline Loading – AsyncButton

+

Spinner replaces icon; text changes to loading copy.

+
+ + Place Bet + + + Submit Form + +
+
+ + + + {/* Error + retry banner */} +
+

Error + Retry Banner

+

Inline banner with optional retry callback.

+ alert("Retrying…")} + /> + +
+
+ ) +} diff --git a/components/events/events-section.tsx b/components/events/events-section.tsx index 238390f..4ba7c6a 100644 --- a/components/events/events-section.tsx +++ b/components/events/events-section.tsx @@ -15,13 +15,14 @@ import { EventsToolbar } from "./events-toolbar" import { EventsTable } from "./events-table" import { EventsPagination } from "./pagination" import { useEventsStore, getEventCounts } from "@/lib/events-store" +import { ErrorBanner } from "@/components/ui/error-banner" interface EventsSectionProps { className?: string } export function EventsSection({ className }: EventsSectionProps) { - const { events, filters, setStatus, loadEvents } = useEventsStore() + const { events, filters, error, setStatus, loadEvents } = useEventsStore() // Get event counts for each tab const eventCounts = React.useMemo(() => getEventCounts(events), [events]) @@ -88,6 +89,15 @@ export function EventsSection({ className }: EventsSectionProps) {
+ {/* Error banner */} + {error && ( + + )} + {/* Tab Content */} diff --git a/components/skeletons/event-detail-skeleton.tsx b/components/skeletons/event-detail-skeleton.tsx new file mode 100644 index 0000000..28330df --- /dev/null +++ b/components/skeletons/event-detail-skeleton.tsx @@ -0,0 +1,52 @@ +import { Skeleton } from "@/components/ui/skeleton" +import { Card, CardContent, CardHeader } from "@/components/ui/card" +import { Separator } from "@/components/ui/separator" + +/** Skeleton for the event detail page */ +export function EventDetailSkeleton() { + return ( +
+ {/* Header */} +
+
+ + +
+ + + +
+ + {/* Stats row */} +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + ))} +
+ + + + {/* Options / bet form */} +
+ + {Array.from({ length: 3 }).map((_, i) => ( +
+ + + +
+ ))} +
+ + {/* Amount input + submit */} +
+ + + +
+
+ ) +} diff --git a/components/skeletons/markets-skeleton.tsx b/components/skeletons/markets-skeleton.tsx new file mode 100644 index 0000000..846191a --- /dev/null +++ b/components/skeletons/markets-skeleton.tsx @@ -0,0 +1,42 @@ +import { Skeleton } from "@/components/ui/skeleton" +import { Card, CardContent, CardHeader } from "@/components/ui/card" + +/** Skeleton for a single market card (used in the marketing widget and dashboard) */ +export function MarketCardSkeleton() { + return ( + +
+
+ {/* Icon */} + +
+ + +
+
+ {/* Odds */} +
+ + +
+
+ {/* Progress bar */} + +
+ + +
+
+ ) +} + +/** Skeleton for the full markets list (3 cards) */ +export function MarketsListSkeleton({ count = 3 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ) +} diff --git a/components/ui/async-button.tsx b/components/ui/async-button.tsx new file mode 100644 index 0000000..ad04e43 --- /dev/null +++ b/components/ui/async-button.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import { Loader2 } from "lucide-react" +import { Button, type ButtonProps } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +interface AsyncButtonProps extends ButtonProps { + loading?: boolean + loadingText?: string +} + +export function AsyncButton({ + loading = false, + loadingText, + disabled, + children, + className, + ...props +}: AsyncButtonProps) { + return ( + + ) +} diff --git a/components/ui/error-banner.tsx b/components/ui/error-banner.tsx new file mode 100644 index 0000000..c39f99c --- /dev/null +++ b/components/ui/error-banner.tsx @@ -0,0 +1,44 @@ +"use client" + +import { AlertCircle, RefreshCw } from "lucide-react" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +interface ErrorBannerProps { + title?: string + message: string + onRetry?: () => void + className?: string +} + +export function ErrorBanner({ + title = "Something went wrong", + message, + onRetry, + className, +}: ErrorBannerProps) { + return ( + + +
+ {title} + {message} +
+ {onRetry && ( + + )} +
+ ) +}