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
70 changes: 70 additions & 0 deletions PR_LOADING_PATTERNS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# feat(design): loading skeletons and async state patterns

## 📝 Description

Implements loading skeletons for the three core surfaces (markets list, event detail, events table), inline async loading states for buttons/forms, and error + retry banners for network failures. All patterns are layout-shift-free.

## 🎯 Type of Change

- [x] 🎨 Style/UI changes
- [x] ✨ New feature (non-breaking change which adds functionality)

## 🔗 Related Issues

Closes #design/loading

## 📋 Changes Made

### New Files

| File | Purpose |
|------|---------|
| `components/skeletons/markets-skeleton.tsx` | `MarketCardSkeleton` + `MarketsListSkeleton` — mirrors market card layout exactly |
| `components/skeletons/event-detail-skeleton.tsx` | `EventDetailSkeleton` — covers badges, title, stat grid, options, form |
| `components/ui/async-button.tsx` | `AsyncButton` — inline spinner + loading text, auto-disables |
| `components/ui/error-banner.tsx` | `ErrorBanner` — destructive alert with optional Retry action |
| `app/(dashboard)/loading-patterns/page.tsx` | Demo page showing all 3 surfaces + patterns |

### Modified Files

| File | Change |
|------|--------|
| `app/(dashboard)/events/event-page/page.tsx` | Added `isLoading`/`fetchError` states; renders skeleton on load, error banner on failure; replaced manual `Loader2` button with `AsyncButton` |
| `components/events/events-section.tsx` | Reads `error` from events store; renders `ErrorBanner` with `loadEvents` as retry callback |

### Key Design Decisions

- Skeleton blocks use explicit `h-*`/`w-*` sizes matching real content — no layout shift on reveal.
- `AsyncButton` wraps the existing `Button` primitive; zero API surface change for callers.
- `ErrorBanner` is a thin wrapper over the existing `Alert` component — consistent with the design system.
- Error/retry pattern hooks directly into the Zustand `events-store` `error` + `loadEvents` — no new state needed.

## 🧪 Testing

- [x] ✅ Manual testing completed
- [x] ✅ TypeScript check passes (`pnpm tsc --noEmit` — zero new errors)
- [x] ✅ Mobile responsiveness tested (skeletons use responsive grid classes)

## 📸 Screenshots

Visit `/loading-patterns` in the running app to see all three surfaces side-by-side:

1. **Markets List Skeleton** — 3 shimmer cards matching the marketing widget
2. **Event Detail Skeleton** — full page skeleton for the event detail route
3. **Events Table Skeleton** — existing skeleton wired via `loading.tsx`
4. **AsyncButton** — live demo with 2-second simulated action
5. **ErrorBanner** — with and without retry callback

## ✅ Pre-submission Checklist

- [x] ✅ Code follows project style guidelines (Tailwind, shadcn/ui primitives)
- [x] ✅ No TypeScript errors in new files
- [x] ✅ No new dependencies added
- [x] ✅ No breaking changes — all changes are additive
- [x] ✅ Skeletons match real content dimensions (no layout shift)
- [x] ✅ `AsyncButton` is backwards-compatible with `Button` props

## 🔧 Additional Notes

- The two pre-existing TS errors (`activity-timeline-demo/page.tsx`, `typography-example.tsx`) are unrelated to this PR.
- The `/loading-patterns` demo page can be removed before merging to `main` if not needed in production.
73 changes: 73 additions & 0 deletions app/(dashboard)/analytics/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { KpiCard } from "@/components/analytics/kpi-card";
import { ChartPanel } from "@/components/analytics/chart-panel";
import { VolumeChart } from "@/components/analytics/volume-chart";
import { DistributionChart } from "@/components/analytics/distribution-chart";

const kpis = [
{
label: "Total Volume",
value: "$14.8M",
unit: "XLM",
delta: "+18% vs last month",
deltaPositive: true,
tooltip: "Total value of all predictions settled on-chain.",
},
{
label: "Active Users",
value: "10,241",
delta: "+201 this week",
deltaPositive: true,
tooltip: "Unique wallets that placed at least one prediction.",
},
{
label: "Markets Created",
value: "1,204",
delta: "+34 this week",
deltaPositive: true,
tooltip: "Total prediction markets opened on the platform.",
},
{
label: "Avg. Payout Time",
value: "2.4",
unit: "sec",
delta: "-0.3s vs last month",
deltaPositive: true,
tooltip: "Median time from event resolution to wallet credit.",
},
];

export default function AnalyticsPage() {
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 space-y-6">
<h1 className="text-xl font-semibold text-foreground">Analytics</h1>

{/* KPI strip — 2 cols on mobile, 4 on desktop */}
<section aria-label="Key metrics">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
{kpis.map((kpi) => (
<KpiCard key={kpi.label} {...kpi} />
))}
</div>
</section>

{/* Charts — stacked on mobile, side-by-side on lg */}
<section aria-label="Charts" className="grid grid-cols-1 lg:grid-cols-5 gap-4">
<ChartPanel
title="Prediction Volume"
description="Monthly settled volume (XLM)"
className="lg:col-span-3"
>
<VolumeChart />
</ChartPanel>

<ChartPanel
title="Market Distribution"
description="Share of markets by category (%)"
className="lg:col-span-2"
>
<DistributionChart />
</ChartPanel>
</section>
</div>
);
}
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>
)
}
33 changes: 33 additions & 0 deletions components/analytics/chart-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";

import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { cn } from "@/lib/utils";

interface ChartPanelProps {
title: string;
description?: string;
children: React.ReactNode;
className?: string;
/** Height of the chart area. Defaults to h-56 on mobile, h-72 on sm+ */
chartHeight?: string;
}

/**
* Wrapper for all chart panels.
* Enforces consistent padding, title/description placement, and responsive height.
*/
export function ChartPanel({ title, description, children, className, chartHeight }: ChartPanelProps) {
return (
<Card className={cn("bg-card border-border", className)}>
<CardHeader className="pb-2 px-4 sm:px-5 pt-4 sm:pt-5">
<CardTitle className="text-sm font-semibold text-foreground">{title}</CardTitle>
{description && (
<CardDescription className="text-xs text-muted-foreground">{description}</CardDescription>
)}
</CardHeader>
<CardContent className={cn("px-2 sm:px-4 pb-4", chartHeight ?? "h-56 sm:h-72")}>
{children}
</CardContent>
</Card>
);
}
34 changes: 34 additions & 0 deletions components/analytics/chart-styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Shared chart style constants.
* Use these across all Recharts instances to keep axes, tooltips, and colors consistent.
*/

/** Palette — maps semantic series names to CSS custom properties from globals.css */
export const CHART_COLORS = {
primary: "hsl(var(--chart-1))",
secondary: "hsl(var(--chart-2))",
tertiary: "hsl(var(--chart-3))",
quaternary: "hsl(var(--chart-4))",
quinary: "hsl(var(--chart-5))",
} as const;

/** Axis tick style — applied to XAxis and YAxis tickStyle / tick props */
export const AXIS_TICK_STYLE = {
fontSize: 11,
fill: "hsl(var(--muted-foreground))",
fontFamily: "inherit",
} as const;

/** Cartesian grid style */
export const GRID_STYLE = {
stroke: "hsl(var(--border))",
strokeDasharray: "3 3",
strokeOpacity: 0.5,
} as const;

/** Tooltip wrapper class — used via ChartTooltipContent className */
export const TOOLTIP_CLASS =
"rounded-lg border border-border/50 bg-popover px-3 py-2 text-xs shadow-lg text-popover-foreground";

/** Standard chart margins — keeps axis labels from clipping */
export const CHART_MARGIN = { top: 4, right: 16, bottom: 0, left: 0 } as const;
Loading