@actuate-media/cms-admin 0.76.6 → 0.77.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/__tests__/lib/preview-link.test.d.ts +2 -0
  3. package/dist/__tests__/lib/preview-link.test.d.ts.map +1 -0
  4. package/dist/__tests__/lib/preview-link.test.js +27 -0
  5. package/dist/__tests__/lib/preview-link.test.js.map +1 -0
  6. package/dist/__tests__/views/editor-top-bar.render.test.js +19 -0
  7. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  8. package/dist/__tests__/views/form-editor.render.test.d.ts +2 -0
  9. package/dist/__tests__/views/form-editor.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/form-editor.render.test.js +146 -0
  11. package/dist/__tests__/views/form-editor.render.test.js.map +1 -0
  12. package/dist/actuate-admin.css +1 -1
  13. package/dist/lib/forms-service.d.ts +15 -3
  14. package/dist/lib/forms-service.d.ts.map +1 -1
  15. package/dist/lib/forms-service.js.map +1 -1
  16. package/dist/lib/preview-link.d.ts +7 -0
  17. package/dist/lib/preview-link.d.ts.map +1 -1
  18. package/dist/lib/preview-link.js +16 -0
  19. package/dist/lib/preview-link.js.map +1 -1
  20. package/dist/views/FormEditor.d.ts.map +1 -1
  21. package/dist/views/FormEditor.js +211 -116
  22. package/dist/views/FormEditor.js.map +1 -1
  23. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  24. package/dist/views/page-editor/EditorTopBar.js +6 -1
  25. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  26. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  27. package/dist/views/page-editor/PageSectionEditor.js +14 -1
  28. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  29. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  30. package/dist/views/post-editor/PostSectionEditor.js +11 -1
  31. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  32. package/package.json +9 -9
  33. package/src/__tests__/lib/preview-link.test.ts +39 -0
  34. package/src/__tests__/views/editor-top-bar.render.test.tsx +22 -0
  35. package/src/__tests__/views/form-editor.render.test.tsx +186 -0
  36. package/src/lib/forms-service.ts +16 -1
  37. package/src/lib/preview-link.ts +21 -0
  38. package/src/views/FormEditor.tsx +655 -510
  39. package/src/views/page-editor/EditorTopBar.tsx +11 -4
  40. package/src/views/page-editor/PageSectionEditor.tsx +18 -0
  41. package/src/views/post-editor/PostSectionEditor.tsx +15 -0
@@ -1,63 +1,90 @@
1
1
  'use client'
2
2
 
3
- import { useState, useCallback, useEffect } from 'react'
3
+ /**
4
+ * Full-page form editor — the canonical place to configure everything about a
5
+ * form. Backed entirely by the typed Forms API (`lib/forms-service.ts`), the
6
+ * same backend as the Forms list's inline Schema pane, so the two surfaces can
7
+ * never drift:
8
+ *
9
+ * - Details → name / description / status + post-submit confirmation
10
+ * (`PATCH /forms/:id` — `successMessage` / `redirectUrl`,
11
+ * which the public submission pipeline actually reads)
12
+ * - Fields → versioned schema editor (`PUT /forms/:id/schema`)
13
+ * - Notifications→ shared NotificationsPanel (`/forms/:id/notifications`)
14
+ * - Analytics → GA4 event tracking config (persisted on the definition,
15
+ * exposed on the public schema, fired by `<ActuateForm>`)
16
+ * - Embed → shared EmbedPanel (endpoints, snippet, allowed domains)
17
+ * - Integrations → shared WebhooksPanel (signed CRM/Zapier webhooks)
18
+ *
19
+ * New forms get a focused create screen (details + starting fields); saving
20
+ * routes into this editor so notifications/embed/webhooks — which need a form
21
+ * id — are one click away.
22
+ */
23
+ import * as Tabs from '@radix-ui/react-tabs'
24
+ import { ArrowLeft, ExternalLink, Inbox, Loader2, MessageSquare, Save } from 'lucide-react'
25
+ import { useCallback, useEffect, useState } from 'react'
26
+ import { toast } from 'sonner'
27
+ import { EmbedPanel } from '../components/forms/EmbedPanel.js'
28
+ import { FieldsPanel } from '../components/forms/FieldsPanel.js'
29
+ import { NotificationsPanel } from '../components/forms/NotificationsPanel.js'
30
+ import { WebhooksPanel } from '../components/forms/WebhooksPanel.js'
4
31
  import {
5
- Save,
6
- ArrowLeft,
7
- Plus,
8
- Trash2,
9
- GripVertical,
10
- MessageSquare,
11
- ExternalLink,
12
- BarChart3,
13
- ChevronDown,
14
- ChevronRight,
15
- Loader2,
16
- } from 'lucide-react'
17
- import { useApiData } from '../lib/useApiData.js'
18
- import { cmsApi } from '../lib/api.js'
32
+ FormStatusBadge,
33
+ FormsErrorState,
34
+ FormsLoading,
35
+ btnPrimary,
36
+ btnSecondary,
37
+ } from '../components/forms/primitives.js'
19
38
  import { Toggle } from '../components/ui/Toggle.js'
39
+ import { emitFormsChanged } from '../lib/forms-events.js'
40
+ import {
41
+ createForm,
42
+ fetchFormById,
43
+ fetchFormSchema,
44
+ saveFormSchema,
45
+ updateForm,
46
+ type FormAnalyticsConfig,
47
+ type FormDefinition,
48
+ type FormField,
49
+ type FormStatus,
50
+ } from '../lib/forms-service.js'
51
+ import { useNavigationBlocker } from '../router/index.js'
20
52
 
21
- interface FormField {
22
- id: string
23
- type: string
24
- label: string
25
- placeholder?: string
26
- required: boolean
27
- options?: string[]
28
- }
53
+ const inputCls =
54
+ 'border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-lg border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none'
29
55
 
30
- interface ConfirmationConfig {
31
- type: 'message' | 'redirect'
32
- message: string
56
+ const TABS = [
57
+ { id: 'details', label: 'Details' },
58
+ { id: 'fields', label: 'Fields' },
59
+ { id: 'notifications', label: 'Notifications' },
60
+ { id: 'analytics', label: 'Analytics' },
61
+ { id: 'embed', label: 'Embed' },
62
+ { id: 'integrations', label: 'Integrations' },
63
+ ] as const
64
+
65
+ interface DetailsState {
66
+ name: string
67
+ description: string
68
+ status: FormStatus
69
+ confirmationType: 'message' | 'redirect'
70
+ successMessage: string
33
71
  redirectUrl: string
34
- redirectDelay: number
35
72
  }
36
73
 
37
- interface AnalyticsConfig {
38
- enabled: boolean
39
- measurementId: string
40
- submitEventName: string
41
- startEventName: string
42
- trackAsConversion: boolean
43
- conversionValue: number
44
- conversionCurrency: string
45
- customParameters: Record<string, string>
74
+ function detailsFromForm(form: FormDefinition): DetailsState {
75
+ return {
76
+ name: form.name,
77
+ description: form.description ?? '',
78
+ status: form.status,
79
+ // The submission pipeline redirects whenever redirectUrl is non-empty,
80
+ // otherwise it shows successMessage — mirror that decision here.
81
+ confirmationType: form.redirectUrl?.trim() ? 'redirect' : 'message',
82
+ successMessage: form.successMessage ?? '',
83
+ redirectUrl: form.redirectUrl ?? '',
84
+ }
46
85
  }
47
86
 
48
- const FIELD_TYPES = [
49
- { value: 'text', label: 'Text' },
50
- { value: 'email', label: 'Email' },
51
- { value: 'tel', label: 'Phone' },
52
- { value: 'textarea', label: 'Textarea' },
53
- { value: 'select', label: 'Dropdown' },
54
- { value: 'checkbox', label: 'Checkbox' },
55
- { value: 'number', label: 'Number' },
56
- { value: 'date', label: 'Date' },
57
- { value: 'url', label: 'URL' },
58
- { value: 'file', label: 'File Upload' },
59
- { value: 'hidden', label: 'Hidden' },
60
- ] as const
87
+ const DEFAULT_ANALYTICS: FormAnalyticsConfig = { enabled: false }
61
88
 
62
89
  export interface FormEditorProps {
63
90
  formId?: string
@@ -65,535 +92,653 @@ export interface FormEditorProps {
65
92
  }
66
93
 
67
94
  export function FormEditor({ formId, onNavigate }: FormEditorProps) {
68
- const isNew = !formId
69
- const { data: existingForm, loading } = useApiData<any>(
70
- formId ? `/collections/forms/${formId}` : null,
71
- )
95
+ if (!formId) {
96
+ return <NewFormEditor onNavigate={onNavigate} />
97
+ }
98
+ return <ExistingFormEditor formId={formId} onNavigate={onNavigate} />
99
+ }
100
+
101
+ // ─── Existing form ─────────────────────────────────────────────────────────
102
+
103
+ function ExistingFormEditor({
104
+ formId,
105
+ onNavigate,
106
+ }: {
107
+ formId: string
108
+ onNavigate?: (path: string) => void
109
+ }) {
110
+ const [form, setForm] = useState<FormDefinition | null>(null)
111
+ const [loading, setLoading] = useState(true)
112
+ const [error, setError] = useState<string | null>(null)
113
+ const [tab, setTab] = useState<string>('details')
114
+
115
+ const [details, setDetails] = useState<DetailsState | null>(null)
116
+ const [detailsDirty, setDetailsDirty] = useState(false)
117
+ const [savingDetails, setSavingDetails] = useState(false)
72
118
 
73
- const [name, setName] = useState('')
74
- const [description, setDescription] = useState('')
75
- const [status, setStatus] = useState('active')
76
119
  const [fields, setFields] = useState<FormField[]>([])
77
- const [saving, setSaving] = useState(false)
78
- const [expandedSection, setExpandedSection] = useState<string | null>('fields')
79
-
80
- const [confirmation, setConfirmation] = useState<ConfirmationConfig>({
81
- type: 'message',
82
- message: 'Thank you! Your submission has been received.',
83
- redirectUrl: '',
84
- redirectDelay: 0,
85
- })
86
-
87
- const [analytics, setAnalytics] = useState<AnalyticsConfig>({
88
- enabled: false,
89
- measurementId: '',
90
- submitEventName: 'form_submit',
91
- startEventName: 'form_start',
92
- trackAsConversion: false,
93
- conversionValue: 0,
94
- conversionCurrency: 'USD',
95
- customParameters: {},
96
- })
120
+ const [fieldsDirty, setFieldsDirty] = useState(false)
121
+ const [savingFields, setSavingFields] = useState(false)
97
122
 
98
- useEffect(() => {
99
- if (existingForm) {
100
- const d = existingForm.data ?? existingForm
101
- setName(d.name ?? '')
102
- setDescription(d.description ?? '')
103
- setStatus(d.status ?? 'active')
104
- if (Array.isArray(d.fields)) setFields(d.fields)
105
- if (d.confirmation) {
106
- setConfirmation((prev) => ({ ...prev, ...d.confirmation }))
107
- }
108
- if (d.analytics) {
109
- setAnalytics((prev) => ({ ...prev, ...d.analytics }))
110
- }
111
- }
112
- }, [existingForm])
113
-
114
- const addField = useCallback((type: string) => {
115
- setFields((prev) => [
116
- ...prev,
117
- {
118
- id: crypto.randomUUID(),
119
- type,
120
- label: `New ${type} field`,
121
- required: false,
122
- },
123
- ])
124
- }, [])
125
-
126
- const removeField = useCallback((id: string) => {
127
- setFields((prev) => prev.filter((f) => f.id !== id))
128
- }, [])
129
-
130
- const updateField = useCallback((id: string, updates: Partial<FormField>) => {
131
- setFields((prev) => prev.map((f) => (f.id === id ? { ...f, ...updates } : f)))
132
- }, [])
133
-
134
- const moveField = useCallback((from: number, to: number) => {
135
- setFields((prev) => {
136
- const next = [...prev]
137
- const [moved] = next.splice(from, 1)
138
- next.splice(to, 0, moved!)
139
- return next
140
- })
141
- }, [])
123
+ const [analytics, setAnalytics] = useState<FormAnalyticsConfig>(DEFAULT_ANALYTICS)
124
+ const [analyticsDirty, setAnalyticsDirty] = useState(false)
125
+ const [savingAnalytics, setSavingAnalytics] = useState(false)
142
126
 
143
- const handleSave = async () => {
144
- setSaving(true)
145
- try {
146
- const payload = {
147
- name,
148
- description,
149
- status,
150
- fields,
151
- confirmation,
152
- analytics: analytics.enabled ? analytics : { enabled: false },
153
- }
127
+ useNavigationBlocker(detailsDirty || fieldsDirty || analyticsDirty)
154
128
 
155
- if (isNew) {
156
- await cmsApi('/collections/forms', {
157
- method: 'POST',
158
- body: JSON.stringify(payload),
159
- })
160
- } else {
161
- await cmsApi(`/collections/forms/${formId}`, {
162
- method: 'PUT',
163
- body: JSON.stringify(payload),
164
- })
129
+ const load = useCallback(async () => {
130
+ setLoading(true)
131
+ setError(null)
132
+ try {
133
+ const [loaded, schema] = await Promise.all([fetchFormById(formId), fetchFormSchema(formId)])
134
+ if (!loaded) {
135
+ setError('Form not found')
136
+ return
165
137
  }
166
- onNavigate?.('/forms')
138
+ setForm(loaded)
139
+ setDetails(detailsFromForm(loaded))
140
+ setAnalytics(loaded.analytics ?? DEFAULT_ANALYTICS)
141
+ setFields(schema.fields)
142
+ setDetailsDirty(false)
143
+ setFieldsDirty(false)
144
+ setAnalyticsDirty(false)
145
+ } catch (e: unknown) {
146
+ setError(e instanceof Error ? e.message : 'Failed to load form')
167
147
  } finally {
168
- setSaving(false)
148
+ setLoading(false)
169
149
  }
150
+ }, [formId])
151
+
152
+ useEffect(() => {
153
+ void load()
154
+ }, [load])
155
+
156
+ function patchDetails(patch: Partial<DetailsState>) {
157
+ setDetails((cur) => (cur ? { ...cur, ...patch } : cur))
158
+ setDetailsDirty(true)
170
159
  }
171
160
 
172
- const toggleSection = (section: string) => {
173
- setExpandedSection(expandedSection === section ? null : section)
161
+ function patchAnalytics(patch: Partial<FormAnalyticsConfig>) {
162
+ setAnalytics((cur) => ({ ...cur, ...patch }))
163
+ setAnalyticsDirty(true)
164
+ }
165
+
166
+ async function saveDetails() {
167
+ if (!details) return
168
+ setSavingDetails(true)
169
+ const res = await updateForm(formId, {
170
+ name: details.name,
171
+ description: details.description,
172
+ status: details.status,
173
+ successMessage: details.successMessage,
174
+ // Message mode must clear the URL — a non-empty redirectUrl always wins
175
+ // in the submission pipeline.
176
+ redirectUrl: details.confirmationType === 'redirect' ? details.redirectUrl : '',
177
+ })
178
+ setSavingDetails(false)
179
+ if (res.error) {
180
+ toast.error(res.error)
181
+ return
182
+ }
183
+ if (res.data) {
184
+ setForm(res.data)
185
+ setDetails(detailsFromForm(res.data))
186
+ }
187
+ setDetailsDirty(false)
188
+ toast.success('Form details saved.')
189
+ emitFormsChanged()
190
+ }
191
+
192
+ async function saveFields() {
193
+ setSavingFields(true)
194
+ const res = await saveFormSchema(
195
+ formId,
196
+ fields.map((f, i) => ({ ...f, sortOrder: i })),
197
+ )
198
+ setSavingFields(false)
199
+ if (res.error) {
200
+ toast.error(res.error)
201
+ return
202
+ }
203
+ setFieldsDirty(false)
204
+ toast.success('Schema saved.')
205
+ emitFormsChanged()
206
+ }
207
+
208
+ async function saveAnalytics() {
209
+ setSavingAnalytics(true)
210
+ const res = await updateForm(formId, { analytics })
211
+ setSavingAnalytics(false)
212
+ if (res.error) {
213
+ toast.error(res.error)
214
+ return
215
+ }
216
+ if (res.data) setAnalytics(res.data.analytics ?? { enabled: false })
217
+ setAnalyticsDirty(false)
218
+ toast.success('Analytics settings saved.')
174
219
  }
175
220
 
176
221
  if (loading) {
177
222
  return (
178
- <div className="flex h-64 items-center justify-center p-4">
179
- <Loader2 className="text-primary h-6 w-6 animate-spin" />
223
+ <div className="max-w-4xl p-3 pr-6 sm:p-4 sm:pr-8">
224
+ <FormsLoading label="Loading form…" />
225
+ </div>
226
+ )
227
+ }
228
+
229
+ if (error || !form || !details) {
230
+ return (
231
+ <div className="max-w-4xl p-3 pr-6 sm:p-4 sm:pr-8">
232
+ <FormsErrorState message={error ?? 'Form not found'} onRetry={() => void load()} />
180
233
  </div>
181
234
  )
182
235
  }
183
236
 
184
237
  return (
185
238
  <div className="max-w-4xl p-3 pr-6 sm:p-4 sm:pr-8">
186
- {/* Header */}
187
239
  <div className="mb-6 flex items-center gap-3">
188
240
  <button
189
241
  onClick={() => onNavigate?.('/forms')}
190
- className="hover:bg-muted rounded-lg p-2 transition-colors"
242
+ aria-label="Back to forms"
243
+ className="hover:bg-muted focus-visible:ring-ring rounded-lg p-2 transition-colors focus-visible:ring-2 focus-visible:outline-none"
191
244
  >
192
- <ArrowLeft className="text-muted-foreground h-5 w-5" />
245
+ <ArrowLeft className="text-muted-foreground h-5 w-5" aria-hidden />
193
246
  </button>
194
- <div className="flex-1">
195
- <h1 className="text-foreground text-xl font-medium">
196
- {isNew ? 'New Form' : 'Edit Form'}
197
- </h1>
247
+ <div className="min-w-0 flex-1">
248
+ <div className="flex items-center gap-2">
249
+ <h1 className="text-foreground truncate text-xl font-medium">{form.name}</h1>
250
+ <FormStatusBadge status={form.status} />
251
+ </div>
252
+ <p className="text-muted-foreground truncate font-mono text-xs">/{form.slug}</p>
198
253
  </div>
199
254
  <button
200
- onClick={handleSave}
201
- disabled={saving || !name.trim()}
202
- className="bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-2 rounded-lg px-4 py-2 text-sm transition-colors disabled:opacity-50"
255
+ onClick={() => onNavigate?.(`/forms/${formId}/submissions`)}
256
+ className={btnSecondary}
203
257
  >
204
- {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
205
- {saving ? 'Saving...' : 'Save'}
258
+ <Inbox className="h-4 w-4" aria-hidden />
259
+ View Entries
206
260
  </button>
207
261
  </div>
208
262
 
209
- {/* Basic info */}
210
- <div className="border-border bg-card mb-4 rounded-lg border p-4">
263
+ <Tabs.Root value={tab} onValueChange={setTab}>
264
+ <Tabs.List
265
+ className="border-border mb-4 flex gap-0.5 overflow-x-auto border-b"
266
+ aria-label="Form settings sections"
267
+ >
268
+ {TABS.map((t) => (
269
+ <Tabs.Trigger
270
+ key={t.id}
271
+ value={t.id}
272
+ className="text-muted-foreground hover:text-foreground focus-visible:ring-ring data-[state=active]:border-primary data-[state=active]:text-foreground -mb-px border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:outline-none"
273
+ >
274
+ {t.label}
275
+ </Tabs.Trigger>
276
+ ))}
277
+ </Tabs.List>
278
+
279
+ <Tabs.Content value="details" tabIndex={-1}>
280
+ <DetailsTab
281
+ details={details}
282
+ onPatch={patchDetails}
283
+ onSave={() => void saveDetails()}
284
+ saving={savingDetails}
285
+ />
286
+ </Tabs.Content>
287
+
288
+ <Tabs.Content value="fields" tabIndex={-1}>
289
+ <div className="border-border bg-card space-y-4 rounded-lg border p-4">
290
+ <FieldsPanel
291
+ fields={fields}
292
+ onChange={(next) => {
293
+ setFields(next)
294
+ setFieldsDirty(true)
295
+ }}
296
+ hasSubmissions={(form.totalEntries ?? 0) > 0}
297
+ />
298
+ <div className="flex justify-end">
299
+ <button
300
+ className={btnPrimary}
301
+ onClick={() => void saveFields()}
302
+ disabled={savingFields || !fieldsDirty}
303
+ >
304
+ {savingFields ? (
305
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
306
+ ) : (
307
+ <Save className="h-4 w-4" aria-hidden />
308
+ )}
309
+ {savingFields ? 'Saving…' : 'Save Schema'}
310
+ </button>
311
+ </div>
312
+ </div>
313
+ </Tabs.Content>
314
+
315
+ <Tabs.Content value="notifications" tabIndex={-1}>
316
+ <div className="border-border bg-card rounded-lg border p-4">
317
+ <NotificationsPanel formId={formId} />
318
+ </div>
319
+ </Tabs.Content>
320
+
321
+ <Tabs.Content value="analytics" tabIndex={-1}>
322
+ <AnalyticsTab
323
+ analytics={analytics}
324
+ onPatch={patchAnalytics}
325
+ onSave={() => void saveAnalytics()}
326
+ saving={savingAnalytics}
327
+ />
328
+ </Tabs.Content>
329
+
330
+ <Tabs.Content value="embed" tabIndex={-1}>
331
+ <div className="border-border bg-card rounded-lg border p-4">
332
+ <EmbedPanel formId={formId} slug={form.slug} status={form.status} />
333
+ </div>
334
+ </Tabs.Content>
335
+
336
+ <Tabs.Content value="integrations" tabIndex={-1}>
337
+ <div className="border-border bg-card rounded-lg border p-4">
338
+ <WebhooksPanel formId={formId} formSlug={form.slug} />
339
+ </div>
340
+ </Tabs.Content>
341
+ </Tabs.Root>
342
+ </div>
343
+ )
344
+ }
345
+
346
+ // ─── Details tab ────────────────────────────────────────────────────────────
347
+
348
+ function DetailsTab({
349
+ details,
350
+ onPatch,
351
+ onSave,
352
+ saving,
353
+ }: {
354
+ details: DetailsState
355
+ onPatch: (patch: Partial<DetailsState>) => void
356
+ onSave: () => void
357
+ saving: boolean
358
+ }) {
359
+ return (
360
+ <div className="space-y-4">
361
+ <div className="border-border bg-card rounded-lg border p-4">
211
362
  <div className="grid gap-4">
212
- <div>
213
- <label className="text-foreground mb-1 block text-sm font-medium">Form Name</label>
363
+ <Field label="Form Name">
214
364
  <input
215
365
  type="text"
216
- value={name}
217
- onChange={(e) => setName(e.target.value)}
366
+ value={details.name}
367
+ onChange={(e) => onPatch({ name: e.target.value })}
218
368
  placeholder="e.g. Contact Form"
219
- className="border-border focus-visible:ring-ring/50 w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
369
+ className={inputCls}
220
370
  />
221
- </div>
222
- <div>
223
- <label className="text-foreground mb-1 block text-sm font-medium">Description</label>
371
+ </Field>
372
+ <Field label="Description">
224
373
  <input
225
374
  type="text"
226
- value={description}
227
- onChange={(e) => setDescription(e.target.value)}
375
+ value={details.description}
376
+ onChange={(e) => onPatch({ description: e.target.value })}
228
377
  placeholder="Brief description of this form"
229
- className="border-border focus-visible:ring-ring/50 w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
378
+ className={inputCls}
230
379
  />
231
- </div>
232
- <div>
233
- <label className="text-foreground mb-1 block text-sm font-medium">Status</label>
380
+ </Field>
381
+ <Field
382
+ label="Status"
383
+ hint="Only active forms accept submissions; archived forms are hidden from embed pickers."
384
+ >
234
385
  <select
235
- value={status}
236
- onChange={(e) => setStatus(e.target.value)}
237
- className="border-border focus-visible:ring-ring/50 rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
386
+ value={details.status}
387
+ onChange={(e) => onPatch({ status: e.target.value as FormStatus })}
388
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring rounded-lg border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
238
389
  >
239
390
  <option value="active">Active</option>
240
- <option value="inactive">Inactive</option>
391
+ <option value="draft">Draft</option>
392
+ <option value="archived">Archived</option>
241
393
  </select>
242
- </div>
394
+ </Field>
243
395
  </div>
244
396
  </div>
245
397
 
246
- {/* Fields section */}
247
- <div className="border-border bg-card mb-4 rounded-lg border">
248
- <button
249
- onClick={() => toggleSection('fields')}
250
- className="hover:bg-muted flex w-full items-center justify-between p-4 transition-colors"
251
- >
252
- <div className="flex items-center gap-2">
253
- {expandedSection === 'fields' ? (
254
- <ChevronDown className="h-4 w-4" />
255
- ) : (
256
- <ChevronRight className="h-4 w-4" />
257
- )}
258
- <span className="text-foreground font-medium">Form Fields</span>
259
- <span className="text-muted-foreground text-sm">({fields.length})</span>
398
+ <div className="border-border bg-card rounded-lg border p-4">
399
+ <h2 className="text-foreground mb-3 text-base font-medium">Confirmation</h2>
400
+ <fieldset className="mb-4">
401
+ <legend className="text-foreground mb-2 block text-sm font-medium">
402
+ After submission
403
+ </legend>
404
+ <div className="flex flex-wrap gap-3">
405
+ <label
406
+ className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-2 transition-colors ${
407
+ details.confirmationType === 'message'
408
+ ? 'border-primary bg-accent'
409
+ : 'border-border hover:bg-muted bg-transparent'
410
+ }`}
411
+ >
412
+ <input
413
+ type="radio"
414
+ name="confirmationType"
415
+ value="message"
416
+ checked={details.confirmationType === 'message'}
417
+ onChange={() => onPatch({ confirmationType: 'message' })}
418
+ className="text-primary"
419
+ />
420
+ <MessageSquare className="h-4 w-4" aria-hidden />
421
+ <span className="text-sm">Show Message</span>
422
+ </label>
423
+ <label
424
+ className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-2 transition-colors ${
425
+ details.confirmationType === 'redirect'
426
+ ? 'border-primary bg-accent'
427
+ : 'border-border hover:bg-muted bg-transparent'
428
+ }`}
429
+ >
430
+ <input
431
+ type="radio"
432
+ name="confirmationType"
433
+ value="redirect"
434
+ checked={details.confirmationType === 'redirect'}
435
+ onChange={() => onPatch({ confirmationType: 'redirect' })}
436
+ className="text-primary"
437
+ />
438
+ <ExternalLink className="h-4 w-4" aria-hidden />
439
+ <span className="text-sm">Redirect to Page</span>
440
+ </label>
260
441
  </div>
261
- </button>
262
- {expandedSection === 'fields' && (
263
- <div className="border-border border-t p-4">
264
- <div className="mb-4 flex flex-wrap gap-2">
265
- {FIELD_TYPES.map((ft) => (
266
- <button
267
- key={ft.value}
268
- onClick={() => addField(ft.value)}
269
- className="border-border hover:bg-muted flex items-center gap-1 rounded-lg border px-3 py-1.5 text-xs transition-colors"
270
- >
271
- <Plus className="h-3 w-3" /> {ft.label}
272
- </button>
273
- ))}
274
- </div>
442
+ </fieldset>
275
443
 
276
- {fields.length === 0 ? (
277
- <p className="text-muted-foreground py-6 text-center text-sm">
278
- Click a field type above to add it to your form.
279
- </p>
280
- ) : (
281
- <div className="flex flex-col gap-2">
282
- {fields.map((field, index) => (
283
- <div
284
- key={field.id}
285
- draggable
286
- onDragStart={(e) => e.dataTransfer.setData('text/plain', String(index))}
287
- onDragOver={(e) => e.preventDefault()}
288
- onDrop={(e) => {
289
- const from = Number(e.dataTransfer.getData('text/plain'))
290
- moveField(from, index)
291
- }}
292
- className="border-border bg-muted hover:bg-card flex items-center gap-3 rounded-lg border p-3 transition-colors"
293
- >
294
- <GripVertical className="text-muted-foreground h-4 w-4 shrink-0 cursor-grab" />
295
- <input
296
- type="text"
297
- value={field.label}
298
- onChange={(e) => updateField(field.id, { label: e.target.value })}
299
- className="border-border focus-visible:ring-ring/50 flex-1 rounded border px-2 py-1 text-sm focus:ring-1 focus:outline-none"
300
- />
301
- <span className="bg-muted text-muted-foreground shrink-0 rounded px-2 py-0.5 text-xs">
302
- {field.type}
303
- </span>
304
- <label className="text-muted-foreground flex shrink-0 items-center gap-1 text-xs">
305
- <input
306
- type="checkbox"
307
- checked={field.required}
308
- onChange={(e) => updateField(field.id, { required: e.target.checked })}
309
- className="rounded"
310
- />
311
- Required
312
- </label>
313
- <button
314
- onClick={() => removeField(field.id)}
315
- className="text-destructive hover:bg-destructive/10 shrink-0 rounded p-1 transition-colors"
316
- >
317
- <Trash2 className="h-4 w-4" />
318
- </button>
319
- </div>
320
- ))}
321
- </div>
322
- )}
323
- </div>
444
+ {details.confirmationType === 'message' ? (
445
+ <Field label="Success Message">
446
+ <textarea
447
+ value={details.successMessage}
448
+ onChange={(e) => onPatch({ successMessage: e.target.value })}
449
+ rows={3}
450
+ placeholder="Thank you! Your submission has been received."
451
+ className={inputCls}
452
+ />
453
+ </Field>
454
+ ) : (
455
+ <Field label="Redirect URL">
456
+ <input
457
+ type="url"
458
+ value={details.redirectUrl}
459
+ onChange={(e) => onPatch({ redirectUrl: e.target.value })}
460
+ placeholder="https://example.com/thank-you"
461
+ className={inputCls}
462
+ />
463
+ </Field>
324
464
  )}
325
465
  </div>
326
466
 
327
- {/* Confirmation section */}
328
- <div className="border-border bg-card mb-4 rounded-lg border">
329
- <button
330
- onClick={() => toggleSection('confirmation')}
331
- className="hover:bg-muted flex w-full items-center justify-between p-4 transition-colors"
332
- >
333
- <div className="flex items-center gap-2">
334
- {expandedSection === 'confirmation' ? (
335
- <ChevronDown className="h-4 w-4" />
336
- ) : (
337
- <ChevronRight className="h-4 w-4" />
338
- )}
339
- <MessageSquare className="text-success h-4 w-4" />
340
- <span className="text-foreground font-medium">Confirmation</span>
341
- <span className="bg-muted text-muted-foreground rounded px-2 py-0.5 text-xs">
342
- {confirmation.type === 'message' ? 'Show Message' : 'Redirect'}
343
- </span>
344
- </div>
467
+ <div className="flex justify-end">
468
+ <button className={btnPrimary} onClick={onSave} disabled={saving || !details.name.trim()}>
469
+ {saving ? (
470
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
471
+ ) : (
472
+ <Save className="h-4 w-4" aria-hidden />
473
+ )}
474
+ {saving ? 'Saving…' : 'Save Details'}
345
475
  </button>
346
- {expandedSection === 'confirmation' && (
347
- <div className="border-border border-t p-4">
348
- <div className="mb-4">
349
- <label className="text-foreground mb-2 block text-sm font-medium">
350
- After submission
351
- </label>
352
- <div className="flex gap-3">
353
- <label
354
- className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-2 transition-colors ${
355
- confirmation.type === 'message'
356
- ? 'border-primary bg-accent'
357
- : 'border-border hover:bg-muted bg-transparent'
358
- }`}
359
- >
360
- <input
361
- type="radio"
362
- name="confirmationType"
363
- value="message"
364
- checked={confirmation.type === 'message'}
365
- onChange={() => setConfirmation((c) => ({ ...c, type: 'message' }))}
366
- className="text-primary"
367
- />
368
- <MessageSquare className="h-4 w-4" />
369
- <span className="text-sm">Show Message</span>
370
- </label>
371
- <label
372
- className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-2 transition-colors ${
373
- confirmation.type === 'redirect'
374
- ? 'border-primary bg-accent'
375
- : 'border-border hover:bg-muted bg-transparent'
376
- }`}
377
- >
378
- <input
379
- type="radio"
380
- name="confirmationType"
381
- value="redirect"
382
- checked={confirmation.type === 'redirect'}
383
- onChange={() => setConfirmation((c) => ({ ...c, type: 'redirect' }))}
384
- className="text-primary"
385
- />
386
- <ExternalLink className="h-4 w-4" />
387
- <span className="text-sm">Redirect to Page</span>
388
- </label>
389
- </div>
390
- </div>
391
-
392
- {confirmation.type === 'message' ? (
393
- <div>
394
- <label className="text-foreground mb-1 block text-sm font-medium">
395
- Success Message
396
- </label>
397
- <textarea
398
- value={confirmation.message}
399
- onChange={(e) => setConfirmation((c) => ({ ...c, message: e.target.value }))}
400
- rows={3}
401
- className="border-border focus-visible:ring-ring/50 w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
402
- placeholder="Thank you! Your submission has been received."
403
- />
404
- </div>
405
- ) : (
406
- <div className="grid gap-3">
407
- <div>
408
- <label className="text-foreground mb-1 block text-sm font-medium">
409
- Redirect URL
410
- </label>
411
- <input
412
- type="url"
413
- value={confirmation.redirectUrl}
414
- onChange={(e) =>
415
- setConfirmation((c) => ({ ...c, redirectUrl: e.target.value }))
416
- }
417
- placeholder="https://example.com/thank-you"
418
- className="border-border focus-visible:ring-ring/50 w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
419
- />
420
- </div>
421
- <div>
422
- <label className="text-foreground mb-1 block text-sm font-medium">
423
- Redirect Delay (ms)
424
- </label>
425
- <input
426
- type="number"
427
- value={confirmation.redirectDelay}
428
- onChange={(e) =>
429
- setConfirmation((c) => ({ ...c, redirectDelay: Number(e.target.value) }))
430
- }
431
- min={0}
432
- step={500}
433
- className="border-border focus-visible:ring-ring/50 w-32 rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
434
- />
435
- <p className="text-muted-foreground mt-1 text-xs">0 = instant redirect</p>
436
- </div>
437
- </div>
438
- )}
439
- </div>
440
- )}
441
476
  </div>
477
+ </div>
478
+ )
479
+ }
442
480
 
443
- {/* Analytics section */}
444
- <div className="border-border bg-card mb-4 rounded-lg border">
445
- <button
446
- onClick={() => toggleSection('analytics')}
447
- className="hover:bg-muted flex w-full items-center justify-between p-4 transition-colors"
448
- >
449
- <div className="flex items-center gap-2">
450
- {expandedSection === 'analytics' ? (
451
- <ChevronDown className="h-4 w-4" />
452
- ) : (
453
- <ChevronRight className="h-4 w-4" />
454
- )}
455
- <BarChart3 className="text-brand h-4 w-4" />
456
- <span className="text-foreground font-medium">GA4 Analytics</span>
457
- <span
458
- className={`rounded px-2 py-0.5 text-xs ${analytics.enabled ? 'bg-success/10 text-success' : 'bg-muted text-muted-foreground'}`}
459
- >
460
- {analytics.enabled ? 'Enabled' : 'Disabled'}
481
+ // ─── Analytics tab ──────────────────────────────────────────────────────────
482
+
483
+ function AnalyticsTab({
484
+ analytics,
485
+ onPatch,
486
+ onSave,
487
+ saving,
488
+ }: {
489
+ analytics: FormAnalyticsConfig
490
+ onPatch: (patch: Partial<FormAnalyticsConfig>) => void
491
+ onSave: () => void
492
+ saving: boolean
493
+ }) {
494
+ return (
495
+ <div className="space-y-4">
496
+ <div className="border-border bg-card rounded-lg border p-4">
497
+ <div className="flex items-center justify-between gap-3">
498
+ <span className="min-w-0">
499
+ <span className="text-foreground block text-sm font-medium">
500
+ Push events to Google Analytics 4
461
501
  </span>
462
- </div>
463
- </button>
464
- {expandedSection === 'analytics' && (
465
- <div className="border-border border-t p-4">
466
- <div className="mb-4 flex items-center gap-3">
467
- <Toggle
468
- checked={analytics.enabled}
469
- onCheckedChange={(v) => setAnalytics((a) => ({ ...a, enabled: v }))}
470
- aria-label="Push events to Google Analytics 4"
502
+ <span className="text-muted-foreground block text-sm">
503
+ Fires form start, submit, and error events on the public site.
504
+ </span>
505
+ </span>
506
+ <Toggle
507
+ checked={analytics.enabled}
508
+ onCheckedChange={(v) => onPatch({ enabled: v })}
509
+ aria-label="Push events to Google Analytics 4"
510
+ />
511
+ </div>
512
+
513
+ {analytics.enabled && (
514
+ <div className="mt-4 grid gap-4">
515
+ <Field label="GA4 Measurement ID" hint="Leave blank to use your site's default GA4 tag">
516
+ <input
517
+ type="text"
518
+ value={analytics.measurementId ?? ''}
519
+ onChange={(e) => onPatch({ measurementId: e.target.value })}
520
+ placeholder="G-XXXXXXXXXX"
521
+ className={`${inputCls} max-w-64`}
471
522
  />
472
- <span className="text-foreground text-sm font-medium">
473
- Push events to Google Analytics 4
474
- </span>
475
- </div>
523
+ </Field>
476
524
 
477
- {analytics.enabled && (
478
- <div className="grid gap-4 pl-12">
479
- <div>
480
- <label className="text-foreground mb-1 block text-sm font-medium">
481
- GA4 Measurement ID
482
- <span className="text-muted-foreground font-normal"> (optional)</span>
483
- </label>
484
- <input
485
- type="text"
486
- value={analytics.measurementId}
487
- onChange={(e) => setAnalytics((a) => ({ ...a, measurementId: e.target.value }))}
488
- placeholder="G-XXXXXXXXXX"
489
- className="border-border focus-visible:ring-ring/50 w-64 rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
490
- />
491
- <p className="text-muted-foreground mt-1 text-xs">
492
- Leave blank to use your site's default GA4 tag
493
- </p>
494
- </div>
525
+ <div className="grid gap-4 sm:grid-cols-2">
526
+ <Field label="Submit Event Name">
527
+ <input
528
+ type="text"
529
+ value={analytics.submitEventName ?? ''}
530
+ onChange={(e) => onPatch({ submitEventName: e.target.value })}
531
+ placeholder="form_submit"
532
+ className={inputCls}
533
+ />
534
+ </Field>
535
+ <Field label="Start Event Name">
536
+ <input
537
+ type="text"
538
+ value={analytics.startEventName ?? ''}
539
+ onChange={(e) => onPatch({ startEventName: e.target.value })}
540
+ placeholder="form_start"
541
+ className={inputCls}
542
+ />
543
+ </Field>
544
+ </div>
495
545
 
496
- <div className="grid grid-cols-2 gap-4">
497
- <div>
498
- <label className="text-foreground mb-1 block text-sm font-medium">
499
- Submit Event Name
500
- </label>
501
- <input
502
- type="text"
503
- value={analytics.submitEventName}
504
- onChange={(e) =>
505
- setAnalytics((a) => ({ ...a, submitEventName: e.target.value }))
506
- }
507
- className="border-border focus-visible:ring-ring/50 w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
508
- />
509
- </div>
510
- <div>
511
- <label className="text-foreground mb-1 block text-sm font-medium">
512
- Start Event Name
513
- </label>
514
- <input
515
- type="text"
516
- value={analytics.startEventName}
517
- onChange={(e) =>
518
- setAnalytics((a) => ({ ...a, startEventName: e.target.value }))
519
- }
520
- className="border-border focus-visible:ring-ring/50 w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
521
- />
522
- </div>
523
- </div>
546
+ <div className="border-border border-t pt-4">
547
+ <div className="mb-3 flex items-center gap-3">
548
+ <input
549
+ type="checkbox"
550
+ id="trackConversion"
551
+ checked={analytics.trackAsConversion ?? false}
552
+ onChange={(e) => onPatch({ trackAsConversion: e.target.checked })}
553
+ className="rounded"
554
+ />
555
+ <label htmlFor="trackConversion" className="text-foreground text-sm font-medium">
556
+ Track as conversion event
557
+ </label>
558
+ </div>
524
559
 
525
- <div className="border-border border-t pt-4">
526
- <div className="mb-3 flex items-center gap-3">
560
+ {analytics.trackAsConversion && (
561
+ <div className="grid gap-4 sm:grid-cols-2">
562
+ <Field label="Conversion Value">
527
563
  <input
528
- type="checkbox"
529
- id="trackConversion"
530
- checked={analytics.trackAsConversion}
531
- onChange={(e) =>
532
- setAnalytics((a) => ({ ...a, trackAsConversion: e.target.checked }))
533
- }
534
- className="rounded"
564
+ type="number"
565
+ value={analytics.conversionValue ?? 0}
566
+ onChange={(e) => onPatch({ conversionValue: Number(e.target.value) })}
567
+ min={0}
568
+ step={0.01}
569
+ className={inputCls}
535
570
  />
536
- <label
537
- htmlFor="trackConversion"
538
- className="text-foreground text-sm font-medium"
571
+ </Field>
572
+ <Field label="Currency">
573
+ <select
574
+ value={analytics.conversionCurrency ?? 'USD'}
575
+ onChange={(e) => onPatch({ conversionCurrency: e.target.value })}
576
+ className={inputCls}
539
577
  >
540
- Track as conversion event
541
- </label>
542
- </div>
543
-
544
- {analytics.trackAsConversion && (
545
- <div className="ml-6 grid grid-cols-2 gap-4">
546
- <div>
547
- <label className="text-foreground mb-1 block text-sm font-medium">
548
- Conversion Value
549
- </label>
550
- <input
551
- type="number"
552
- value={analytics.conversionValue}
553
- onChange={(e) =>
554
- setAnalytics((a) => ({ ...a, conversionValue: Number(e.target.value) }))
555
- }
556
- min={0}
557
- step={0.01}
558
- className="border-border focus-visible:ring-ring/50 w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
559
- />
560
- </div>
561
- <div>
562
- <label className="text-foreground mb-1 block text-sm font-medium">
563
- Currency
564
- </label>
565
- <select
566
- value={analytics.conversionCurrency}
567
- onChange={(e) =>
568
- setAnalytics((a) => ({ ...a, conversionCurrency: e.target.value }))
569
- }
570
- className="border-border focus-visible:ring-ring/50 w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
571
- >
572
- <option value="USD">USD</option>
573
- <option value="EUR">EUR</option>
574
- <option value="GBP">GBP</option>
575
- <option value="CAD">CAD</option>
576
- <option value="AUD">AUD</option>
577
- </select>
578
- </div>
579
- </div>
580
- )}
578
+ <option value="USD">USD</option>
579
+ <option value="EUR">EUR</option>
580
+ <option value="GBP">GBP</option>
581
+ <option value="CAD">CAD</option>
582
+ <option value="AUD">AUD</option>
583
+ </select>
584
+ </Field>
581
585
  </div>
586
+ )}
587
+ </div>
582
588
 
583
- <div className="border-info/30 bg-info/10 rounded-lg border p-3">
584
- <p className="text-foreground text-xs">
585
- <strong>Note:</strong> Your site must have the GA4 snippet or GTM container
586
- installed. Actuate pushes events via{' '}
587
- <code className="bg-info/10 rounded px-1">gtag()</code> /
588
- <code className="bg-info/10 rounded px-1">dataLayer</code> — it does not load
589
- the GA4 script.
590
- </p>
591
- </div>
592
- </div>
593
- )}
589
+ <div className="border-info/30 bg-info/10 rounded-lg border p-3">
590
+ <p className="text-foreground text-xs">
591
+ <strong>Note:</strong> Your site must have the GA4 snippet or GTM container
592
+ installed. Actuate pushes events via{' '}
593
+ <code className="bg-info/10 rounded px-1">gtag()</code> /
594
+ <code className="bg-info/10 rounded px-1">dataLayer</code> — it does not load the
595
+ GA4 script.
596
+ </p>
597
+ </div>
594
598
  </div>
595
599
  )}
596
600
  </div>
601
+
602
+ <div className="flex justify-end">
603
+ <button className={btnPrimary} onClick={onSave} disabled={saving}>
604
+ {saving ? (
605
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
606
+ ) : (
607
+ <Save className="h-4 w-4" aria-hidden />
608
+ )}
609
+ {saving ? 'Saving…' : 'Save Analytics'}
610
+ </button>
611
+ </div>
597
612
  </div>
598
613
  )
599
614
  }
615
+
616
+ // ─── New form ───────────────────────────────────────────────────────────────
617
+
618
+ function NewFormEditor({ onNavigate }: { onNavigate?: (path: string) => void }) {
619
+ const [name, setName] = useState('')
620
+ const [description, setDescription] = useState('')
621
+ const [status, setStatus] = useState<FormStatus>('active')
622
+ const [fields, setFields] = useState<FormField[]>([])
623
+ const [saving, setSaving] = useState(false)
624
+ const [dirty, setDirty] = useState(false)
625
+
626
+ useNavigationBlocker(dirty && !saving)
627
+
628
+ async function handleCreate() {
629
+ setSaving(true)
630
+ const res = await createForm({ name, description, status, fields })
631
+ setSaving(false)
632
+ if (res.error || !res.data) {
633
+ toast.error(res.error ?? 'Failed to create form')
634
+ return
635
+ }
636
+ setDirty(false)
637
+ toast.success('Form created. Configure notifications, embed, and integrations here.')
638
+ emitFormsChanged()
639
+ onNavigate?.(`/forms/${res.data.id}/edit`)
640
+ }
641
+
642
+ return (
643
+ <div className="max-w-4xl p-3 pr-6 sm:p-4 sm:pr-8">
644
+ <div className="mb-6 flex items-center gap-3">
645
+ <button
646
+ onClick={() => onNavigate?.('/forms')}
647
+ aria-label="Back to forms"
648
+ className="hover:bg-muted focus-visible:ring-ring rounded-lg p-2 transition-colors focus-visible:ring-2 focus-visible:outline-none"
649
+ >
650
+ <ArrowLeft className="text-muted-foreground h-5 w-5" aria-hidden />
651
+ </button>
652
+ <h1 className="text-foreground flex-1 text-xl font-medium">New Form</h1>
653
+ <button
654
+ onClick={() => void handleCreate()}
655
+ disabled={saving || !name.trim()}
656
+ className={btnPrimary}
657
+ >
658
+ {saving ? (
659
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
660
+ ) : (
661
+ <Save className="h-4 w-4" aria-hidden />
662
+ )}
663
+ {saving ? 'Creating…' : 'Create Form'}
664
+ </button>
665
+ </div>
666
+
667
+ <div className="border-border bg-card mb-4 rounded-lg border p-4">
668
+ <div className="grid gap-4">
669
+ <Field label="Form Name">
670
+ <input
671
+ type="text"
672
+ value={name}
673
+ onChange={(e) => {
674
+ setName(e.target.value)
675
+ setDirty(true)
676
+ }}
677
+ placeholder="e.g. Contact Form"
678
+ className={inputCls}
679
+ />
680
+ </Field>
681
+ <Field label="Description">
682
+ <input
683
+ type="text"
684
+ value={description}
685
+ onChange={(e) => {
686
+ setDescription(e.target.value)
687
+ setDirty(true)
688
+ }}
689
+ placeholder="Brief description of this form"
690
+ className={inputCls}
691
+ />
692
+ </Field>
693
+ <Field label="Status">
694
+ <select
695
+ value={status}
696
+ onChange={(e) => setStatus(e.target.value as FormStatus)}
697
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring rounded-lg border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
698
+ >
699
+ <option value="active">Active</option>
700
+ <option value="draft">Draft</option>
701
+ </select>
702
+ </Field>
703
+ </div>
704
+ </div>
705
+
706
+ <div className="border-border bg-card rounded-lg border p-4">
707
+ <h2 className="text-foreground mb-3 text-base font-medium">Fields</h2>
708
+ <FieldsPanel
709
+ fields={fields}
710
+ onChange={(next) => {
711
+ setFields(next)
712
+ setDirty(true)
713
+ }}
714
+ hasSubmissions={false}
715
+ />
716
+ </div>
717
+
718
+ <p className="text-muted-foreground mt-4 text-sm">
719
+ Confirmation, notifications, analytics, embed, and integrations are configured after the
720
+ form is created.
721
+ </p>
722
+ </div>
723
+ )
724
+ }
725
+
726
+ // ─── Shared bits ────────────────────────────────────────────────────────────
727
+
728
+ function Field({
729
+ label,
730
+ hint,
731
+ children,
732
+ }: {
733
+ label: string
734
+ hint?: string
735
+ children: React.ReactNode
736
+ }) {
737
+ return (
738
+ <label className="block">
739
+ <span className="text-foreground mb-1 block text-sm font-medium">{label}</span>
740
+ {children}
741
+ {hint && <span className="text-muted-foreground mt-1 block text-xs">{hint}</span>}
742
+ </label>
743
+ )
744
+ }