@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.
Files changed (94) hide show
  1. package/LICENSE +21 -21
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +16 -10
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/actuate-admin.css +2 -0
  6. package/dist/components/TipTapEditor.js +78 -78
  7. package/dist/lib/useApiData.d.ts +8 -1
  8. package/dist/lib/useApiData.d.ts.map +1 -1
  9. package/dist/lib/useApiData.js +39 -7
  10. package/dist/lib/useApiData.js.map +1 -1
  11. package/dist/views/Dashboard.d.ts.map +1 -1
  12. package/dist/views/Dashboard.js +8 -3
  13. package/dist/views/Dashboard.js.map +1 -1
  14. package/package.json +10 -5
  15. package/src/AdminRoot.tsx +312 -0
  16. package/src/__tests__/lib/search.test.ts +138 -0
  17. package/src/__tests__/lib/utils.test.ts +19 -0
  18. package/src/__tests__/router/match-route.test.ts +47 -0
  19. package/src/__tests__/router/strip-base.test.ts +30 -0
  20. package/src/components/Breadcrumbs.tsx +92 -0
  21. package/src/components/CommandPalette.tsx +384 -0
  22. package/src/components/ErrorBoundary.tsx +52 -0
  23. package/src/components/FocalPointPicker.tsx +54 -0
  24. package/src/components/FolderTree.tsx +427 -0
  25. package/src/components/LivePreview.tsx +136 -0
  26. package/src/components/LocaleProvider.tsx +51 -0
  27. package/src/components/LocaleSwitcher.tsx +51 -0
  28. package/src/components/MediaPickerModal.tsx +183 -0
  29. package/src/components/PresenceIndicator.tsx +71 -0
  30. package/src/components/SEOPanel.tsx +767 -0
  31. package/src/components/ThemeProvider.tsx +98 -0
  32. package/src/components/TipTapEditor.tsx +469 -0
  33. package/src/components/VersionHistory.tsx +167 -0
  34. package/src/components/ui/Avatar.tsx +42 -0
  35. package/src/components/ui/Badge.tsx +25 -0
  36. package/src/components/ui/Button.tsx +52 -0
  37. package/src/components/ui/CommandPalette.tsx +119 -0
  38. package/src/components/ui/ConfirmDialog.tsx +52 -0
  39. package/src/components/ui/DataTable.tsx +194 -0
  40. package/src/components/ui/EmptyState.tsx +29 -0
  41. package/src/components/ui/Modal.tsx +48 -0
  42. package/src/components/ui/Pagination.tsx +79 -0
  43. package/src/components/ui/SearchInput.tsx +44 -0
  44. package/src/components/ui/Skeleton.tsx +48 -0
  45. package/src/components/ui/Toast.tsx +66 -0
  46. package/src/components/ui/index.ts +24 -0
  47. package/src/fields/ArrayField.tsx +92 -0
  48. package/src/fields/BlockBuilderField.tsx +421 -0
  49. package/src/fields/DateField.tsx +41 -0
  50. package/src/fields/FieldRenderer.tsx +84 -0
  51. package/src/fields/GroupField.tsx +41 -0
  52. package/src/fields/MediaField.tsx +48 -0
  53. package/src/fields/NavBuilderField.tsx +78 -0
  54. package/src/fields/NumberField.tsx +45 -0
  55. package/src/fields/RelationshipField.tsx +245 -0
  56. package/src/fields/RichTextField.tsx +26 -0
  57. package/src/fields/SelectField.tsx +117 -0
  58. package/src/fields/SlugField.tsx +65 -0
  59. package/src/fields/TextField.tsx +48 -0
  60. package/src/fields/ToggleField.tsx +36 -0
  61. package/src/fields/block-types.ts +95 -0
  62. package/src/fields/index.ts +17 -0
  63. package/src/hooks/useContentLock.ts +52 -0
  64. package/src/hooks/useDebounce.ts +14 -0
  65. package/src/hooks/useKeyboardShortcuts.ts +32 -0
  66. package/src/index.ts +55 -0
  67. package/src/layout/Header.tsx +135 -0
  68. package/src/layout/Layout.tsx +77 -0
  69. package/src/layout/Sidebar.tsx +216 -0
  70. package/src/lib/api.ts +67 -0
  71. package/src/lib/search.ts +59 -0
  72. package/src/lib/useApiData.ts +95 -0
  73. package/src/lib/utils.ts +6 -0
  74. package/src/router/index.ts +81 -0
  75. package/src/styles/build-input.css +11 -0
  76. package/src/styles/tailwind.css +11 -6
  77. package/src/styles/theme.css +182 -181
  78. package/src/views/CollectionList.tsx +270 -0
  79. package/src/views/Dashboard.tsx +207 -0
  80. package/src/views/DocumentEdit.tsx +377 -0
  81. package/src/views/FormEditor.tsx +533 -0
  82. package/src/views/FormSubmissions.tsx +316 -0
  83. package/src/views/Forms.tsx +106 -0
  84. package/src/views/Login.tsx +322 -0
  85. package/src/views/MediaBrowser.tsx +774 -0
  86. package/src/views/PageEditor.tsx +192 -0
  87. package/src/views/Pages.tsx +354 -0
  88. package/src/views/PostEditor.tsx +251 -0
  89. package/src/views/Posts.tsx +243 -0
  90. package/src/views/Redirects.tsx +293 -0
  91. package/src/views/SEO.tsx +458 -0
  92. package/src/views/Settings.tsx +811 -0
  93. package/src/views/SetupWizard.tsx +207 -0
  94. 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
+ }