@actuate-media/cms-admin 0.1.4 → 0.2.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/LICENSE +21 -21
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +16 -10
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +2 -0
- package/dist/components/TipTapEditor.js +78 -78
- package/dist/lib/useApiData.d.ts +8 -1
- package/dist/lib/useApiData.d.ts.map +1 -1
- package/dist/lib/useApiData.js +39 -7
- package/dist/lib/useApiData.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +8 -3
- package/dist/views/Dashboard.js.map +1 -1
- package/package.json +10 -5
- package/src/AdminRoot.tsx +312 -0
- package/src/__tests__/lib/search.test.ts +138 -0
- package/src/__tests__/lib/utils.test.ts +19 -0
- package/src/__tests__/router/match-route.test.ts +47 -0
- package/src/__tests__/router/strip-base.test.ts +30 -0
- package/src/components/Breadcrumbs.tsx +92 -0
- package/src/components/CommandPalette.tsx +384 -0
- package/src/components/ErrorBoundary.tsx +52 -0
- package/src/components/FocalPointPicker.tsx +54 -0
- package/src/components/FolderTree.tsx +427 -0
- package/src/components/LivePreview.tsx +136 -0
- package/src/components/LocaleProvider.tsx +51 -0
- package/src/components/LocaleSwitcher.tsx +51 -0
- package/src/components/MediaPickerModal.tsx +183 -0
- package/src/components/PresenceIndicator.tsx +71 -0
- package/src/components/SEOPanel.tsx +767 -0
- package/src/components/ThemeProvider.tsx +98 -0
- package/src/components/TipTapEditor.tsx +469 -0
- package/src/components/VersionHistory.tsx +167 -0
- package/src/components/ui/Avatar.tsx +42 -0
- package/src/components/ui/Badge.tsx +25 -0
- package/src/components/ui/Button.tsx +52 -0
- package/src/components/ui/CommandPalette.tsx +119 -0
- package/src/components/ui/ConfirmDialog.tsx +52 -0
- package/src/components/ui/DataTable.tsx +194 -0
- package/src/components/ui/EmptyState.tsx +29 -0
- package/src/components/ui/Modal.tsx +48 -0
- package/src/components/ui/Pagination.tsx +79 -0
- package/src/components/ui/SearchInput.tsx +44 -0
- package/src/components/ui/Skeleton.tsx +48 -0
- package/src/components/ui/Toast.tsx +66 -0
- package/src/components/ui/index.ts +24 -0
- package/src/fields/ArrayField.tsx +92 -0
- package/src/fields/BlockBuilderField.tsx +421 -0
- package/src/fields/DateField.tsx +41 -0
- package/src/fields/FieldRenderer.tsx +84 -0
- package/src/fields/GroupField.tsx +41 -0
- package/src/fields/MediaField.tsx +48 -0
- package/src/fields/NavBuilderField.tsx +78 -0
- package/src/fields/NumberField.tsx +45 -0
- package/src/fields/RelationshipField.tsx +245 -0
- package/src/fields/RichTextField.tsx +26 -0
- package/src/fields/SelectField.tsx +117 -0
- package/src/fields/SlugField.tsx +65 -0
- package/src/fields/TextField.tsx +48 -0
- package/src/fields/ToggleField.tsx +36 -0
- package/src/fields/block-types.ts +95 -0
- package/src/fields/index.ts +17 -0
- package/src/hooks/useContentLock.ts +52 -0
- package/src/hooks/useDebounce.ts +14 -0
- package/src/hooks/useKeyboardShortcuts.ts +32 -0
- package/src/index.ts +55 -0
- package/src/layout/Header.tsx +135 -0
- package/src/layout/Layout.tsx +77 -0
- package/src/layout/Sidebar.tsx +216 -0
- package/src/lib/api.ts +67 -0
- package/src/lib/search.ts +59 -0
- package/src/lib/useApiData.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/router/index.ts +81 -0
- package/src/styles/build-input.css +11 -0
- package/src/styles/tailwind.css +11 -6
- package/src/styles/theme.css +182 -181
- package/src/views/CollectionList.tsx +270 -0
- package/src/views/Dashboard.tsx +207 -0
- package/src/views/DocumentEdit.tsx +377 -0
- package/src/views/FormEditor.tsx +533 -0
- package/src/views/FormSubmissions.tsx +316 -0
- package/src/views/Forms.tsx +106 -0
- package/src/views/Login.tsx +322 -0
- package/src/views/MediaBrowser.tsx +774 -0
- package/src/views/PageEditor.tsx +192 -0
- package/src/views/Pages.tsx +354 -0
- package/src/views/PostEditor.tsx +251 -0
- package/src/views/Posts.tsx +243 -0
- package/src/views/Redirects.tsx +293 -0
- package/src/views/SEO.tsx +458 -0
- package/src/views/Settings.tsx +811 -0
- package/src/views/SetupWizard.tsx +207 -0
- package/src/views/Users.tsx +282 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
Save, ArrowLeft, Plus, Trash2, GripVertical,
|
|
6
|
+
MessageSquare, ExternalLink, BarChart3, ChevronDown, ChevronRight,
|
|
7
|
+
Loader2,
|
|
8
|
+
} from 'lucide-react';
|
|
9
|
+
import { useApiData } from '../lib/useApiData.js';
|
|
10
|
+
import { cmsApi } from '../lib/api.js';
|
|
11
|
+
|
|
12
|
+
interface FormField {
|
|
13
|
+
id: string;
|
|
14
|
+
type: string;
|
|
15
|
+
label: string;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
required: boolean;
|
|
18
|
+
options?: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ConfirmationConfig {
|
|
22
|
+
type: 'message' | 'redirect';
|
|
23
|
+
message: string;
|
|
24
|
+
redirectUrl: string;
|
|
25
|
+
redirectDelay: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface AnalyticsConfig {
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
measurementId: string;
|
|
31
|
+
submitEventName: string;
|
|
32
|
+
startEventName: string;
|
|
33
|
+
trackAsConversion: boolean;
|
|
34
|
+
conversionValue: number;
|
|
35
|
+
conversionCurrency: string;
|
|
36
|
+
customParameters: Record<string, string>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const FIELD_TYPES = [
|
|
40
|
+
{ value: 'text', label: 'Text' },
|
|
41
|
+
{ value: 'email', label: 'Email' },
|
|
42
|
+
{ value: 'tel', label: 'Phone' },
|
|
43
|
+
{ value: 'textarea', label: 'Textarea' },
|
|
44
|
+
{ value: 'select', label: 'Dropdown' },
|
|
45
|
+
{ value: 'checkbox', label: 'Checkbox' },
|
|
46
|
+
{ value: 'number', label: 'Number' },
|
|
47
|
+
{ value: 'date', label: 'Date' },
|
|
48
|
+
{ value: 'url', label: 'URL' },
|
|
49
|
+
{ value: 'file', label: 'File Upload' },
|
|
50
|
+
{ value: 'hidden', label: 'Hidden' },
|
|
51
|
+
] as const;
|
|
52
|
+
|
|
53
|
+
export interface FormEditorProps {
|
|
54
|
+
formId?: string;
|
|
55
|
+
onNavigate?: (path: string) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function FormEditor({ formId, onNavigate }: FormEditorProps) {
|
|
59
|
+
const isNew = !formId;
|
|
60
|
+
const { data: existingForm, loading } = useApiData<any>(
|
|
61
|
+
formId ? `/collections/forms/${formId}` : null,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const [name, setName] = useState('');
|
|
65
|
+
const [description, setDescription] = useState('');
|
|
66
|
+
const [status, setStatus] = useState('active');
|
|
67
|
+
const [fields, setFields] = useState<FormField[]>([]);
|
|
68
|
+
const [saving, setSaving] = useState(false);
|
|
69
|
+
const [expandedSection, setExpandedSection] = useState<string | null>('fields');
|
|
70
|
+
|
|
71
|
+
const [confirmation, setConfirmation] = useState<ConfirmationConfig>({
|
|
72
|
+
type: 'message',
|
|
73
|
+
message: 'Thank you! Your submission has been received.',
|
|
74
|
+
redirectUrl: '',
|
|
75
|
+
redirectDelay: 0,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const [analytics, setAnalytics] = useState<AnalyticsConfig>({
|
|
79
|
+
enabled: false,
|
|
80
|
+
measurementId: '',
|
|
81
|
+
submitEventName: 'form_submit',
|
|
82
|
+
startEventName: 'form_start',
|
|
83
|
+
trackAsConversion: false,
|
|
84
|
+
conversionValue: 0,
|
|
85
|
+
conversionCurrency: 'USD',
|
|
86
|
+
customParameters: {},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (existingForm) {
|
|
91
|
+
const d = existingForm.data ?? existingForm;
|
|
92
|
+
setName(d.name ?? '');
|
|
93
|
+
setDescription(d.description ?? '');
|
|
94
|
+
setStatus(d.status ?? 'active');
|
|
95
|
+
if (Array.isArray(d.fields)) setFields(d.fields);
|
|
96
|
+
if (d.confirmation) {
|
|
97
|
+
setConfirmation((prev) => ({ ...prev, ...d.confirmation }));
|
|
98
|
+
}
|
|
99
|
+
if (d.analytics) {
|
|
100
|
+
setAnalytics((prev) => ({ ...prev, ...d.analytics }));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}, [existingForm]);
|
|
104
|
+
|
|
105
|
+
const addField = useCallback((type: string) => {
|
|
106
|
+
setFields((prev) => [
|
|
107
|
+
...prev,
|
|
108
|
+
{
|
|
109
|
+
id: crypto.randomUUID(),
|
|
110
|
+
type,
|
|
111
|
+
label: `New ${type} field`,
|
|
112
|
+
required: false,
|
|
113
|
+
},
|
|
114
|
+
]);
|
|
115
|
+
}, []);
|
|
116
|
+
|
|
117
|
+
const removeField = useCallback((id: string) => {
|
|
118
|
+
setFields((prev) => prev.filter((f) => f.id !== id));
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
const updateField = useCallback((id: string, updates: Partial<FormField>) => {
|
|
122
|
+
setFields((prev) => prev.map((f) => (f.id === id ? { ...f, ...updates } : f)));
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
const moveField = useCallback((from: number, to: number) => {
|
|
126
|
+
setFields((prev) => {
|
|
127
|
+
const next = [...prev];
|
|
128
|
+
const [moved] = next.splice(from, 1);
|
|
129
|
+
next.splice(to, 0, moved!);
|
|
130
|
+
return next;
|
|
131
|
+
});
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
const handleSave = async () => {
|
|
135
|
+
setSaving(true);
|
|
136
|
+
try {
|
|
137
|
+
const payload = {
|
|
138
|
+
name,
|
|
139
|
+
description,
|
|
140
|
+
status,
|
|
141
|
+
fields,
|
|
142
|
+
confirmation,
|
|
143
|
+
analytics: analytics.enabled ? analytics : { enabled: false },
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (isNew) {
|
|
147
|
+
await cmsApi('/collections/forms', {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
body: JSON.stringify(payload),
|
|
150
|
+
});
|
|
151
|
+
} else {
|
|
152
|
+
await cmsApi(`/collections/forms/${formId}`, {
|
|
153
|
+
method: 'PUT',
|
|
154
|
+
body: JSON.stringify(payload),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
onNavigate?.('/forms');
|
|
158
|
+
} finally {
|
|
159
|
+
setSaving(false);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const toggleSection = (section: string) => {
|
|
164
|
+
setExpandedSection(expandedSection === section ? null : section);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (loading) {
|
|
168
|
+
return (
|
|
169
|
+
<div className="p-4 flex items-center justify-center h-64">
|
|
170
|
+
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<div className="p-3 pr-6 sm:p-4 sm:pr-8 max-w-4xl">
|
|
177
|
+
{/* Header */}
|
|
178
|
+
<div className="flex items-center gap-3 mb-6">
|
|
179
|
+
<button
|
|
180
|
+
onClick={() => onNavigate?.('/forms')}
|
|
181
|
+
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
|
182
|
+
>
|
|
183
|
+
<ArrowLeft className="w-5 h-5 text-gray-600" />
|
|
184
|
+
</button>
|
|
185
|
+
<div className="flex-1">
|
|
186
|
+
<h1 className="text-xl font-semibold text-gray-900">
|
|
187
|
+
{isNew ? 'New Form' : 'Edit Form'}
|
|
188
|
+
</h1>
|
|
189
|
+
</div>
|
|
190
|
+
<button
|
|
191
|
+
onClick={handleSave}
|
|
192
|
+
disabled={saving || !name.trim()}
|
|
193
|
+
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm disabled:opacity-50"
|
|
194
|
+
>
|
|
195
|
+
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
196
|
+
{saving ? 'Saving...' : 'Save'}
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{/* Basic info */}
|
|
201
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-4">
|
|
202
|
+
<div className="grid gap-4">
|
|
203
|
+
<div>
|
|
204
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">Form Name</label>
|
|
205
|
+
<input
|
|
206
|
+
type="text"
|
|
207
|
+
value={name}
|
|
208
|
+
onChange={(e) => setName(e.target.value)}
|
|
209
|
+
placeholder="e.g. Contact Form"
|
|
210
|
+
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
<div>
|
|
214
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
|
215
|
+
<input
|
|
216
|
+
type="text"
|
|
217
|
+
value={description}
|
|
218
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
219
|
+
placeholder="Brief description of this form"
|
|
220
|
+
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
221
|
+
/>
|
|
222
|
+
</div>
|
|
223
|
+
<div>
|
|
224
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
|
225
|
+
<select
|
|
226
|
+
value={status}
|
|
227
|
+
onChange={(e) => setStatus(e.target.value)}
|
|
228
|
+
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
229
|
+
>
|
|
230
|
+
<option value="active">Active</option>
|
|
231
|
+
<option value="inactive">Inactive</option>
|
|
232
|
+
</select>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
{/* Fields section */}
|
|
238
|
+
<div className="bg-white rounded-lg border border-gray-200 mb-4">
|
|
239
|
+
<button
|
|
240
|
+
onClick={() => toggleSection('fields')}
|
|
241
|
+
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
|
242
|
+
>
|
|
243
|
+
<div className="flex items-center gap-2">
|
|
244
|
+
{expandedSection === 'fields' ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
|
245
|
+
<span className="font-medium text-gray-900">Form Fields</span>
|
|
246
|
+
<span className="text-sm text-gray-500">({fields.length})</span>
|
|
247
|
+
</div>
|
|
248
|
+
</button>
|
|
249
|
+
{expandedSection === 'fields' && (
|
|
250
|
+
<div className="border-t border-gray-200 p-4">
|
|
251
|
+
<div className="flex flex-wrap gap-2 mb-4">
|
|
252
|
+
{FIELD_TYPES.map((ft) => (
|
|
253
|
+
<button
|
|
254
|
+
key={ft.value}
|
|
255
|
+
onClick={() => addField(ft.value)}
|
|
256
|
+
className="flex items-center gap-1 px-3 py-1.5 text-xs border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
257
|
+
>
|
|
258
|
+
<Plus className="w-3 h-3" /> {ft.label}
|
|
259
|
+
</button>
|
|
260
|
+
))}
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
{fields.length === 0 ? (
|
|
264
|
+
<p className="text-sm text-gray-500 text-center py-6">
|
|
265
|
+
Click a field type above to add it to your form.
|
|
266
|
+
</p>
|
|
267
|
+
) : (
|
|
268
|
+
<div className="flex flex-col gap-2">
|
|
269
|
+
{fields.map((field, index) => (
|
|
270
|
+
<div
|
|
271
|
+
key={field.id}
|
|
272
|
+
draggable
|
|
273
|
+
onDragStart={(e) => e.dataTransfer.setData('text/plain', String(index))}
|
|
274
|
+
onDragOver={(e) => e.preventDefault()}
|
|
275
|
+
onDrop={(e) => {
|
|
276
|
+
const from = Number(e.dataTransfer.getData('text/plain'));
|
|
277
|
+
moveField(from, index);
|
|
278
|
+
}}
|
|
279
|
+
className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg bg-gray-50 hover:bg-white transition-colors"
|
|
280
|
+
>
|
|
281
|
+
<GripVertical className="w-4 h-4 text-gray-400 cursor-grab shrink-0" />
|
|
282
|
+
<input
|
|
283
|
+
type="text"
|
|
284
|
+
value={field.label}
|
|
285
|
+
onChange={(e) => updateField(field.id, { label: e.target.value })}
|
|
286
|
+
className="flex-1 px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
287
|
+
/>
|
|
288
|
+
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded shrink-0">
|
|
289
|
+
{field.type}
|
|
290
|
+
</span>
|
|
291
|
+
<label className="flex items-center gap-1 text-xs text-gray-600 shrink-0">
|
|
292
|
+
<input
|
|
293
|
+
type="checkbox"
|
|
294
|
+
checked={field.required}
|
|
295
|
+
onChange={(e) => updateField(field.id, { required: e.target.checked })}
|
|
296
|
+
className="rounded"
|
|
297
|
+
/>
|
|
298
|
+
Required
|
|
299
|
+
</label>
|
|
300
|
+
<button
|
|
301
|
+
onClick={() => removeField(field.id)}
|
|
302
|
+
className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors shrink-0"
|
|
303
|
+
>
|
|
304
|
+
<Trash2 className="w-4 h-4" />
|
|
305
|
+
</button>
|
|
306
|
+
</div>
|
|
307
|
+
))}
|
|
308
|
+
</div>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
{/* Confirmation section */}
|
|
315
|
+
<div className="bg-white rounded-lg border border-gray-200 mb-4">
|
|
316
|
+
<button
|
|
317
|
+
onClick={() => toggleSection('confirmation')}
|
|
318
|
+
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
|
319
|
+
>
|
|
320
|
+
<div className="flex items-center gap-2">
|
|
321
|
+
{expandedSection === 'confirmation' ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
|
322
|
+
<MessageSquare className="w-4 h-4 text-green-600" />
|
|
323
|
+
<span className="font-medium text-gray-900">Confirmation</span>
|
|
324
|
+
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
|
|
325
|
+
{confirmation.type === 'message' ? 'Show Message' : 'Redirect'}
|
|
326
|
+
</span>
|
|
327
|
+
</div>
|
|
328
|
+
</button>
|
|
329
|
+
{expandedSection === 'confirmation' && (
|
|
330
|
+
<div className="border-t border-gray-200 p-4">
|
|
331
|
+
<div className="mb-4">
|
|
332
|
+
<label className="block text-sm font-medium text-gray-700 mb-2">After submission</label>
|
|
333
|
+
<div className="flex gap-3">
|
|
334
|
+
<label className="flex items-center gap-2 px-4 py-2 border rounded-lg cursor-pointer transition-colors hover:bg-gray-50"
|
|
335
|
+
style={{ borderColor: confirmation.type === 'message' ? '#2563eb' : '#d1d5db', backgroundColor: confirmation.type === 'message' ? '#eff6ff' : 'transparent' }}
|
|
336
|
+
>
|
|
337
|
+
<input
|
|
338
|
+
type="radio"
|
|
339
|
+
name="confirmationType"
|
|
340
|
+
value="message"
|
|
341
|
+
checked={confirmation.type === 'message'}
|
|
342
|
+
onChange={() => setConfirmation((c) => ({ ...c, type: 'message' }))}
|
|
343
|
+
className="text-blue-600"
|
|
344
|
+
/>
|
|
345
|
+
<MessageSquare className="w-4 h-4" />
|
|
346
|
+
<span className="text-sm">Show Message</span>
|
|
347
|
+
</label>
|
|
348
|
+
<label className="flex items-center gap-2 px-4 py-2 border rounded-lg cursor-pointer transition-colors hover:bg-gray-50"
|
|
349
|
+
style={{ borderColor: confirmation.type === 'redirect' ? '#2563eb' : '#d1d5db', backgroundColor: confirmation.type === 'redirect' ? '#eff6ff' : 'transparent' }}
|
|
350
|
+
>
|
|
351
|
+
<input
|
|
352
|
+
type="radio"
|
|
353
|
+
name="confirmationType"
|
|
354
|
+
value="redirect"
|
|
355
|
+
checked={confirmation.type === 'redirect'}
|
|
356
|
+
onChange={() => setConfirmation((c) => ({ ...c, type: 'redirect' }))}
|
|
357
|
+
className="text-blue-600"
|
|
358
|
+
/>
|
|
359
|
+
<ExternalLink className="w-4 h-4" />
|
|
360
|
+
<span className="text-sm">Redirect to Page</span>
|
|
361
|
+
</label>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
|
|
365
|
+
{confirmation.type === 'message' ? (
|
|
366
|
+
<div>
|
|
367
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">Success Message</label>
|
|
368
|
+
<textarea
|
|
369
|
+
value={confirmation.message}
|
|
370
|
+
onChange={(e) => setConfirmation((c) => ({ ...c, message: e.target.value }))}
|
|
371
|
+
rows={3}
|
|
372
|
+
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
373
|
+
placeholder="Thank you! Your submission has been received."
|
|
374
|
+
/>
|
|
375
|
+
</div>
|
|
376
|
+
) : (
|
|
377
|
+
<div className="grid gap-3">
|
|
378
|
+
<div>
|
|
379
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">Redirect URL</label>
|
|
380
|
+
<input
|
|
381
|
+
type="url"
|
|
382
|
+
value={confirmation.redirectUrl}
|
|
383
|
+
onChange={(e) => setConfirmation((c) => ({ ...c, redirectUrl: e.target.value }))}
|
|
384
|
+
placeholder="https://example.com/thank-you"
|
|
385
|
+
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
386
|
+
/>
|
|
387
|
+
</div>
|
|
388
|
+
<div>
|
|
389
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">Redirect Delay (ms)</label>
|
|
390
|
+
<input
|
|
391
|
+
type="number"
|
|
392
|
+
value={confirmation.redirectDelay}
|
|
393
|
+
onChange={(e) => setConfirmation((c) => ({ ...c, redirectDelay: Number(e.target.value) }))}
|
|
394
|
+
min={0}
|
|
395
|
+
step={500}
|
|
396
|
+
className="w-32 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
397
|
+
/>
|
|
398
|
+
<p className="text-xs text-gray-500 mt-1">0 = instant redirect</p>
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
)}
|
|
402
|
+
</div>
|
|
403
|
+
)}
|
|
404
|
+
</div>
|
|
405
|
+
|
|
406
|
+
{/* Analytics section */}
|
|
407
|
+
<div className="bg-white rounded-lg border border-gray-200 mb-4">
|
|
408
|
+
<button
|
|
409
|
+
onClick={() => toggleSection('analytics')}
|
|
410
|
+
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
|
411
|
+
>
|
|
412
|
+
<div className="flex items-center gap-2">
|
|
413
|
+
{expandedSection === 'analytics' ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
|
414
|
+
<BarChart3 className="w-4 h-4 text-purple-600" />
|
|
415
|
+
<span className="font-medium text-gray-900">GA4 Analytics</span>
|
|
416
|
+
<span className={`text-xs px-2 py-0.5 rounded ${analytics.enabled ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
|
417
|
+
{analytics.enabled ? 'Enabled' : 'Disabled'}
|
|
418
|
+
</span>
|
|
419
|
+
</div>
|
|
420
|
+
</button>
|
|
421
|
+
{expandedSection === 'analytics' && (
|
|
422
|
+
<div className="border-t border-gray-200 p-4">
|
|
423
|
+
<div className="flex items-center gap-3 mb-4">
|
|
424
|
+
<label className="relative inline-flex items-center cursor-pointer">
|
|
425
|
+
<input
|
|
426
|
+
type="checkbox"
|
|
427
|
+
checked={analytics.enabled}
|
|
428
|
+
onChange={(e) => setAnalytics((a) => ({ ...a, enabled: e.target.checked }))}
|
|
429
|
+
className="sr-only peer"
|
|
430
|
+
/>
|
|
431
|
+
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600" />
|
|
432
|
+
</label>
|
|
433
|
+
<span className="text-sm font-medium text-gray-700">Push events to Google Analytics 4</span>
|
|
434
|
+
</div>
|
|
435
|
+
|
|
436
|
+
{analytics.enabled && (
|
|
437
|
+
<div className="grid gap-4 pl-12">
|
|
438
|
+
<div>
|
|
439
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
440
|
+
GA4 Measurement ID
|
|
441
|
+
<span className="text-gray-400 font-normal"> (optional)</span>
|
|
442
|
+
</label>
|
|
443
|
+
<input
|
|
444
|
+
type="text"
|
|
445
|
+
value={analytics.measurementId}
|
|
446
|
+
onChange={(e) => setAnalytics((a) => ({ ...a, measurementId: e.target.value }))}
|
|
447
|
+
placeholder="G-XXXXXXXXXX"
|
|
448
|
+
className="w-64 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
449
|
+
/>
|
|
450
|
+
<p className="text-xs text-gray-500 mt-1">Leave blank to use your site's default GA4 tag</p>
|
|
451
|
+
</div>
|
|
452
|
+
|
|
453
|
+
<div className="grid grid-cols-2 gap-4">
|
|
454
|
+
<div>
|
|
455
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">Submit Event Name</label>
|
|
456
|
+
<input
|
|
457
|
+
type="text"
|
|
458
|
+
value={analytics.submitEventName}
|
|
459
|
+
onChange={(e) => setAnalytics((a) => ({ ...a, submitEventName: e.target.value }))}
|
|
460
|
+
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
461
|
+
/>
|
|
462
|
+
</div>
|
|
463
|
+
<div>
|
|
464
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">Start Event Name</label>
|
|
465
|
+
<input
|
|
466
|
+
type="text"
|
|
467
|
+
value={analytics.startEventName}
|
|
468
|
+
onChange={(e) => setAnalytics((a) => ({ ...a, startEventName: e.target.value }))}
|
|
469
|
+
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
470
|
+
/>
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
|
|
474
|
+
<div className="border-t border-gray-200 pt-4">
|
|
475
|
+
<div className="flex items-center gap-3 mb-3">
|
|
476
|
+
<input
|
|
477
|
+
type="checkbox"
|
|
478
|
+
id="trackConversion"
|
|
479
|
+
checked={analytics.trackAsConversion}
|
|
480
|
+
onChange={(e) => setAnalytics((a) => ({ ...a, trackAsConversion: e.target.checked }))}
|
|
481
|
+
className="rounded"
|
|
482
|
+
/>
|
|
483
|
+
<label htmlFor="trackConversion" className="text-sm font-medium text-gray-700">
|
|
484
|
+
Track as conversion event
|
|
485
|
+
</label>
|
|
486
|
+
</div>
|
|
487
|
+
|
|
488
|
+
{analytics.trackAsConversion && (
|
|
489
|
+
<div className="grid grid-cols-2 gap-4 ml-6">
|
|
490
|
+
<div>
|
|
491
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">Conversion Value</label>
|
|
492
|
+
<input
|
|
493
|
+
type="number"
|
|
494
|
+
value={analytics.conversionValue}
|
|
495
|
+
onChange={(e) => setAnalytics((a) => ({ ...a, conversionValue: Number(e.target.value) }))}
|
|
496
|
+
min={0}
|
|
497
|
+
step={0.01}
|
|
498
|
+
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
499
|
+
/>
|
|
500
|
+
</div>
|
|
501
|
+
<div>
|
|
502
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">Currency</label>
|
|
503
|
+
<select
|
|
504
|
+
value={analytics.conversionCurrency}
|
|
505
|
+
onChange={(e) => setAnalytics((a) => ({ ...a, conversionCurrency: e.target.value }))}
|
|
506
|
+
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
507
|
+
>
|
|
508
|
+
<option value="USD">USD</option>
|
|
509
|
+
<option value="EUR">EUR</option>
|
|
510
|
+
<option value="GBP">GBP</option>
|
|
511
|
+
<option value="CAD">CAD</option>
|
|
512
|
+
<option value="AUD">AUD</option>
|
|
513
|
+
</select>
|
|
514
|
+
</div>
|
|
515
|
+
</div>
|
|
516
|
+
)}
|
|
517
|
+
</div>
|
|
518
|
+
|
|
519
|
+
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
|
520
|
+
<p className="text-xs text-blue-800">
|
|
521
|
+
<strong>Note:</strong> Your site must have the GA4 snippet or GTM container installed.
|
|
522
|
+
Actuate pushes events via <code className="bg-blue-100 px-1 rounded">gtag()</code> /
|
|
523
|
+
<code className="bg-blue-100 px-1 rounded">dataLayer</code> — it does not load the GA4 script.
|
|
524
|
+
</p>
|
|
525
|
+
</div>
|
|
526
|
+
</div>
|
|
527
|
+
)}
|
|
528
|
+
</div>
|
|
529
|
+
)}
|
|
530
|
+
</div>
|
|
531
|
+
</div>
|
|
532
|
+
);
|
|
533
|
+
}
|