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
59 changes: 42 additions & 17 deletions app/(dashboard)/events/event-page/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -104,6 +107,8 @@ export default function EventDetailsPage() {
const [timeLeft, setTimeLeft] = useState<string>(() =>
calculateTimeLeft(eventData.deadline)
);
const [isLoading, setIsLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const [selectedOption, setSelectedOption] = useState<string | null>(null);
const [betAmount, setBetAmount] = useState<string>("");
const [isSubmittingBet, setIsSubmittingBet] = useState(false);
Expand All @@ -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(
Expand Down Expand Up @@ -216,6 +229,29 @@ export default function EventDetailsPage() {
const potentialPayout =
currentOdds && betAmount ? parseFloat(betAmount || "0") * currentOdds : 0;

if (isLoading) {
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<EventDetailSkeleton />
</div>
);
}

if (fetchError) {
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<ErrorBanner
message={fetchError}
onRetry={() => {
setIsLoading(true);
setFetchError(null);
setTimeout(() => setIsLoading(false), 800);
}}
/>
</div>
);
}

return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="mb-6">
Expand Down Expand Up @@ -501,26 +537,15 @@ export default function EventDetailsPage() {
)}
</CardContent>
<CardFooter>
<Button
<AsyncButton
type="submit"
className="w-full"
disabled={
isEventClosed ||
isSubmittingBet ||
!selectedOption ||
!betAmount ||
!!error
}
loading={isSubmittingBet}
loadingText="Placing Bet..."
disabled={isEventClosed || !selectedOption || !betAmount || !!error}
>
{isSubmittingBet ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Placing Bet...
</>
) : (
"Place Bet"
)}
</Button>
Place Bet
</AsyncButton>
</CardFooter>
</form>
</Card>
Expand Down
88 changes: 88 additions & 0 deletions app/(dashboard)/loading-patterns/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="container mx-auto max-w-4xl px-4 py-10 space-y-12">
<div>
<h1 className="text-2xl font-bold mb-1">Loading & Async State Patterns</h1>
<p className="text-muted-foreground text-sm">Design reference for skeletons, inline loading, and error/retry states.</p>
</div>

<Separator />

{/* Surface 1 – Markets list skeleton */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">1. Markets List Skeleton</h2>
<p className="text-sm text-muted-foreground">Shown while the markets widget fetches data.</p>
<div className="max-w-md">
<MarketsListSkeleton count={3} />
</div>
</section>

<Separator />

{/* Surface 2 – Event detail skeleton */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">2. Event Detail Skeleton</h2>
<p className="text-sm text-muted-foreground">Shown while the event detail page loads.</p>
<EventDetailSkeleton />
</section>

<Separator />

{/* Surface 3 – Events table skeleton */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">3. Events Table Skeleton</h2>
<p className="text-sm text-muted-foreground">Shown via Next.js loading.tsx on the events list page.</p>
<EventsTableSkeleton />
</section>

<Separator />

{/* Inline loading button */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Inline Loading – AsyncButton</h2>
<p className="text-sm text-muted-foreground">Spinner replaces icon; text changes to loading copy.</p>
<div className="flex flex-wrap gap-3">
<AsyncButton loading={btnLoading} loadingText="Placing Bet..." onClick={simulateAction}>
Place Bet
</AsyncButton>
<AsyncButton loading={btnLoading} loadingText="Submitting..." variant="outline" onClick={simulateAction}>
Submit Form
</AsyncButton>
</div>
</section>

<Separator />

{/* Error + retry banner */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Error + Retry Banner</h2>
<p className="text-sm text-muted-foreground">Inline banner with optional retry callback.</p>
<ErrorBanner
message="Failed to load events. Check your connection and try again."
onRetry={() => alert("Retrying…")}
/>
<ErrorBanner
title="Bet submission failed"
message="Your transaction could not be broadcast to the Stellar network."
/>
</section>
</div>
)
}
12 changes: 11 additions & 1 deletion components/events/events-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -88,6 +89,15 @@ export function EventsSection({ className }: EventsSectionProps) {
</Tabs>
</div>

{/* Error banner */}
{error && (
<ErrorBanner
message={error}
onRetry={loadEvents}
className="mb-2"
/>
)}

{/* Tab Content */}
<Tabs value={filters.status} onValueChange={handleTabChange} className="w-full">
<TabsContent value="ongoing" className="space-y-6 mt-6">
Expand Down
52 changes: 52 additions & 0 deletions components/skeletons/event-detail-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-6">
{/* Header */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-5 w-20 rounded-full" />
</div>
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>

{/* Stats row */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="p-4 space-y-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-6 w-20" />
</Card>
))}
</div>

<Separator />

{/* Options / bet form */}
<div className="space-y-3">
<Skeleton className="h-5 w-32" />
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border p-4">
<Skeleton className="h-4 w-4 rounded-full shrink-0" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-4 w-12" />
</div>
))}
</div>

{/* Amount input + submit */}
<div className="space-y-3">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full rounded-md" />
<Skeleton className="h-10 w-full rounded-md" />
</div>
</div>
)
}
42 changes: 42 additions & 0 deletions components/skeletons/markets-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card className="border-white/10 bg-[#201F3780] p-4">
<div className="mb-3 flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
{/* Icon */}
<Skeleton className="h-9 w-9 rounded-lg shrink-0" />
<div className="flex-1 min-w-0 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
{/* Odds */}
<div className="space-y-1 text-right shrink-0">
<Skeleton className="h-3 w-14" />
<Skeleton className="h-3 w-10" />
</div>
</div>
{/* Progress bar */}
<Skeleton className="h-2 w-full rounded-full" />
<div className="mt-2 flex justify-between">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-20" />
</div>
</Card>
)
}

/** Skeleton for the full markets list (3 cards) */
export function MarketsListSkeleton({ count = 3 }: { count?: number }) {
return (
<div className="space-y-4">
{Array.from({ length: count }).map((_, i) => (
<MarketCardSkeleton key={i} />
))}
</div>
)
}
31 changes: 31 additions & 0 deletions components/ui/async-button.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button
disabled={disabled || loading}
className={cn("relative", className)}
{...props}
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{loading && loadingText ? loadingText : children}
</Button>
)
}
44 changes: 44 additions & 0 deletions components/ui/error-banner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Alert
variant="destructive"
className={cn("flex items-start gap-3", className)}
>
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<AlertTitle>{title}</AlertTitle>
<AlertDescription className="mt-1">{message}</AlertDescription>
</div>
{onRetry && (
<Button
variant="outline"
size="sm"
onClick={onRetry}
className="shrink-0 border-destructive/50 text-destructive hover:bg-destructive/10"
>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Retry
</Button>
)}
</Alert>
)
}