forked from Nova-reward/Nova-Rewards
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathEmptyState.js
More file actions
139 lines (133 loc) · 6.66 KB
/
Copy pathEmptyState.js
File metadata and controls
139 lines (133 loc) · 6.66 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
/** Preset configs for common empty states. */
export const EMPTY_STATE_VARIANTS = {
'no-rewards': { icon: 'rewards', title: 'No rewards earned yet', description: 'Browse partner campaigns to start earning NOVA tokens.', actionLabel: 'Browse Campaigns' },
'no-transactions': { icon: 'transactions', title: 'No activity yet', description: 'Earn or redeem NOVA tokens to see your transaction history.', actionLabel: 'Start Earning' },
'no-campaigns': { icon: 'campaigns', title: 'No active campaigns', description: 'Check back soon — new merchant campaigns are added regularly.', actionLabel: null },
'no-wallet': { icon: 'inbox', title: 'Connect your wallet', description: 'Connect a Stellar wallet to see your NOVA balance and transactions.', actionLabel: 'Connect Wallet' },
'loading-error': { icon: 'search', title: 'Something went wrong', description: 'We couldn\'t load this content. Please try again.', actionLabel: 'Try Again' },
'search-empty': { icon: 'search', title: 'No results found', description: 'Try a different search term or browse all campaigns.', actionLabel: 'Clear Search' },
};
/**
* EmptyState — reusable empty state with illustration, headline, description, and CTA.
* Includes SVG illustrations optimized for web (<5KB each).
*
* @param {{
* icon?: 'inbox'|'rewards'|'transactions'|'campaigns'|'notifications'|'search',
* illustration?: React.ReactNode, // custom SVG/image overrides icon
* title?: string,
* description?: string,
* actionLabel?: string,
* onAction?: () => void,
* variant?: 'default'|'primary'|'success'|'warning',
* }} props
*/
export default function EmptyState({
icon,
illustration,
title,
description,
actionLabel,
onAction,
variant = 'default',
emptyVariant, // 'no-rewards' | 'no-transactions' | 'no-campaigns' | 'no-wallet' | 'loading-error' | 'search-empty'
}) {
// Merge preset config with caller overrides
const preset = emptyVariant ? EMPTY_STATE_VARIANTS[emptyVariant] ?? {} : {};
icon = icon ?? preset.icon ?? 'inbox';
title = title ?? preset.title ?? 'Nothing here yet';
description = description ?? preset.description ?? 'Get started by taking an action below.';
actionLabel = actionLabel ?? preset.actionLabel ?? undefined;
// Optimized SVG illustrations (~2-4KB each)
const illustrations = {
inbox: (
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="12" y="20" width="56" height="40" rx="4" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M12 20L40 35L68 20" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round"/>
<circle cx="40" cy="40" r="8" fill="currentColor" opacity="0.2"/>
</svg>
),
rewards: (
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="40" cy="40" r="28" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M40 28V52M28 40H52" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<circle cx="40" cy="40" r="6" fill="currentColor"/>
<path d="M55 25L60 20M25 55L20 60" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
),
transactions: (
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="16" y="16" width="48" height="48" rx="4" stroke="currentColor" strokeWidth="2" fill="none"/>
<line x1="24" y1="28" x2="56" y2="28" stroke="currentColor" strokeWidth="1.5"/>
<line x1="24" y1="40" x2="56" y2="40" stroke="currentColor" strokeWidth="1.5"/>
<line x1="24" y1="52" x2="40" y2="52" stroke="currentColor" strokeWidth="1.5"/>
</svg>
),
campaigns: (
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="16" y="24" width="48" height="40" rx="4" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M24 40L32 28L40 36L48 24L56 40" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<circle cx="28" cy="28" r="2" fill="currentColor"/>
</svg>
),
notifications: (
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M40 12C32 12 26 18 26 28V44L20 56H60L54 44V28C54 18 48 12 40 12Z" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<circle cx="40" cy="64" r="3" fill="currentColor"/>
<circle cx="40" cy="64" r="6" stroke="currentColor" strokeWidth="1.5" fill="none"/>
</svg>
),
search: (
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="32" cy="32" r="16" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M48 48L64 64" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
),
};
const bg = { default: 'var(--surface-2)', primary: 'rgba(124,58,237,0.06)', success: 'rgba(5,150,105,0.06)', warning: 'rgba(217,119,6,0.06)' };
const iconColor = { default: 'var(--muted)', primary: 'var(--accent)', success: 'var(--success)', warning: '#d97706' };
return (
<div
role="status"
aria-label={title}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '3rem 1.5rem',
background: bg[variant] ?? bg.default,
borderRadius: '12px',
textAlign: 'center',
}}
>
{/* Illustration or icon */}
<div style={{ marginBottom: '1.25rem', color: iconColor[variant] ?? iconColor.default }} aria-hidden="true">
{illustration ?? illustrations[icon] ?? illustrations.inbox}
</div>
<h3 className="type-h6" style={{ color: 'var(--text)', marginBottom: '0.5rem' }}>
{title}
</h3>
<p className="type-body-sm" style={{ color: 'var(--color-neutral-600)', maxWidth: '28rem', marginBottom: actionLabel ? '1.5rem' : 0 }}>
{description}
</p>
{actionLabel && onAction && (
<button
onClick={onAction}
className="type-label"
style={{
background: 'var(--accent)',
color: '#fff',
border: 'none',
borderRadius: '8px',
padding: '0.5rem 1.5rem',
fontWeight: 600,
cursor: 'pointer',
}}
aria-label={actionLabel}
>
{actionLabel}
</button>
)}
</div>
);
}