@actuate-media/cms-admin 0.21.0 → 0.22.0
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/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +24 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.js +30 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.js +66 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -0
- package/dist/__tests__/views/forms-list.render.test.d.ts +2 -0
- package/dist/__tests__/views/forms-list.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/forms-list.render.test.js +122 -0
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/ErrorBoundary.d.ts +1 -1
- package/dist/components/forms/Drawer.d.ts +13 -0
- package/dist/components/forms/Drawer.d.ts.map +1 -0
- package/dist/components/forms/Drawer.js +13 -0
- package/dist/components/forms/Drawer.js.map +1 -0
- package/dist/components/forms/EmbedPanel.d.ts +7 -0
- package/dist/components/forms/EmbedPanel.d.ts.map +1 -0
- package/dist/components/forms/EmbedPanel.js +91 -0
- package/dist/components/forms/EmbedPanel.js.map +1 -0
- package/dist/components/forms/FieldsPanel.d.ts +8 -0
- package/dist/components/forms/FieldsPanel.d.ts.map +1 -0
- package/dist/components/forms/FieldsPanel.js +123 -0
- package/dist/components/forms/FieldsPanel.js.map +1 -0
- package/dist/components/forms/FormSchemaDrawer.d.ts +9 -0
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +1 -0
- package/dist/components/forms/FormSchemaDrawer.js +96 -0
- package/dist/components/forms/FormSchemaDrawer.js.map +1 -0
- package/dist/components/forms/NotificationsPanel.d.ts +6 -0
- package/dist/components/forms/NotificationsPanel.d.ts.map +1 -0
- package/dist/components/forms/NotificationsPanel.js +80 -0
- package/dist/components/forms/NotificationsPanel.js.map +1 -0
- package/dist/components/forms/primitives.d.ts +42 -0
- package/dist/components/forms/primitives.d.ts.map +1 -0
- package/dist/components/forms/primitives.js +96 -0
- package/dist/components/forms/primitives.js.map +1 -0
- package/dist/layout/Sidebar.d.ts +5 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +143 -10
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/forms-events.d.ts +17 -0
- package/dist/lib/forms-events.d.ts.map +1 -0
- package/dist/lib/forms-events.js +20 -0
- package/dist/lib/forms-events.js.map +1 -0
- package/dist/lib/forms-service.d.ts +80 -0
- package/dist/lib/forms-service.d.ts.map +1 -0
- package/dist/lib/forms-service.js +144 -0
- package/dist/lib/forms-service.js.map +1 -0
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +119 -16
- package/dist/views/Forms.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +22 -0
- package/src/__tests__/layout/sidebar-forms-badge.render.test.tsx +50 -0
- package/src/__tests__/layout/sidebar-submenu.render.test.tsx +93 -0
- package/src/__tests__/views/forms-list.render.test.tsx +141 -0
- package/src/components/forms/Drawer.tsx +70 -0
- package/src/components/forms/EmbedPanel.tsx +173 -0
- package/src/components/forms/FieldsPanel.tsx +385 -0
- package/src/components/forms/FormSchemaDrawer.tsx +185 -0
- package/src/components/forms/NotificationsPanel.tsx +240 -0
- package/src/components/forms/primitives.tsx +200 -0
- package/src/layout/Sidebar.tsx +295 -14
- package/src/lib/forms-events.ts +32 -0
- package/src/lib/forms-service.ts +244 -0
- package/src/views/Forms.tsx +343 -104
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Notifications tab of the Form Schema drawer. Persists per-form notification
|
|
5
|
+
* settings (recipients, reply-to, subject/message templates, field inclusion,
|
|
6
|
+
* autoresponder). Actual email/webhook delivery + the "send test" action are
|
|
7
|
+
* wired in a later phase (PR 6); this panel owns the configuration only.
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useState } from 'react'
|
|
10
|
+
import * as Switch from '@radix-ui/react-switch'
|
|
11
|
+
import { toast } from 'sonner'
|
|
12
|
+
import {
|
|
13
|
+
fetchNotificationSettings,
|
|
14
|
+
updateNotificationSettings,
|
|
15
|
+
type FormNotificationSettings,
|
|
16
|
+
} from '../../lib/forms-service.js'
|
|
17
|
+
import { emitFormsChanged } from '../../lib/forms-events.js'
|
|
18
|
+
import { FormsErrorState, FormsLoading, btnPrimary } from './primitives.js'
|
|
19
|
+
|
|
20
|
+
const inputCls =
|
|
21
|
+
'border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
|
|
22
|
+
|
|
23
|
+
export function NotificationsPanel({
|
|
24
|
+
formId,
|
|
25
|
+
onChanged,
|
|
26
|
+
}: {
|
|
27
|
+
formId: string
|
|
28
|
+
/** Called after a successful save so the list can refresh the notify state. */
|
|
29
|
+
onChanged?: () => void
|
|
30
|
+
}) {
|
|
31
|
+
const [settings, setSettings] = useState<FormNotificationSettings | null>(null)
|
|
32
|
+
const [loading, setLoading] = useState(true)
|
|
33
|
+
const [error, setError] = useState<string | null>(null)
|
|
34
|
+
const [saving, setSaving] = useState(false)
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
let active = true
|
|
38
|
+
setLoading(true)
|
|
39
|
+
setError(null)
|
|
40
|
+
fetchNotificationSettings(formId)
|
|
41
|
+
.then((s) => {
|
|
42
|
+
if (active) setSettings(s)
|
|
43
|
+
})
|
|
44
|
+
.catch((e: unknown) => {
|
|
45
|
+
if (active)
|
|
46
|
+
setError(e instanceof Error ? e.message : 'Failed to load notification settings')
|
|
47
|
+
})
|
|
48
|
+
.finally(() => {
|
|
49
|
+
if (active) setLoading(false)
|
|
50
|
+
})
|
|
51
|
+
return () => {
|
|
52
|
+
active = false
|
|
53
|
+
}
|
|
54
|
+
}, [formId])
|
|
55
|
+
|
|
56
|
+
function patch(p: Partial<FormNotificationSettings>) {
|
|
57
|
+
setSettings((cur) => (cur ? { ...cur, ...p } : cur))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function save() {
|
|
61
|
+
if (!settings) return
|
|
62
|
+
setSaving(true)
|
|
63
|
+
const res = await updateNotificationSettings(formId, settings)
|
|
64
|
+
setSaving(false)
|
|
65
|
+
if (res.error) {
|
|
66
|
+
toast.error(res.error)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (res.data) setSettings(res.data)
|
|
70
|
+
toast.success('Notification settings saved.')
|
|
71
|
+
emitFormsChanged()
|
|
72
|
+
onChanged?.()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (loading) return <FormsLoading label="Loading notification settings…" />
|
|
76
|
+
if (error) return <FormsErrorState message={error} />
|
|
77
|
+
if (!settings) return null
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="space-y-4">
|
|
81
|
+
<ToggleRow
|
|
82
|
+
label="Enable notifications"
|
|
83
|
+
help="Email the recipients below when a new entry arrives."
|
|
84
|
+
checked={settings.enabled}
|
|
85
|
+
onChange={(v) => patch({ enabled: v })}
|
|
86
|
+
/>
|
|
87
|
+
|
|
88
|
+
<fieldset
|
|
89
|
+
disabled={!settings.enabled}
|
|
90
|
+
className="space-y-4 transition-opacity disabled:opacity-50"
|
|
91
|
+
>
|
|
92
|
+
<Field label="Recipients" hint="Comma-separated email addresses">
|
|
93
|
+
<input
|
|
94
|
+
type="text"
|
|
95
|
+
value={settings.recipients.join(', ')}
|
|
96
|
+
onChange={(e) =>
|
|
97
|
+
patch({
|
|
98
|
+
recipients: e.target.value
|
|
99
|
+
.split(',')
|
|
100
|
+
.map((s) => s.trim())
|
|
101
|
+
.filter(Boolean),
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
placeholder="team@example.com"
|
|
105
|
+
className={inputCls}
|
|
106
|
+
/>
|
|
107
|
+
</Field>
|
|
108
|
+
|
|
109
|
+
<Field label="Reply-to field key" hint="Field whose value is used as the reply-to address">
|
|
110
|
+
<input
|
|
111
|
+
type="text"
|
|
112
|
+
value={settings.replyToFieldKey ?? ''}
|
|
113
|
+
onChange={(e) => patch({ replyToFieldKey: e.target.value || null })}
|
|
114
|
+
placeholder="email"
|
|
115
|
+
className={inputCls}
|
|
116
|
+
/>
|
|
117
|
+
</Field>
|
|
118
|
+
|
|
119
|
+
<Field label="Subject template">
|
|
120
|
+
<input
|
|
121
|
+
type="text"
|
|
122
|
+
value={settings.subjectTemplate ?? ''}
|
|
123
|
+
onChange={(e) => patch({ subjectTemplate: e.target.value })}
|
|
124
|
+
placeholder="New submission from {{name}}"
|
|
125
|
+
className={inputCls}
|
|
126
|
+
/>
|
|
127
|
+
</Field>
|
|
128
|
+
|
|
129
|
+
<Field label="Message template">
|
|
130
|
+
<textarea
|
|
131
|
+
rows={3}
|
|
132
|
+
value={settings.messageTemplate ?? ''}
|
|
133
|
+
onChange={(e) => patch({ messageTemplate: e.target.value })}
|
|
134
|
+
className={inputCls}
|
|
135
|
+
/>
|
|
136
|
+
</Field>
|
|
137
|
+
|
|
138
|
+
<ToggleRow
|
|
139
|
+
label="Include all fields"
|
|
140
|
+
help="Append every submitted field to the notification body."
|
|
141
|
+
checked={settings.includeFields ?? true}
|
|
142
|
+
onChange={(v) => patch({ includeFields: v })}
|
|
143
|
+
/>
|
|
144
|
+
<ToggleRow
|
|
145
|
+
label="Exclude sensitive fields"
|
|
146
|
+
help="Omit fields marked as sensitive/PII from the email."
|
|
147
|
+
checked={settings.excludeSensitiveFields ?? false}
|
|
148
|
+
onChange={(v) => patch({ excludeSensitiveFields: v })}
|
|
149
|
+
/>
|
|
150
|
+
|
|
151
|
+
<div className="border-border border-t pt-4">
|
|
152
|
+
<ToggleRow
|
|
153
|
+
label="Auto-response email"
|
|
154
|
+
help="Send a confirmation email back to the submitter."
|
|
155
|
+
checked={settings.autoresponderEnabled ?? false}
|
|
156
|
+
onChange={(v) => patch({ autoresponderEnabled: v })}
|
|
157
|
+
/>
|
|
158
|
+
{settings.autoresponderEnabled && (
|
|
159
|
+
<div className="mt-3 space-y-3">
|
|
160
|
+
<Field label="Auto-response subject">
|
|
161
|
+
<input
|
|
162
|
+
type="text"
|
|
163
|
+
value={settings.autoresponderSubject ?? ''}
|
|
164
|
+
onChange={(e) => patch({ autoresponderSubject: e.target.value })}
|
|
165
|
+
className={inputCls}
|
|
166
|
+
/>
|
|
167
|
+
</Field>
|
|
168
|
+
<Field label="Auto-response body">
|
|
169
|
+
<textarea
|
|
170
|
+
rows={3}
|
|
171
|
+
value={settings.autoresponderBody ?? ''}
|
|
172
|
+
onChange={(e) => patch({ autoresponderBody: e.target.value })}
|
|
173
|
+
className={inputCls}
|
|
174
|
+
/>
|
|
175
|
+
</Field>
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
</fieldset>
|
|
180
|
+
|
|
181
|
+
<p className="text-muted-foreground text-xs">
|
|
182
|
+
Email & webhook delivery (including the test action) is wired in an upcoming release.
|
|
183
|
+
Settings saved here apply automatically once delivery is enabled.
|
|
184
|
+
</p>
|
|
185
|
+
|
|
186
|
+
<div className="flex justify-end">
|
|
187
|
+
<button className={btnPrimary} onClick={save} disabled={saving}>
|
|
188
|
+
{saving ? 'Saving…' : 'Save notifications'}
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function ToggleRow({
|
|
196
|
+
label,
|
|
197
|
+
help,
|
|
198
|
+
checked,
|
|
199
|
+
onChange,
|
|
200
|
+
}: {
|
|
201
|
+
label: string
|
|
202
|
+
help: string
|
|
203
|
+
checked: boolean
|
|
204
|
+
onChange: (v: boolean) => void
|
|
205
|
+
}) {
|
|
206
|
+
return (
|
|
207
|
+
<div className="flex items-center justify-between gap-3">
|
|
208
|
+
<span className="min-w-0">
|
|
209
|
+
<span className="text-foreground block text-sm font-medium">{label}</span>
|
|
210
|
+
<span className="text-muted-foreground block text-sm">{help}</span>
|
|
211
|
+
</span>
|
|
212
|
+
<Switch.Root
|
|
213
|
+
checked={checked}
|
|
214
|
+
onCheckedChange={onChange}
|
|
215
|
+
aria-label={label}
|
|
216
|
+
className="bg-input-background data-[state=checked]:bg-primary focus-visible:ring-ring relative h-[18px] w-8 shrink-0 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
217
|
+
>
|
|
218
|
+
<Switch.Thumb className="bg-background block h-3.5 w-3.5 translate-x-0.5 rounded-full shadow-sm transition-transform data-[state=checked]:translate-x-[14px]" />
|
|
219
|
+
</Switch.Root>
|
|
220
|
+
</div>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function Field({
|
|
225
|
+
label,
|
|
226
|
+
hint,
|
|
227
|
+
children,
|
|
228
|
+
}: {
|
|
229
|
+
label: string
|
|
230
|
+
hint?: string
|
|
231
|
+
children: React.ReactNode
|
|
232
|
+
}) {
|
|
233
|
+
return (
|
|
234
|
+
<label className="block">
|
|
235
|
+
<span className="text-foreground mb-1 block text-sm font-medium">{label}</span>
|
|
236
|
+
{children}
|
|
237
|
+
{hint && <span className="text-muted-foreground mt-1 block text-xs">{hint}</span>}
|
|
238
|
+
</label>
|
|
239
|
+
)
|
|
240
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared Forms UI primitives — token-only, accessible building blocks used
|
|
5
|
+
* across the Forms list, schema drawer, and (later) the entries views. No raw
|
|
6
|
+
* palette colours: field-type and status hues map onto the theme's semantic
|
|
7
|
+
* tokens (success / warning / info / destructive / primary / muted) so they
|
|
8
|
+
* track light/dark mode and brand overrides automatically.
|
|
9
|
+
*/
|
|
10
|
+
import type { ReactNode } from 'react'
|
|
11
|
+
import { AlertTriangle, Loader2 } from 'lucide-react'
|
|
12
|
+
import type { FormFieldType, FormStatus } from '../../lib/forms-service.js'
|
|
13
|
+
|
|
14
|
+
// ─── Field-type badges ─────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/** Map a field type onto a semantic colour pair (background + text). */
|
|
17
|
+
function fieldTypeTone(type: FormFieldType): string {
|
|
18
|
+
switch (type) {
|
|
19
|
+
case 'email':
|
|
20
|
+
case 'url':
|
|
21
|
+
case 'date':
|
|
22
|
+
case 'number':
|
|
23
|
+
return 'bg-info/10 text-info'
|
|
24
|
+
case 'phone':
|
|
25
|
+
case 'textarea':
|
|
26
|
+
return 'bg-primary/10 text-primary'
|
|
27
|
+
case 'select':
|
|
28
|
+
case 'multiselect':
|
|
29
|
+
case 'radio':
|
|
30
|
+
return 'bg-warning/10 text-warning'
|
|
31
|
+
case 'checkbox':
|
|
32
|
+
case 'consent':
|
|
33
|
+
return 'bg-success/10 text-success'
|
|
34
|
+
case 'file':
|
|
35
|
+
return 'bg-destructive/10 text-destructive'
|
|
36
|
+
default:
|
|
37
|
+
return 'bg-muted text-muted-foreground'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const FIELD_TYPE_LABELS: Partial<Record<FormFieldType, string>> = {
|
|
42
|
+
text: 'TEXT',
|
|
43
|
+
email: 'EMAIL',
|
|
44
|
+
phone: 'PHONE',
|
|
45
|
+
textarea: 'TEXTAREA',
|
|
46
|
+
select: 'SELECT',
|
|
47
|
+
multiselect: 'MULTI',
|
|
48
|
+
radio: 'RADIO',
|
|
49
|
+
checkbox: 'CHECKBOX',
|
|
50
|
+
date: 'DATE',
|
|
51
|
+
number: 'NUMBER',
|
|
52
|
+
url: 'URL',
|
|
53
|
+
hidden: 'HIDDEN',
|
|
54
|
+
file: 'FILE',
|
|
55
|
+
consent: 'CONSENT',
|
|
56
|
+
richtext: 'CONTENT',
|
|
57
|
+
divider: 'DIVIDER',
|
|
58
|
+
honeypot: 'HONEYPOT',
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function FieldTypeBadge({ type }: { type: FormFieldType }) {
|
|
62
|
+
return (
|
|
63
|
+
<span
|
|
64
|
+
className={`inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase ${fieldTypeTone(type)}`}
|
|
65
|
+
>
|
|
66
|
+
{FIELD_TYPE_LABELS[type] ?? type}
|
|
67
|
+
</span>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Status badges ─────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
const STATUS_META: Record<FormStatus, { label: string; cls: string; dot: string }> = {
|
|
74
|
+
active: { label: 'Active', cls: 'bg-success/10 text-success', dot: 'bg-success' },
|
|
75
|
+
draft: { label: 'Draft', cls: 'bg-warning/10 text-warning', dot: 'bg-warning' },
|
|
76
|
+
archived: {
|
|
77
|
+
label: 'Archived',
|
|
78
|
+
cls: 'bg-muted text-muted-foreground',
|
|
79
|
+
dot: 'bg-muted-foreground',
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function FormStatusBadge({ status }: { status: FormStatus }) {
|
|
84
|
+
const meta = STATUS_META[status] ?? STATUS_META.draft
|
|
85
|
+
return (
|
|
86
|
+
<span
|
|
87
|
+
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${meta.cls}`}
|
|
88
|
+
>
|
|
89
|
+
<span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} aria-hidden />
|
|
90
|
+
{meta.label}
|
|
91
|
+
</span>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Small red count badge for unread submissions. Hidden when count is 0. */
|
|
96
|
+
export function UnreadBadge({ count, className = '' }: { count: number; className?: string }) {
|
|
97
|
+
if (count <= 0) return null
|
|
98
|
+
return (
|
|
99
|
+
<span
|
|
100
|
+
className={`bg-destructive text-destructive-foreground inline-flex min-w-5 items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums ${className}`}
|
|
101
|
+
aria-label={`${count} unread`}
|
|
102
|
+
>
|
|
103
|
+
{count > 99 ? '99+' : count}
|
|
104
|
+
</span>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Summary chips ───────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
export function SummaryChip({
|
|
111
|
+
value,
|
|
112
|
+
label,
|
|
113
|
+
tone = 'neutral',
|
|
114
|
+
}: {
|
|
115
|
+
value: ReactNode
|
|
116
|
+
label: string
|
|
117
|
+
tone?: 'neutral' | 'success' | 'warning' | 'destructive'
|
|
118
|
+
}) {
|
|
119
|
+
const valueCls =
|
|
120
|
+
tone === 'success'
|
|
121
|
+
? 'text-success'
|
|
122
|
+
: tone === 'warning'
|
|
123
|
+
? 'text-warning'
|
|
124
|
+
: tone === 'destructive'
|
|
125
|
+
? 'text-destructive'
|
|
126
|
+
: 'text-foreground'
|
|
127
|
+
return (
|
|
128
|
+
<div className="border-border bg-card flex items-baseline gap-1.5 rounded-lg border px-3 py-2">
|
|
129
|
+
<span className={`text-base font-medium tabular-nums ${valueCls}`}>{value}</span>
|
|
130
|
+
<span className="text-muted-foreground text-sm">{label}</span>
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── States ──────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
export function FormsLoading({ label = 'Loading…' }: { label?: string }) {
|
|
138
|
+
return (
|
|
139
|
+
<div className="flex h-64 items-center justify-center" role="status" aria-live="polite">
|
|
140
|
+
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" aria-hidden />
|
|
141
|
+
<span className="sr-only">{label}</span>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function FormsEmptyState({
|
|
147
|
+
icon,
|
|
148
|
+
title,
|
|
149
|
+
description,
|
|
150
|
+
action,
|
|
151
|
+
}: {
|
|
152
|
+
icon?: ReactNode
|
|
153
|
+
title: string
|
|
154
|
+
description?: string
|
|
155
|
+
action?: ReactNode
|
|
156
|
+
}) {
|
|
157
|
+
return (
|
|
158
|
+
<div className="border-border flex flex-col items-center justify-center rounded-lg border border-dashed px-6 py-12 text-center">
|
|
159
|
+
{icon && (
|
|
160
|
+
<div className="text-muted-foreground mb-3" aria-hidden>
|
|
161
|
+
{icon}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
<h3 className="text-foreground text-base font-medium">{title}</h3>
|
|
165
|
+
{description && <p className="text-muted-foreground mt-1 max-w-md text-sm">{description}</p>}
|
|
166
|
+
{action && <div className="mt-4">{action}</div>}
|
|
167
|
+
</div>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function FormsErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
|
172
|
+
return (
|
|
173
|
+
<div
|
|
174
|
+
className="border-destructive/30 bg-destructive/5 flex items-center gap-3 rounded-lg border p-3"
|
|
175
|
+
role="alert"
|
|
176
|
+
>
|
|
177
|
+
<AlertTriangle className="text-destructive h-5 w-5 shrink-0" aria-hidden />
|
|
178
|
+
<span className="text-destructive flex-1 text-sm">{message}</span>
|
|
179
|
+
{onRetry && (
|
|
180
|
+
<button
|
|
181
|
+
onClick={onRetry}
|
|
182
|
+
className="border-border text-foreground hover:bg-accent rounded-md border px-3 py-1 text-sm transition-colors"
|
|
183
|
+
>
|
|
184
|
+
Retry
|
|
185
|
+
</button>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── Button helpers (token-only) ─────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
export const btnPrimary =
|
|
194
|
+
'inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-base font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60'
|
|
195
|
+
|
|
196
|
+
export const btnSecondary =
|
|
197
|
+
'inline-flex items-center justify-center gap-2 rounded-md border border-border bg-card px-4 py-2 text-base font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60'
|
|
198
|
+
|
|
199
|
+
export const btnGhostIcon =
|
|
200
|
+
'inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|