@etherealui/react 0.0.0-stage → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +62 -2
- package/dist/index.d.mts +583 -0
- package/dist/index.d.ts +583 -0
- package/dist/index.js +5189 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +5189 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +83 -3
- package/src/blocks/app-shell.tsx +441 -0
- package/src/blocks/billing-card.tsx +2 -0
- package/src/blocks/data-table.tsx +479 -0
- package/src/blocks/filter-bar.tsx +134 -0
- package/src/blocks/header-navbar.tsx +7 -0
- package/src/blocks/metric-grid.tsx +5 -0
- package/src/blocks/profile-form.tsx +2 -0
- package/src/blocks/security-settings.tsx +2 -0
- package/src/blocks/settings-blocks.tsx +406 -0
- package/src/blocks/sidebar.tsx +6 -0
- package/src/blocks/stat-card.tsx +7 -0
- package/src/blocks/stat-cards.tsx +212 -0
- package/src/blocks/team-members.tsx +5 -0
- package/src/index.ts +114 -0
- package/src/primitives/accordion.tsx +61 -0
- package/src/primitives/announcement.tsx +33 -0
- package/src/primitives/avatar.tsx +156 -0
- package/src/primitives/badge.tsx +108 -0
- package/src/primitives/breadcrumb.tsx +45 -0
- package/src/primitives/button-group.tsx +79 -0
- package/src/primitives/button.tsx +165 -0
- package/src/primitives/carousel.tsx +84 -0
- package/src/primitives/chart.tsx +831 -0
- package/src/primitives/checkbox.tsx +26 -0
- package/src/primitives/chip.tsx +37 -0
- package/src/primitives/close-button.tsx +29 -0
- package/src/primitives/color-picker.tsx +62 -0
- package/src/primitives/combobox.tsx +123 -0
- package/src/primitives/command-palette.tsx +216 -0
- package/src/primitives/context-menu.tsx +84 -0
- package/src/primitives/date-calendar.tsx +76 -0
- package/src/primitives/date-picker.tsx +400 -0
- package/src/primitives/dialog.tsx +279 -0
- package/src/primitives/divider.tsx +31 -0
- package/src/primitives/drawer.tsx +95 -0
- package/src/primitives/dropdown-menu.tsx +164 -0
- package/src/primitives/file-upload.tsx +123 -0
- package/src/primitives/icon-button.tsx +23 -0
- package/src/primitives/input.tsx +124 -0
- package/src/primitives/modal.tsx +26 -0
- package/src/primitives/pagination.tsx +58 -0
- package/src/primitives/popover.tsx +28 -0
- package/src/primitives/progress.tsx +145 -0
- package/src/primitives/radio-group.tsx +63 -0
- package/src/primitives/select.tsx +207 -0
- package/src/primitives/skeleton.tsx +61 -0
- package/src/primitives/slider.tsx +44 -0
- package/src/primitives/switch.tsx +146 -0
- package/src/primitives/table.tsx +47 -0
- package/src/primitives/tabs.tsx +86 -0
- package/src/primitives/textarea.tsx +40 -0
- package/src/primitives/toast.tsx +71 -0
- package/src/primitives/tooltip.tsx +58 -0
- package/src/styles/globals.css +49 -0
- package/src/templates/control-tower.tsx +41 -0
- package/src/templates/executive-analytics.tsx +272 -0
- package/src/templates/workspace-settings.tsx +179 -0
- package/src/utils/cn.ts +7 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import {
|
|
3
|
+
Shield,
|
|
4
|
+
Plus,
|
|
5
|
+
CreditCard,
|
|
6
|
+
Check,
|
|
7
|
+
Smartphone,
|
|
8
|
+
Laptop,
|
|
9
|
+
} from "lucide-react"
|
|
10
|
+
import { cn } from "../utils/cn"
|
|
11
|
+
import { Avatar } from "../primitives/avatar"
|
|
12
|
+
import { Badge } from "../primitives/badge"
|
|
13
|
+
import { Button } from "../primitives/button"
|
|
14
|
+
import { Input, Label, Textarea } from "../primitives/input"
|
|
15
|
+
import { Switch } from "../primitives/switch"
|
|
16
|
+
import {
|
|
17
|
+
Select,
|
|
18
|
+
SelectTrigger,
|
|
19
|
+
SelectValue,
|
|
20
|
+
SelectContent,
|
|
21
|
+
SelectItem,
|
|
22
|
+
} from "../primitives/select"
|
|
23
|
+
import { Progress } from "../primitives/progress"
|
|
24
|
+
|
|
25
|
+
export const UserProfileFormBlock: React.FC<{ className?: string }> = ({
|
|
26
|
+
className,
|
|
27
|
+
}) => {
|
|
28
|
+
const [saved, setSaved] = React.useState(false)
|
|
29
|
+
const [timezone, setTimezone] = React.useState("utc+7")
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
className={cn(
|
|
34
|
+
"rounded-xl border border-zinc-200 bg-white p-6 space-y-6 dark:border-zinc-800 dark:bg-zinc-900",
|
|
35
|
+
className
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between border-b border-zinc-200 pb-5 dark:border-zinc-800">
|
|
39
|
+
<div className="flex items-center gap-4">
|
|
40
|
+
<Avatar fallback="SR" tint="blue" size="xl" status="online" />
|
|
41
|
+
<div>
|
|
42
|
+
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
|
43
|
+
Profile details
|
|
44
|
+
</h3>
|
|
45
|
+
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
|
46
|
+
Update your photo and personal details here.
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
<Button variant="outline" size="sm">
|
|
51
|
+
Change photo
|
|
52
|
+
</Button>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
56
|
+
<div className="space-y-2">
|
|
57
|
+
<Label>Full name</Label>
|
|
58
|
+
<Input defaultValue="Saeful Rohman" />
|
|
59
|
+
</div>
|
|
60
|
+
<div className="space-y-2">
|
|
61
|
+
<Label>Username</Label>
|
|
62
|
+
<Input prefixText="ethereal-ui.dev/" defaultValue="saefulrdevs" />
|
|
63
|
+
</div>
|
|
64
|
+
<div className="space-y-2">
|
|
65
|
+
<Label>Email address</Label>
|
|
66
|
+
<Input type="email" defaultValue="saeful@acme.com" />
|
|
67
|
+
</div>
|
|
68
|
+
<div className="space-y-2">
|
|
69
|
+
<Label>Timezone</Label>
|
|
70
|
+
<Select value={timezone} onValueChange={setTimezone}>
|
|
71
|
+
<SelectTrigger>
|
|
72
|
+
<SelectValue placeholder="Select timezone" />
|
|
73
|
+
</SelectTrigger>
|
|
74
|
+
<SelectContent>
|
|
75
|
+
<SelectItem value="utc+7">Asia/Jakarta (GMT+07:00)</SelectItem>
|
|
76
|
+
<SelectItem value="utc+0">UTC / London (GMT+00:00)</SelectItem>
|
|
77
|
+
<SelectItem value="utc-5">America/New_York (EST)</SelectItem>
|
|
78
|
+
<SelectItem value="utc+9">Asia/Tokyo (JST)</SelectItem>
|
|
79
|
+
</SelectContent>
|
|
80
|
+
</Select>
|
|
81
|
+
</div>
|
|
82
|
+
<div className="sm:col-span-2 space-y-2">
|
|
83
|
+
<Label optional>Bio</Label>
|
|
84
|
+
<Textarea
|
|
85
|
+
autoResize
|
|
86
|
+
defaultValue="Product engineer focused on design systems and web architecture."
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div className="flex items-center justify-end gap-2 border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
|
92
|
+
<Button variant="outline" size="sm">
|
|
93
|
+
Cancel
|
|
94
|
+
</Button>
|
|
95
|
+
<Button
|
|
96
|
+
variant="primary"
|
|
97
|
+
size="sm"
|
|
98
|
+
onClick={() => {
|
|
99
|
+
setSaved(true)
|
|
100
|
+
setTimeout(() => setSaved(false), 2000)
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
{saved ? "Saved ✓" : "Save changes"}
|
|
104
|
+
</Button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const SecurityPasswordFormBlock: React.FC<{ className?: string }> = ({
|
|
111
|
+
className,
|
|
112
|
+
}) => {
|
|
113
|
+
const [mfaEnabled, setMfaEnabled] = React.useState(true)
|
|
114
|
+
const [hardwareKey, setHardwareKey] = React.useState(true)
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div
|
|
118
|
+
className={cn(
|
|
119
|
+
"rounded-xl border border-zinc-200 bg-white p-6 space-y-6 dark:border-zinc-800 dark:bg-zinc-900",
|
|
120
|
+
className
|
|
121
|
+
)}
|
|
122
|
+
>
|
|
123
|
+
<div className="flex items-start justify-between">
|
|
124
|
+
<div>
|
|
125
|
+
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
|
|
126
|
+
<Shield className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
|
127
|
+
Two-factor authentication & sessions
|
|
128
|
+
</h3>
|
|
129
|
+
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
|
130
|
+
Manage passkeys, authenticator apps, and active devices.
|
|
131
|
+
</p>
|
|
132
|
+
</div>
|
|
133
|
+
<Badge variant="emerald" dot>
|
|
134
|
+
Protected
|
|
135
|
+
</Badge>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div className="space-y-3">
|
|
139
|
+
<div className="flex items-center justify-between rounded-xl border border-zinc-200 bg-zinc-50/60 p-4 dark:border-zinc-800 dark:bg-zinc-800/40">
|
|
140
|
+
<div className="space-y-0.5">
|
|
141
|
+
<p className="text-xs font-medium text-zinc-900 dark:text-zinc-100">
|
|
142
|
+
Authenticator App (TOTP)
|
|
143
|
+
</p>
|
|
144
|
+
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
|
145
|
+
Use a 6-digit verification code when signing in.
|
|
146
|
+
</p>
|
|
147
|
+
</div>
|
|
148
|
+
<Switch checked={mfaEnabled} onCheckedChange={setMfaEnabled} />
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div className="flex items-center justify-between rounded-xl border border-zinc-200 bg-zinc-50/60 p-4 dark:border-zinc-800 dark:bg-zinc-800/40">
|
|
152
|
+
<div className="space-y-0.5">
|
|
153
|
+
<p className="text-xs font-medium text-zinc-900 dark:text-zinc-100">
|
|
154
|
+
Passkey / Security Key
|
|
155
|
+
</p>
|
|
156
|
+
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
|
157
|
+
Biometric or hardware key sign-in via WebAuthn.
|
|
158
|
+
</p>
|
|
159
|
+
</div>
|
|
160
|
+
<Switch checked={hardwareKey} onCheckedChange={setHardwareKey} />
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div className="space-y-3 border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
|
165
|
+
<div className="text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
|
166
|
+
Active sessions
|
|
167
|
+
</div>
|
|
168
|
+
<div className="divide-y divide-zinc-200 rounded-xl border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
|
169
|
+
<div className="flex items-center justify-between p-3.5">
|
|
170
|
+
<div className="flex items-center gap-3">
|
|
171
|
+
<Laptop className="h-4 w-4 text-zinc-500" />
|
|
172
|
+
<div>
|
|
173
|
+
<p className="text-xs font-medium text-zinc-900 dark:text-zinc-100">
|
|
174
|
+
MacBook Pro • Chrome 134 (Current)
|
|
175
|
+
</p>
|
|
176
|
+
<p className="text-[11px] text-zinc-500">
|
|
177
|
+
Jakarta, Indonesia • Active now
|
|
178
|
+
</p>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
<Badge variant="emerald">Current</Badge>
|
|
182
|
+
</div>
|
|
183
|
+
<div className="flex items-center justify-between p-3.5">
|
|
184
|
+
<div className="flex items-center gap-3">
|
|
185
|
+
<Smartphone className="h-4 w-4 text-zinc-400" />
|
|
186
|
+
<div>
|
|
187
|
+
<p className="text-xs font-medium text-zinc-900 dark:text-zinc-100">
|
|
188
|
+
iPhone 16 Pro • Safari
|
|
189
|
+
</p>
|
|
190
|
+
<p className="text-[11px] text-zinc-500">
|
|
191
|
+
Last active 3 hours ago
|
|
192
|
+
</p>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
<Button variant="outline" size="sm">
|
|
196
|
+
Revoke
|
|
197
|
+
</Button>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface TeamMember {
|
|
206
|
+
id: string
|
|
207
|
+
name: string
|
|
208
|
+
email: string
|
|
209
|
+
fallback: string
|
|
210
|
+
role: string
|
|
211
|
+
status: "online" | "away" | "offline"
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export const TeamRbacTableBlock: React.FC<{ className?: string }> = ({
|
|
215
|
+
className,
|
|
216
|
+
}) => {
|
|
217
|
+
const [members, setMembers] = React.useState<TeamMember[]>([
|
|
218
|
+
{
|
|
219
|
+
id: "m1",
|
|
220
|
+
name: "Saeful Rohman",
|
|
221
|
+
email: "saeful@acme.com",
|
|
222
|
+
fallback: "SR",
|
|
223
|
+
role: "owner",
|
|
224
|
+
status: "online",
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
id: "m2",
|
|
228
|
+
name: "Elena Rostova",
|
|
229
|
+
email: "elena@acme.com",
|
|
230
|
+
fallback: "ER",
|
|
231
|
+
role: "admin",
|
|
232
|
+
status: "online",
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
id: "m3",
|
|
236
|
+
name: "Marcus Vance",
|
|
237
|
+
email: "mvance@acme.com",
|
|
238
|
+
fallback: "MV",
|
|
239
|
+
role: "member",
|
|
240
|
+
status: "away",
|
|
241
|
+
},
|
|
242
|
+
])
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div
|
|
246
|
+
className={cn(
|
|
247
|
+
"rounded-xl border border-zinc-200 bg-white overflow-hidden dark:border-zinc-800 dark:bg-zinc-900",
|
|
248
|
+
className
|
|
249
|
+
)}
|
|
250
|
+
>
|
|
251
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between border-b border-zinc-200 p-5 dark:border-zinc-800">
|
|
252
|
+
<div>
|
|
253
|
+
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
|
254
|
+
Team members
|
|
255
|
+
</h3>
|
|
256
|
+
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
|
257
|
+
Manage who has access to this workspace and their permissions.
|
|
258
|
+
</p>
|
|
259
|
+
</div>
|
|
260
|
+
<Button
|
|
261
|
+
variant="primary"
|
|
262
|
+
size="sm"
|
|
263
|
+
onClick={() =>
|
|
264
|
+
setMembers((prev) => [
|
|
265
|
+
...prev,
|
|
266
|
+
{
|
|
267
|
+
id: `m-${prev.length + 1}`,
|
|
268
|
+
name: "Aria Chen",
|
|
269
|
+
email: "aria@acme.com",
|
|
270
|
+
fallback: "AC",
|
|
271
|
+
role: "member",
|
|
272
|
+
status: "online",
|
|
273
|
+
},
|
|
274
|
+
])
|
|
275
|
+
}
|
|
276
|
+
>
|
|
277
|
+
<Plus className="h-3.5 w-3.5" />
|
|
278
|
+
Invite member
|
|
279
|
+
</Button>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
<div className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
|
283
|
+
{members.map((member, idx) => (
|
|
284
|
+
<div
|
|
285
|
+
key={member.id}
|
|
286
|
+
className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between px-5 py-3.5"
|
|
287
|
+
>
|
|
288
|
+
<div className="flex items-center gap-3">
|
|
289
|
+
<Avatar
|
|
290
|
+
fallback={member.fallback}
|
|
291
|
+
tint={idx % 2 === 0 ? "blue" : "neutral"}
|
|
292
|
+
status={member.status}
|
|
293
|
+
size="default"
|
|
294
|
+
/>
|
|
295
|
+
<div>
|
|
296
|
+
<p className="text-xs font-medium text-zinc-900 dark:text-zinc-100">
|
|
297
|
+
{member.name}
|
|
298
|
+
</p>
|
|
299
|
+
<p className="text-[11px] text-zinc-500">{member.email}</p>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
<div className="flex items-center gap-3">
|
|
304
|
+
<Select
|
|
305
|
+
value={member.role}
|
|
306
|
+
onValueChange={(newRole) =>
|
|
307
|
+
setMembers((prev) =>
|
|
308
|
+
prev.map((m) =>
|
|
309
|
+
m.id === member.id ? { ...m, role: newRole } : m
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
>
|
|
314
|
+
<SelectTrigger className="w-36 h-8 text-xs">
|
|
315
|
+
<SelectValue />
|
|
316
|
+
</SelectTrigger>
|
|
317
|
+
<SelectContent>
|
|
318
|
+
<SelectItem value="owner">Owner</SelectItem>
|
|
319
|
+
<SelectItem value="admin">Admin</SelectItem>
|
|
320
|
+
<SelectItem value="member">Member</SelectItem>
|
|
321
|
+
<SelectItem value="viewer">Billing Viewer</SelectItem>
|
|
322
|
+
</SelectContent>
|
|
323
|
+
</Select>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
))}
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export const BillingPlanCardBlock: React.FC<{ className?: string }> = ({
|
|
333
|
+
className,
|
|
334
|
+
}) => {
|
|
335
|
+
return (
|
|
336
|
+
<div
|
|
337
|
+
className={cn(
|
|
338
|
+
"rounded-xl border border-zinc-200 bg-white p-6 space-y-6 dark:border-zinc-800 dark:bg-zinc-900",
|
|
339
|
+
className
|
|
340
|
+
)}
|
|
341
|
+
>
|
|
342
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between border-b border-zinc-200 pb-5 dark:border-zinc-800">
|
|
343
|
+
<div>
|
|
344
|
+
<div className="flex items-center gap-2">
|
|
345
|
+
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
|
346
|
+
Pro Plan
|
|
347
|
+
</h3>
|
|
348
|
+
<Badge variant="blue">Active</Badge>
|
|
349
|
+
</div>
|
|
350
|
+
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
|
351
|
+
Includes unlimited projects, custom domains, and team collaboration.
|
|
352
|
+
</p>
|
|
353
|
+
</div>
|
|
354
|
+
<div className="text-right">
|
|
355
|
+
<div className="text-2xl font-bold text-zinc-900 dark:text-zinc-100">
|
|
356
|
+
$49<span className="text-xs font-normal text-zinc-400">/month</span>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
362
|
+
<div className="rounded-xl border border-zinc-200 bg-zinc-50/50 p-4 space-y-2 dark:border-zinc-800 dark:bg-zinc-800/40">
|
|
363
|
+
<Progress value={78} label="Team seats" showValue variant="blue" />
|
|
364
|
+
<p className="text-[11px] text-zinc-500">39 of 50 seats used</p>
|
|
365
|
+
</div>
|
|
366
|
+
<div className="rounded-xl border border-zinc-200 bg-zinc-50/50 p-4 space-y-2 dark:border-zinc-800 dark:bg-zinc-800/40">
|
|
367
|
+
<Progress
|
|
368
|
+
value={44}
|
|
369
|
+
label="Storage volume"
|
|
370
|
+
showValue
|
|
371
|
+
variant="emerald"
|
|
372
|
+
/>
|
|
373
|
+
<p className="text-[11px] text-zinc-500">44 GB of 100 GB</p>
|
|
374
|
+
</div>
|
|
375
|
+
<div className="rounded-xl border border-zinc-200 bg-zinc-50/50 p-4 space-y-2 dark:border-zinc-800 dark:bg-zinc-800/40">
|
|
376
|
+
<Progress value={62} label="Monthly bandwidth" showValue variant="blue" />
|
|
377
|
+
<p className="text-[11px] text-zinc-500">620 GB of 1 TB</p>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between rounded-xl border border-zinc-200 p-4 dark:border-zinc-800">
|
|
382
|
+
<div className="flex items-center gap-3">
|
|
383
|
+
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
|
|
384
|
+
<CreditCard className="h-4 w-4" />
|
|
385
|
+
</div>
|
|
386
|
+
<div>
|
|
387
|
+
<p className="text-xs font-medium text-zinc-900 dark:text-zinc-100">
|
|
388
|
+
Visa ending in 4242
|
|
389
|
+
</p>
|
|
390
|
+
<p className="text-[11px] text-zinc-500">
|
|
391
|
+
Expires 09/2028 • Default payment method
|
|
392
|
+
</p>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
<div className="flex items-center gap-2">
|
|
396
|
+
<Badge variant="emerald">
|
|
397
|
+
<Check className="h-3 w-3" /> Verified
|
|
398
|
+
</Badge>
|
|
399
|
+
<Button variant="outline" size="sm">
|
|
400
|
+
Edit card
|
|
401
|
+
</Button>
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
)
|
|
406
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { ArrowUpRight, ArrowDownRight } from "lucide-react"
|
|
3
|
+
import { cn } from "../utils/cn"
|
|
4
|
+
import { Badge } from "../primitives/badge"
|
|
5
|
+
import { Progress } from "../primitives/progress"
|
|
6
|
+
|
|
7
|
+
export interface StatMetricCardProps {
|
|
8
|
+
title: string
|
|
9
|
+
value: string
|
|
10
|
+
change: string
|
|
11
|
+
trend?: "up" | "down" | "neutral"
|
|
12
|
+
subtitle?: string
|
|
13
|
+
sparkline?: number[]
|
|
14
|
+
accent?: "blue" | "indigo" | "emerald" | "amber" | "rose"
|
|
15
|
+
className?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const strokeColors = {
|
|
19
|
+
blue: "#2563eb",
|
|
20
|
+
indigo: "#2563eb",
|
|
21
|
+
emerald: "#10b981",
|
|
22
|
+
amber: "#f59e0b",
|
|
23
|
+
rose: "#f43f5e",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const StatMetricCard: React.FC<StatMetricCardProps> = ({
|
|
27
|
+
title,
|
|
28
|
+
value,
|
|
29
|
+
change,
|
|
30
|
+
trend = "up",
|
|
31
|
+
subtitle = "vs. previous 30 days",
|
|
32
|
+
sparkline = [28, 34, 31, 45, 42, 56, 64, 61, 78, 86],
|
|
33
|
+
accent = "blue",
|
|
34
|
+
className,
|
|
35
|
+
}) => {
|
|
36
|
+
const min = Math.min(...sparkline)
|
|
37
|
+
const max = Math.max(...sparkline)
|
|
38
|
+
const range = Math.max(1, max - min)
|
|
39
|
+
const width = 104
|
|
40
|
+
const height = 34
|
|
41
|
+
|
|
42
|
+
const points = sparkline
|
|
43
|
+
.map((val, i) => {
|
|
44
|
+
const x = (i / Math.max(1, sparkline.length - 1)) * width
|
|
45
|
+
const y = height - ((val - min) / range) * (height - 8) - 4
|
|
46
|
+
return `${x.toFixed(1)},${y.toFixed(1)}`
|
|
47
|
+
})
|
|
48
|
+
.join(" ")
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
className={cn(
|
|
53
|
+
"rounded-xl border border-zinc-200 bg-white p-5 transition-colors dark:border-zinc-800 dark:bg-zinc-900",
|
|
54
|
+
className
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
<div className="flex items-center justify-between">
|
|
58
|
+
<span className="text-xs font-medium text-zinc-500 dark:text-zinc-400">
|
|
59
|
+
{title}
|
|
60
|
+
</span>
|
|
61
|
+
<Badge
|
|
62
|
+
variant={
|
|
63
|
+
trend === "up" ? "emerald" : trend === "down" ? "rose" : "default"
|
|
64
|
+
}
|
|
65
|
+
className="text-[11px]"
|
|
66
|
+
>
|
|
67
|
+
{trend === "up" ? (
|
|
68
|
+
<ArrowUpRight className="h-3 w-3" />
|
|
69
|
+
) : (
|
|
70
|
+
<ArrowDownRight className="h-3 w-3" />
|
|
71
|
+
)}
|
|
72
|
+
{change}
|
|
73
|
+
</Badge>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div className="mt-3 flex items-end justify-between gap-4">
|
|
77
|
+
<div>
|
|
78
|
+
<div className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
|
|
79
|
+
{value}
|
|
80
|
+
</div>
|
|
81
|
+
<p className="mt-1 text-[11px] text-zinc-400 dark:text-zinc-500">
|
|
82
|
+
{subtitle}
|
|
83
|
+
</p>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<svg
|
|
87
|
+
width={width}
|
|
88
|
+
height={height}
|
|
89
|
+
viewBox={`0 0 ${width} ${height}`}
|
|
90
|
+
className="overflow-visible shrink-0"
|
|
91
|
+
>
|
|
92
|
+
<polyline
|
|
93
|
+
fill="none"
|
|
94
|
+
stroke={strokeColors[accent]}
|
|
95
|
+
strokeWidth="2"
|
|
96
|
+
strokeLinecap="round"
|
|
97
|
+
strokeLinejoin="round"
|
|
98
|
+
points={points}
|
|
99
|
+
/>
|
|
100
|
+
</svg>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface MultiMetricGridProps {
|
|
107
|
+
metrics?: StatMetricCardProps[]
|
|
108
|
+
className?: string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const defaultMetrics: StatMetricCardProps[] = [
|
|
112
|
+
{
|
|
113
|
+
title: "Monthly Recurring Revenue",
|
|
114
|
+
value: "$128,430",
|
|
115
|
+
change: "+14.2%",
|
|
116
|
+
trend: "up",
|
|
117
|
+
subtitle: "Compared to $112.4k last month",
|
|
118
|
+
sparkline: [32, 38, 40, 48, 52, 60, 68, 74, 82, 95],
|
|
119
|
+
accent: "blue",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
title: "Active Subscribers",
|
|
123
|
+
value: "4,892",
|
|
124
|
+
change: "+8.4%",
|
|
125
|
+
trend: "up",
|
|
126
|
+
subtitle: "312 new trials converted",
|
|
127
|
+
sparkline: [40, 42, 49, 55, 53, 64, 72, 79, 88, 92],
|
|
128
|
+
accent: "emerald",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
title: "Net Revenue Retention",
|
|
132
|
+
value: "118.6%",
|
|
133
|
+
change: "+2.1%",
|
|
134
|
+
trend: "up",
|
|
135
|
+
subtitle: "Enterprise cohort expansion",
|
|
136
|
+
sparkline: [70, 72, 75, 78, 80, 84, 86, 89, 92, 95],
|
|
137
|
+
accent: "blue",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
title: "Logo Churn Rate",
|
|
141
|
+
value: "1.24%",
|
|
142
|
+
change: "-0.4%",
|
|
143
|
+
trend: "up",
|
|
144
|
+
subtitle: "Lowest in 4 quarters",
|
|
145
|
+
sparkline: [65, 58, 50, 44, 39, 32, 28, 22, 18, 12],
|
|
146
|
+
accent: "emerald",
|
|
147
|
+
},
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
export const MultiMetricGrid: React.FC<MultiMetricGridProps> = ({
|
|
151
|
+
metrics = defaultMetrics,
|
|
152
|
+
className,
|
|
153
|
+
}) => {
|
|
154
|
+
return (
|
|
155
|
+
<div
|
|
156
|
+
className={cn(
|
|
157
|
+
"grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4",
|
|
158
|
+
className
|
|
159
|
+
)}
|
|
160
|
+
>
|
|
161
|
+
{metrics.map((m) => (
|
|
162
|
+
<StatMetricCard key={m.title} {...m} />
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface ProgressMetricCardProps {
|
|
169
|
+
title?: string
|
|
170
|
+
current?: string
|
|
171
|
+
target?: string
|
|
172
|
+
percentage?: number
|
|
173
|
+
statusText?: string
|
|
174
|
+
variant?: "blue" | "indigo" | "emerald" | "amber" | "rose"
|
|
175
|
+
className?: string
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const ProgressMetricCard: React.FC<ProgressMetricCardProps> = ({
|
|
179
|
+
title = "Monthly API Usage",
|
|
180
|
+
current = "768,000",
|
|
181
|
+
target = "1,000,000 reqs",
|
|
182
|
+
percentage = 76.8,
|
|
183
|
+
statusText = "Resets in 9 days • Pay-as-you-go enabled",
|
|
184
|
+
variant = "blue",
|
|
185
|
+
className,
|
|
186
|
+
}) => {
|
|
187
|
+
return (
|
|
188
|
+
<div
|
|
189
|
+
className={cn(
|
|
190
|
+
"rounded-xl border border-zinc-200 bg-white p-5 space-y-4 dark:border-zinc-800 dark:bg-zinc-900",
|
|
191
|
+
className
|
|
192
|
+
)}
|
|
193
|
+
>
|
|
194
|
+
<div className="flex items-center justify-between">
|
|
195
|
+
<span className="text-xs font-medium text-zinc-700 dark:text-zinc-200">
|
|
196
|
+
{title}
|
|
197
|
+
</span>
|
|
198
|
+
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
|
199
|
+
<strong className="text-zinc-900 dark:text-white">{current}</strong> /{" "}
|
|
200
|
+
{target}
|
|
201
|
+
</span>
|
|
202
|
+
</div>
|
|
203
|
+
<Progress value={percentage} variant={variant} showValue />
|
|
204
|
+
<p className="text-[11px] text-zinc-400 dark:text-zinc-500">
|
|
205
|
+
{statusText}
|
|
206
|
+
</p>
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export type StatCardProps = StatMetricCardProps
|
|
212
|
+
export const StatCard = StatMetricCard
|