@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.
- package/CHANGELOG.md +33 -0
- package/dist/__tests__/lib/preview-link.test.d.ts +2 -0
- package/dist/__tests__/lib/preview-link.test.d.ts.map +1 -0
- package/dist/__tests__/lib/preview-link.test.js +27 -0
- package/dist/__tests__/lib/preview-link.test.js.map +1 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js +19 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
- package/dist/__tests__/views/form-editor.render.test.d.ts +2 -0
- package/dist/__tests__/views/form-editor.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/form-editor.render.test.js +146 -0
- package/dist/__tests__/views/form-editor.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/lib/forms-service.d.ts +15 -3
- package/dist/lib/forms-service.d.ts.map +1 -1
- package/dist/lib/forms-service.js.map +1 -1
- package/dist/lib/preview-link.d.ts +7 -0
- package/dist/lib/preview-link.d.ts.map +1 -1
- package/dist/lib/preview-link.js +16 -0
- package/dist/lib/preview-link.js.map +1 -1
- package/dist/views/FormEditor.d.ts.map +1 -1
- package/dist/views/FormEditor.js +211 -116
- package/dist/views/FormEditor.js.map +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +6 -1
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +14 -1
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +11 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/package.json +9 -9
- package/src/__tests__/lib/preview-link.test.ts +39 -0
- package/src/__tests__/views/editor-top-bar.render.test.tsx +22 -0
- package/src/__tests__/views/form-editor.render.test.tsx +186 -0
- package/src/lib/forms-service.ts +16 -1
- package/src/lib/preview-link.ts +21 -0
- package/src/views/FormEditor.tsx +655 -510
- package/src/views/page-editor/EditorTopBar.tsx +11 -4
- package/src/views/page-editor/PageSectionEditor.tsx +18 -0
- package/src/views/post-editor/PostSectionEditor.tsx +15 -0
package/src/views/FormEditor.tsx
CHANGED
|
@@ -1,63 +1,90 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 [
|
|
78
|
-
const [
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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="
|
|
179
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
196
|
-
|
|
197
|
-
|
|
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={
|
|
201
|
-
|
|
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
|
-
|
|
205
|
-
|
|
258
|
+
<Inbox className="h-4 w-4" aria-hidden />
|
|
259
|
+
View Entries
|
|
206
260
|
</button>
|
|
207
261
|
</div>
|
|
208
262
|
|
|
209
|
-
{
|
|
210
|
-
|
|
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
|
-
<
|
|
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) =>
|
|
366
|
+
value={details.name}
|
|
367
|
+
onChange={(e) => onPatch({ name: e.target.value })}
|
|
218
368
|
placeholder="e.g. Contact Form"
|
|
219
|
-
className=
|
|
369
|
+
className={inputCls}
|
|
220
370
|
/>
|
|
221
|
-
</
|
|
222
|
-
<
|
|
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) =>
|
|
375
|
+
value={details.description}
|
|
376
|
+
onChange={(e) => onPatch({ description: e.target.value })}
|
|
228
377
|
placeholder="Brief description of this form"
|
|
229
|
-
className=
|
|
378
|
+
className={inputCls}
|
|
230
379
|
/>
|
|
231
|
-
</
|
|
232
|
-
<
|
|
233
|
-
|
|
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) =>
|
|
237
|
-
className="border-border focus-visible:ring-ring
|
|
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="
|
|
391
|
+
<option value="draft">Draft</option>
|
|
392
|
+
<option value="archived">Archived</option>
|
|
241
393
|
</select>
|
|
242
|
-
</
|
|
394
|
+
</Field>
|
|
243
395
|
</div>
|
|
244
396
|
</div>
|
|
245
397
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
<
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
<div className="flex
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
473
|
-
Push events to Google Analytics 4
|
|
474
|
-
</span>
|
|
475
|
-
</div>
|
|
523
|
+
</Field>
|
|
476
524
|
|
|
477
|
-
|
|
478
|
-
<
|
|
479
|
-
<
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
526
|
-
|
|
560
|
+
{analytics.trackAsConversion && (
|
|
561
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
562
|
+
<Field label="Conversion Value">
|
|
527
563
|
<input
|
|
528
|
-
type="
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
+
}
|