Skip to content

Commit 9727329

Browse files
authored
Merge pull request #730 from Chinonso-Peter/feat/toast-notification-system
Add toast notification system with warning variant, queue, and keyboard dismiss
2 parents 965f89a + a61ed43 commit 9727329

18 files changed

Lines changed: 309 additions & 104 deletions

frontend/src/components/CampaignDetailPanel.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { AddressAvatar } from './AddressAvatar';
1212
import { EmptyState } from './EmptyState';
1313
import { ContributorSummary } from './ContributorSummary';
1414
import { CampaignImage } from './CampaignImage';
15+
import { useMinDisplayTime } from '../hooks/useMinDisplayTime';
1516

1617
interface CampaignDetailPanelProps {
1718
campaign: Campaign | null;
@@ -91,17 +92,18 @@ export function CampaignDetailPanel({
9192

9293

9394

94-
if (isLoading) {
95+
const showSkeleton = useMinDisplayTime(isLoading);
96+
if (showSkeleton) {
9597
return (
96-
<section className="card detail-panel">
98+
<section className="card detail-panel" aria-busy="true" aria-label="Loading campaign details">
9799
<div className="section-heading">
98100
<h2>
99101
<div className="skeleton skeleton-line" style={{ width: 220 }} />
100102
</h2>
101103
<div className="skeleton skeleton-line" style={{ width: 320, height: 14 }} />
102104
</div>
103105
<div className="detail-grid">
104-
{Array.from({ length: 4 }).map((_, index) => (
106+
{Array.from({ length: 5 }).map((_, index) => (
105107
<article key={index} className="detail-stat">
106108
<div className="skeleton skeleton-line" style={{ width: 120 }} />
107109
<div

frontend/src/components/CampaignTimeline.tsx

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { History } from 'lucide-react';
22
import { CampaignEvent } from '../types/campaign';
33
import { EmptyState } from './EmptyState';
4+
import { SkeletonTimeline } from './SkeletonTimeline';
5+
import { useMinDisplayTime } from '../hooks/useMinDisplayTime';
46

57
const MILESTONES = [25, 50, 75] as const;
68

@@ -97,15 +99,9 @@ export function CampaignTimeline({
9799
: 0;
98100

99101
const showProgress = typeof targetAmount === 'number' && targetAmount > 0;
100-
if (isLoading) {
101-
return (
102-
<section className="card">
103-
<div className="section-heading">
104-
<h2>Timeline</h2>
105-
<p className="muted">Loading campaign activity...</p>
106-
</div>
107-
</section>
108-
);
102+
const showSkeleton = useMinDisplayTime(isLoading);
103+
if (showSkeleton) {
104+
return <SkeletonTimeline />;
109105
}
110106

111107
if (history.length === 0) {

frontend/src/components/CampaignsTable.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { AddressAvatar } from "./AddressAvatar";
1717
import { SkeletonCard } from "./SkeletonCard";
1818
import { useWindowVirtualizer } from "@tanstack/react-virtual";
1919
import { useMediaQuery } from "../hooks/useMediaQuery";
20+
import { useMinDisplayTime } from "../hooks/useMinDisplayTime";
2021

2122
type StatusFilterValue = "" | CampaignStatus;
2223

@@ -204,7 +205,8 @@ export function CampaignsTable({
204205
overscan: 5,
205206
});
206207

207-
if (isLoading && isEmpty) {
208+
const showSkeleton = useMinDisplayTime(isLoading && isEmpty);
209+
if (showSkeleton) {
208210
return (
209211
<section className="card">
210212
<div className="section-heading">

frontend/src/components/ContributorSummary.tsx

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { EmptyState } from './EmptyState';
66
import { ContributorSummary as ContributorSummaryData } from '../types/campaign';
77
import { apiRequest } from '../services/httpClient';
88
import { buildContributorCsv, downloadCsv } from '../utils/exportCsv';
9+
import { useMinDisplayTime } from '../hooks/useMinDisplayTime';
910

1011
const POLL_INTERVAL_MS = 30_000;
1112

@@ -77,24 +78,52 @@ export function ContributorSummary({
7778
};
7879
}, [campaignId, fetchContributors]);
7980

80-
if (externalLoading || (isLoading && contributors.length === 0)) {
81+
const showSkeleton = useMinDisplayTime(externalLoading || (isLoading && contributors.length === 0));
82+
if (showSkeleton) {
8183
return (
8284
<section
83-
className="contributor-summary contributor-summary-loading"
85+
className="contributor-summary"
86+
aria-busy="true"
8487
aria-label="Contributor summary"
8588
>
8689
<h3 className="contributor-summary-title">Contributors</h3>
8790
<div className="contributor-summary-stats" style={{ marginTop: 12 }}>
88-
{Array.from({ length: 3 }).map((_, i) => (
91+
{Array.from({ length: 4 }).map((_, i) => (
8992
<article key={i} className="contributor-stat">
9093
<div className="skeleton skeleton-line" style={{ width: 100 }} />
9194
<div
9295
className="skeleton skeleton-line"
9396
style={{ width: 60, height: 20, marginTop: 8 }}
9497
/>
98+
<div className="skeleton skeleton-line" style={{ width: 140, height: 10, marginTop: 6 }} />
9599
</article>
96100
))}
97101
</div>
102+
<div className="contributor-table-wrap" aria-hidden="true">
103+
<div className="contributor-table contributor-table-head">
104+
<div className="contributor-table-row">
105+
<span><div className="skeleton skeleton-line" style={{ width: 80 }} /></span>
106+
<span><div className="skeleton skeleton-line" style={{ width: 80 }} /></span>
107+
<span><div className="skeleton skeleton-line" style={{ width: 80 }} /></span>
108+
</div>
109+
</div>
110+
<div className="contributor-table contributor-table-body">
111+
{Array.from({ length: 3 }).map((_, i) => (
112+
<div key={i} className="contributor-table-row">
113+
<div className="contributor-address" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
114+
<div className="skeleton" style={{ width: 24, height: 24, borderRadius: '50%' }} />
115+
<div className="skeleton skeleton-line" style={{ width: 120 }} />
116+
</div>
117+
<div className="contributor-amounts">
118+
<div className="skeleton skeleton-line" style={{ width: 60 }} />
119+
</div>
120+
<div className="contributor-amounts">
121+
<div className="skeleton skeleton-line" style={{ width: 60 }} />
122+
</div>
123+
</div>
124+
))}
125+
</div>
126+
</div>
98127
</section>
99128
);
100129
}

frontend/src/components/CreatorAnalytics.tsx

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
ResponsiveContainer,
1313
} from 'recharts';
1414
import { Campaign } from '../types/campaign';
15+
import { SkeletonAnalytics } from './SkeletonAnalytics';
16+
import { useMinDisplayTime } from '../hooks/useMinDisplayTime';
1517

1618
interface CreatorAnalyticsProps {
1719
creatorAddress: string;
@@ -80,26 +82,9 @@ export const CreatorAnalytics: React.FC<CreatorAnalyticsProps> = ({
8082
};
8183
}, [creatorAddress, campaigns]);
8284

83-
if (isLoading) {
84-
return (
85-
<div className="creator-metrics-container">
86-
<h3 className="creator-metrics-title">Creator Performance</h3>
87-
<div className="metric-grid">
88-
<div className="metric-card">
89-
<span>Campaigns Created</span>
90-
<strong></strong>
91-
</div>
92-
<div className="metric-card">
93-
<span>Funded Campaigns</span>
94-
<strong></strong>
95-
</div>
96-
<div className="metric-card">
97-
<span>Claimed Vaults</span>
98-
<strong></strong>
99-
</div>
100-
</div>
101-
</div>
102-
);
85+
const showSkeleton = useMinDisplayTime(isLoading);
86+
if (showSkeleton) {
87+
return <SkeletonAnalytics />;
10388
}
10489

10590
const hasChartData = pledgeData.length > 0;

frontend/src/components/IssueBacklog.tsx

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { useState } from 'react';
22
import { ListTodo, X } from 'lucide-react';
33
import { OpenIssue } from '../types/campaign';
44
import { EmptyState } from './EmptyState';
5+
import { SkeletonIssueList } from './SkeletonIssueList';
6+
import { useMinDisplayTime } from '../hooks/useMinDisplayTime';
57

68
interface IssueBacklogProps {
79
issues: OpenIssue[];
@@ -11,15 +13,9 @@ interface IssueBacklogProps {
1113
export function IssueBacklog({ issues, isLoading = false }: IssueBacklogProps) {
1214
const [activeLabels, setActiveLabels] = useState<string[]>([]);
1315

14-
if (isLoading) {
15-
return (
16-
<section className="card">
17-
<div className="section-heading">
18-
<h2>Contribution backlog</h2>
19-
<p className="muted">Loading open issue ideas...</p>
20-
</div>
21-
</section>
22-
);
16+
const showSkeleton = useMinDisplayTime(isLoading);
17+
if (showSkeleton) {
18+
return <SkeletonIssueList />;
2319
}
2420

2521
if (issues.length === 0) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export function SkeletonAnalytics() {
2+
return (
3+
<div className="creator-metrics-container" aria-busy="true" aria-label="Loading creator analytics">
4+
<div className="skeleton skeleton-line" style={{ width: 300, height: 20, marginBottom: 24 }} />
5+
<div className="metric-grid">
6+
{Array.from({ length: 3 }).map((_, i) => (
7+
<div key={i} className="metric-card">
8+
<div className="skeleton skeleton-line" style={{ width: 120 }} />
9+
<div className="skeleton skeleton-line" style={{ width: 40, height: 20, marginTop: 8 }} />
10+
</div>
11+
))}
12+
</div>
13+
<div style={{ marginTop: 32 }} aria-hidden="true">
14+
<div className="skeleton skeleton-line" style={{ width: 200, height: 16, marginBottom: 16 }} />
15+
<div className="skeleton" style={{ width: '100%', height: 300, borderRadius: 8 }} />
16+
</div>
17+
<div style={{ marginTop: 32 }} aria-hidden="true">
18+
<div className="skeleton skeleton-line" style={{ width: 200, height: 16, marginBottom: 16 }} />
19+
<div className="skeleton" style={{ width: '100%', height: 300, borderRadius: 8 }} />
20+
</div>
21+
</div>
22+
);
23+
}

frontend/src/components/SkeletonCard.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
export function SkeletonCard() {
22
return (
3-
<article className="campaign-card animate-pulse">
3+
<article className="campaign-card">
44
<div className="campaign-card-main">
55
<div className="campaign-card-header">
6-
<div className="h-6 bg-gray-300 rounded w-3/4 mb-2"></div>
7-
<div className="h-6 bg-gray-300 rounded w-1/4"></div>
6+
<div className="skeleton skeleton-line" style={{ width: '75%', height: 24 }} />
7+
<div className="skeleton skeleton-line" style={{ width: '25%', height: 24 }} />
88
</div>
99
<div
1010
className="campaign-creator mono"
@@ -15,19 +15,19 @@ export function SkeletonCard() {
1515
marginBottom: 12,
1616
}}
1717
>
18-
<div className="h-6 w-6 bg-gray-300 rounded-full"></div>
19-
<div className="h-6 bg-gray-300 rounded flex-1"></div>
18+
<div className="skeleton" style={{ width: 24, height: 24, borderRadius: '50%' }} />
19+
<div className="skeleton skeleton-line" style={{ flex: 1 }} />
2020
</div>
2121
<div className="campaign-progress">
22-
<div className="h-4 bg-gray-300 rounded w-full mb-2"></div>
23-
<div className="h-4 bg-gray-300 rounded w-1/2"></div>
22+
<div className="skeleton skeleton-line" style={{ width: '100%', marginBottom: 8 }} />
23+
<div className="skeleton skeleton-line" style={{ width: '50%' }} />
2424
</div>
2525
<div className="campaign-meta">
26-
<div className="h-4 bg-gray-300 rounded w-1/3"></div>
26+
<div className="skeleton skeleton-line" style={{ width: '33%' }} />
2727
</div>
2828
</div>
2929
<div className="campaign-card-actions">
30-
<div className="h-10 bg-gray-300 rounded w-full"></div>
30+
<div className="skeleton" style={{ height: 40, borderRadius: 8 }} />
3131
</div>
3232
</article>
3333
);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export function SkeletonIssueList() {
2+
return (
3+
<section className="card" aria-busy="true" aria-label="Loading issue backlog">
4+
<div className="section-heading">
5+
<h2>Contribution backlog</h2>
6+
<p className="muted">Loading open issue ideas...</p>
7+
</div>
8+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }} aria-hidden="true">
9+
{Array.from({ length: 4 }).map((_, i) => (
10+
<div key={i} className="skeleton" style={{ width: 80, height: 28, borderRadius: 999 }} />
11+
))}
12+
</div>
13+
<div className="issue-list">
14+
{Array.from({ length: 3 }).map((_, i) => (
15+
<article key={i} className="issue-item">
16+
<div className="issue-topline">
17+
<div className="skeleton skeleton-line" style={{ width: '60%' }} />
18+
<div className="skeleton" style={{ width: 50, height: 20, borderRadius: 4 }} />
19+
</div>
20+
<div className="skeleton skeleton-line" style={{ width: '80%', marginTop: 8 }} />
21+
<div className="chip-row" style={{ marginTop: 8 }}>
22+
<div className="skeleton" style={{ width: 60, height: 24, borderRadius: 999 }} />
23+
<div className="skeleton" style={{ width: 80, height: 24, borderRadius: 999 }} />
24+
</div>
25+
</article>
26+
))}
27+
</div>
28+
</section>
29+
);
30+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export function SkeletonTimeline() {
2+
return (
3+
<section className="card" aria-busy="true" aria-label="Loading timeline">
4+
<div className="section-heading">
5+
<h2>Timeline</h2>
6+
<p className="muted">Loading campaign activity...</p>
7+
</div>
8+
<div className="timeline-progress-wrap" aria-hidden="true">
9+
<div className="skeleton" style={{ height: 8, borderRadius: 4, width: '60%' }} />
10+
</div>
11+
<div className="timeline">
12+
{Array.from({ length: 4 }).map((_, i) => (
13+
<article key={i} className="timeline-item" aria-hidden="true">
14+
<div className="timeline-dot skeleton" style={{ width: 12, height: 12, borderRadius: '50%' }} />
15+
<div className="timeline-copy">
16+
<div className="skeleton skeleton-line" style={{ width: 160 }} />
17+
<div className="skeleton skeleton-line" style={{ width: 200, marginTop: 4 }} />
18+
<div className="skeleton skeleton-line" style={{ width: 120, marginTop: 4 }} />
19+
</div>
20+
</article>
21+
))}
22+
</div>
23+
</section>
24+
);
25+
}

0 commit comments

Comments
 (0)