@emdash-cms/plugin-forms 0.0.1

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/src/admin.tsx ADDED
@@ -0,0 +1,1288 @@
1
+ /**
2
+ * Forms Plugin - Admin UI
3
+ *
4
+ * React components for the forms admin pages and dashboard widget.
5
+ * Communicates with the plugin's API routes via fetch.
6
+ */
7
+
8
+ import { Badge, Button, Checkbox, Input, Loader, Select } from "@cloudflare/kumo";
9
+ import {
10
+ Plus,
11
+ Trash,
12
+ Copy,
13
+ Pause,
14
+ Play,
15
+ PencilSimple,
16
+ Star as StarIcon,
17
+ Eye,
18
+ Export,
19
+ Envelope,
20
+ ListBullets,
21
+ ArrowLeft,
22
+ } from "@phosphor-icons/react";
23
+ import type { PluginAdminExports } from "emdash";
24
+ import { apiFetch as baseFetch, getErrorMessage, parseApiResponse } from "emdash/plugin-utils";
25
+ import * as React from "react";
26
+
27
+ // =============================================================================
28
+ // Constants
29
+ // =============================================================================
30
+
31
+ const API = "/_emdash/api/plugins/emdash-forms";
32
+
33
+ const NON_ALNUM_PATTERN = /[^a-z0-9]+/g;
34
+ const LEADING_TRAILING_SEP = /^-|-$/g;
35
+ const LEADING_UNDERSCORE_TRIM = /^_|_$/g;
36
+ const LEADING_DIGIT = /^(\d)/;
37
+
38
+ // =============================================================================
39
+ // API Helpers
40
+ // =============================================================================
41
+
42
+ /** POST to a forms plugin API route with CSRF header. */
43
+ function apiFetch(route: string, body?: unknown): Promise<Response> {
44
+ return baseFetch(`${API}/${route}`, {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json" },
47
+ body: JSON.stringify(body ?? {}),
48
+ });
49
+ }
50
+
51
+ // =============================================================================
52
+ // Types (mirrors plugin types, kept minimal for admin use)
53
+ // =============================================================================
54
+
55
+ interface FormItem {
56
+ id: string;
57
+ name: string;
58
+ slug: string;
59
+ status: "active" | "paused";
60
+ submissionCount: number;
61
+ lastSubmissionAt: string | null;
62
+ createdAt: string;
63
+ updatedAt: string;
64
+ pages: FormPage[];
65
+ settings: FormSettings;
66
+ }
67
+
68
+ interface FormPage {
69
+ title?: string;
70
+ fields: FormField[];
71
+ }
72
+
73
+ interface FormField {
74
+ id: string;
75
+ type: string;
76
+ label: string;
77
+ name: string;
78
+ placeholder?: string;
79
+ helpText?: string;
80
+ required: boolean;
81
+ validation?: Record<string, unknown>;
82
+ options?: Array<{ label: string; value: string }>;
83
+ defaultValue?: string;
84
+ width: "full" | "half";
85
+ }
86
+
87
+ interface FormSettings {
88
+ confirmationMessage: string;
89
+ redirectUrl?: string;
90
+ notifyEmails: string[];
91
+ digestEnabled: boolean;
92
+ digestHour: number;
93
+ webhookUrl?: string;
94
+ retentionDays: number;
95
+ spamProtection: "none" | "honeypot" | "turnstile";
96
+ submitLabel: string;
97
+ }
98
+
99
+ interface SubmissionItem {
100
+ id: string;
101
+ formId: string;
102
+ data: Record<string, unknown>;
103
+ status: "new" | "read" | "archived";
104
+ starred: boolean;
105
+ notes?: string;
106
+ createdAt: string;
107
+ meta: {
108
+ ip: string | null;
109
+ country: string | null;
110
+ };
111
+ }
112
+
113
+ // =============================================================================
114
+ // Shared Helpers
115
+ // =============================================================================
116
+
117
+ function EmptyState({
118
+ icon: Icon,
119
+ title,
120
+ description,
121
+ action,
122
+ }: {
123
+ icon: React.ElementType;
124
+ title: string;
125
+ description: string;
126
+ action?: React.ReactNode;
127
+ }) {
128
+ return (
129
+ <div className="flex flex-col items-center justify-center py-16 text-center">
130
+ <Icon className="h-10 w-10 text-muted-foreground/50 mb-3" />
131
+ <h3 className="font-medium text-muted-foreground">{title}</h3>
132
+ <p className="text-sm text-muted-foreground/70 mt-1 max-w-sm">{description}</p>
133
+ {action && <div className="mt-4">{action}</div>}
134
+ </div>
135
+ );
136
+ }
137
+
138
+ function formatDate(iso: string): string {
139
+ return new Date(iso).toLocaleDateString(undefined, {
140
+ year: "numeric",
141
+ month: "short",
142
+ day: "numeric",
143
+ });
144
+ }
145
+
146
+ function formatDateTime(iso: string): string {
147
+ return new Date(iso).toLocaleString(undefined, {
148
+ year: "numeric",
149
+ month: "short",
150
+ day: "numeric",
151
+ hour: "2-digit",
152
+ minute: "2-digit",
153
+ });
154
+ }
155
+
156
+ function autoName(label: string): string {
157
+ return label
158
+ .toLowerCase()
159
+ .replace(NON_ALNUM_PATTERN, "_")
160
+ .replace(LEADING_UNDERSCORE_TRIM, "")
161
+ .replace(LEADING_DIGIT, "_$1");
162
+ }
163
+
164
+ function autoSlugify(value: string): string {
165
+ return value.toLowerCase().replace(NON_ALNUM_PATTERN, "-").replace(LEADING_TRAILING_SEP, "");
166
+ }
167
+
168
+ function stringifyValue(value: unknown): string {
169
+ if (value == null) return "";
170
+ if (typeof value === "string") return value;
171
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
172
+ return JSON.stringify(value);
173
+ }
174
+
175
+ // =============================================================================
176
+ // Forms List Page
177
+ // =============================================================================
178
+
179
+ function FormsListPage() {
180
+ const [forms, setForms] = React.useState<FormItem[]>([]);
181
+ const [loading, setLoading] = React.useState(true);
182
+ const [error, setError] = React.useState<string | null>(null);
183
+ const [editingForm, setEditingForm] = React.useState<FormItem | null>(null);
184
+ const [creating, setCreating] = React.useState(false);
185
+
186
+ const loadForms = React.useCallback(async () => {
187
+ try {
188
+ const res = await apiFetch("forms/list");
189
+ if (!res.ok) {
190
+ setError("Failed to load forms");
191
+ return;
192
+ }
193
+ const data = await parseApiResponse<{ items: FormItem[] }>(res);
194
+ setForms(data.items);
195
+ } catch {
196
+ setError("Failed to load forms");
197
+ } finally {
198
+ setLoading(false);
199
+ }
200
+ }, []);
201
+
202
+ React.useEffect(() => {
203
+ void loadForms();
204
+ }, [loadForms]);
205
+
206
+ const handleToggleStatus = async (form: FormItem) => {
207
+ const newStatus = form.status === "active" ? "paused" : "active";
208
+ const res = await apiFetch("forms/update", {
209
+ id: form.id,
210
+ status: newStatus,
211
+ });
212
+ if (res.ok) await loadForms();
213
+ };
214
+
215
+ const handleDuplicate = async (form: FormItem) => {
216
+ const res = await apiFetch("forms/duplicate", { id: form.id });
217
+ if (res.ok) await loadForms();
218
+ };
219
+
220
+ const handleDelete = async (form: FormItem) => {
221
+ if (!confirm(`Delete "${form.name}" and all its submissions?`)) return;
222
+ const res = await apiFetch("forms/delete", {
223
+ id: form.id,
224
+ deleteSubmissions: true,
225
+ });
226
+ if (res.ok) await loadForms();
227
+ };
228
+
229
+ if (editingForm || creating) {
230
+ return (
231
+ <FormEditor
232
+ form={editingForm}
233
+ onSave={async () => {
234
+ setEditingForm(null);
235
+ setCreating(false);
236
+ await loadForms();
237
+ }}
238
+ onCancel={() => {
239
+ setEditingForm(null);
240
+ setCreating(false);
241
+ }}
242
+ />
243
+ );
244
+ }
245
+
246
+ if (loading) {
247
+ return (
248
+ <div className="flex items-center justify-center py-16">
249
+ <Loader />
250
+ </div>
251
+ );
252
+ }
253
+
254
+ if (error) {
255
+ return (
256
+ <div className="rounded-lg border border-destructive/50 bg-destructive/5 p-4 text-sm text-destructive">
257
+ {error}
258
+ </div>
259
+ );
260
+ }
261
+
262
+ return (
263
+ <div className="space-y-6">
264
+ <div className="flex items-center justify-between">
265
+ <div>
266
+ <h1 className="text-3xl font-bold">Forms</h1>
267
+ <p className="text-muted-foreground mt-1">Create and manage forms</p>
268
+ </div>
269
+ <Button icon={<Plus />} onClick={() => setCreating(true)}>
270
+ New Form
271
+ </Button>
272
+ </div>
273
+
274
+ {forms.length === 0 ? (
275
+ <EmptyState
276
+ icon={ListBullets}
277
+ title="No forms yet"
278
+ description="Create your first form to start collecting submissions."
279
+ action={
280
+ <Button icon={<Plus />} onClick={() => setCreating(true)}>
281
+ Create Form
282
+ </Button>
283
+ }
284
+ />
285
+ ) : (
286
+ <div className="border rounded-lg overflow-x-auto">
287
+ <table className="w-full text-sm">
288
+ <thead>
289
+ <tr className="border-b bg-muted/50">
290
+ <th className="text-left p-3 font-medium">Name</th>
291
+ <th className="text-left p-3 font-medium">Slug</th>
292
+ <th className="text-left p-3 font-medium">Status</th>
293
+ <th className="text-right p-3 font-medium">Submissions</th>
294
+ <th className="text-left p-3 font-medium">Last Submission</th>
295
+ <th className="text-right p-3 font-medium">Actions</th>
296
+ </tr>
297
+ </thead>
298
+ <tbody>
299
+ {forms.map((form) => (
300
+ <tr key={form.id} className="border-b last:border-0 hover:bg-muted/30">
301
+ <td className="p-3 font-medium">{form.name}</td>
302
+ <td className="p-3 text-muted-foreground font-mono text-xs">{form.slug}</td>
303
+ <td className="p-3">
304
+ <Badge variant={form.status === "active" ? "success" : "warning"}>
305
+ {form.status}
306
+ </Badge>
307
+ </td>
308
+ <td className="p-3 text-right tabular-nums">{form.submissionCount}</td>
309
+ <td className="p-3 text-muted-foreground">
310
+ {form.lastSubmissionAt ? formatDate(form.lastSubmissionAt) : "Never"}
311
+ </td>
312
+ <td className="p-3">
313
+ <div className="flex items-center justify-end gap-1">
314
+ <Button
315
+ variant="ghost"
316
+ shape="square"
317
+ onClick={() => setEditingForm(form)}
318
+ aria-label="Edit"
319
+ >
320
+ <PencilSimple className="h-4 w-4" />
321
+ </Button>
322
+ <Button
323
+ variant="ghost"
324
+ shape="square"
325
+ onClick={() => void handleToggleStatus(form)}
326
+ aria-label={form.status === "active" ? "Pause" : "Resume"}
327
+ >
328
+ {form.status === "active" ? (
329
+ <Pause className="h-4 w-4" />
330
+ ) : (
331
+ <Play className="h-4 w-4" />
332
+ )}
333
+ </Button>
334
+ <Button
335
+ variant="ghost"
336
+ shape="square"
337
+ onClick={() => void handleDuplicate(form)}
338
+ aria-label="Duplicate"
339
+ >
340
+ <Copy className="h-4 w-4" />
341
+ </Button>
342
+ <Button
343
+ variant="ghost"
344
+ shape="square"
345
+ onClick={() => void handleDelete(form)}
346
+ aria-label="Delete"
347
+ className="text-destructive"
348
+ >
349
+ <Trash className="h-4 w-4" />
350
+ </Button>
351
+ </div>
352
+ </td>
353
+ </tr>
354
+ ))}
355
+ </tbody>
356
+ </table>
357
+ </div>
358
+ )}
359
+ </div>
360
+ );
361
+ }
362
+
363
+ // =============================================================================
364
+ // Form Editor (used for both create and edit)
365
+ // =============================================================================
366
+
367
+ const FIELD_TYPES = [
368
+ "text",
369
+ "email",
370
+ "textarea",
371
+ "number",
372
+ "tel",
373
+ "url",
374
+ "date",
375
+ "select",
376
+ "radio",
377
+ "checkbox",
378
+ "checkbox-group",
379
+ "file",
380
+ "hidden",
381
+ ] as const;
382
+
383
+ const FIELD_TYPE_ITEMS = FIELD_TYPES.map((t) => ({ label: t, value: t }));
384
+
385
+ const SPAM_ITEMS = [
386
+ { label: "None", value: "none" },
387
+ { label: "Honeypot", value: "honeypot" },
388
+ { label: "Turnstile", value: "turnstile" },
389
+ ];
390
+
391
+ const WIDTH_ITEMS = [
392
+ { label: "Full", value: "full" },
393
+ { label: "Half", value: "half" },
394
+ ];
395
+
396
+ function FormEditor({
397
+ form,
398
+ onSave,
399
+ onCancel,
400
+ }: {
401
+ form: FormItem | null;
402
+ onSave: () => void;
403
+ onCancel: () => void;
404
+ }) {
405
+ const [name, setName] = React.useState(form?.name ?? "");
406
+ const [slug, setSlug] = React.useState(form?.slug ?? "");
407
+ const [fields, setFields] = React.useState<FormField[]>(form?.pages[0]?.fields ?? []);
408
+ const [settings, setSettings] = React.useState<Partial<FormSettings>>(form?.settings ?? {});
409
+ const [saving, setSaving] = React.useState(false);
410
+ const [error, setError] = React.useState<string | null>(null);
411
+ const [turnstileStatus, setTurnstileStatus] = React.useState<{
412
+ hasSiteKey: boolean;
413
+ hasSecretKey: boolean;
414
+ } | null>(null);
415
+
416
+ const spamProtection = settings.spamProtection ?? "honeypot";
417
+
418
+ React.useEffect(() => {
419
+ if (spamProtection !== "turnstile") return;
420
+ void (async () => {
421
+ try {
422
+ const res = await apiFetch("settings/turnstile-status");
423
+ if (res.ok) {
424
+ setTurnstileStatus(await parseApiResponse(res));
425
+ }
426
+ } catch {
427
+ // ignore — warning just won't show
428
+ }
429
+ })();
430
+ }, [spamProtection]);
431
+
432
+ const isNew = !form;
433
+
434
+ const handleNameChange = (value: string) => {
435
+ setName(value);
436
+ if (isNew) {
437
+ setSlug(autoSlugify(value));
438
+ }
439
+ };
440
+
441
+ const addField = () => {
442
+ setFields((prev) => [
443
+ ...prev,
444
+ {
445
+ id: crypto.randomUUID(),
446
+ type: "text",
447
+ label: "",
448
+ name: "",
449
+ required: false,
450
+ width: "full" as const,
451
+ },
452
+ ]);
453
+ };
454
+
455
+ const updateField = (index: number, updates: Partial<FormField>) => {
456
+ setFields((prev) => prev.map((f, i) => (i === index ? { ...f, ...updates } : f)));
457
+ };
458
+
459
+ const removeField = (index: number) => {
460
+ setFields((prev) => prev.filter((_, i) => i !== index));
461
+ };
462
+
463
+ const moveField = (index: number, direction: -1 | 1) => {
464
+ const target = index + direction;
465
+ if (target < 0 || target >= fields.length) return;
466
+ setFields((prev) => {
467
+ const next = [...prev];
468
+ [next[index]!, next[target]!] = [next[target]!, next[index]!];
469
+ return next;
470
+ });
471
+ };
472
+
473
+ const handleSave = async () => {
474
+ if (!name.trim() || !slug.trim()) {
475
+ setError("Name and slug are required");
476
+ return;
477
+ }
478
+ if (fields.length === 0) {
479
+ setError("At least one field is required");
480
+ return;
481
+ }
482
+ for (const f of fields) {
483
+ if (!f.label.trim() || !f.name.trim()) {
484
+ setError("All fields must have a label and name");
485
+ return;
486
+ }
487
+ }
488
+
489
+ setSaving(true);
490
+ setError(null);
491
+
492
+ const payload = form
493
+ ? {
494
+ id: form.id,
495
+ name,
496
+ slug,
497
+ pages: [{ fields }],
498
+ settings,
499
+ }
500
+ : {
501
+ name,
502
+ slug,
503
+ pages: [{ fields }],
504
+ settings: {
505
+ confirmationMessage: "Thank you for your submission.",
506
+ notifyEmails: [],
507
+ digestEnabled: false,
508
+ digestHour: 9,
509
+ retentionDays: 0,
510
+ spamProtection: "honeypot",
511
+ submitLabel: "Submit",
512
+ ...settings,
513
+ },
514
+ };
515
+
516
+ try {
517
+ const route = form ? "forms/update" : "forms/create";
518
+ const res = await apiFetch(route, payload);
519
+ if (!res.ok) {
520
+ setError(await getErrorMessage(res, "Failed to save form"));
521
+ return;
522
+ }
523
+ onSave();
524
+ } catch {
525
+ setError("Failed to save form");
526
+ } finally {
527
+ setSaving(false);
528
+ }
529
+ };
530
+
531
+ return (
532
+ <div className="space-y-6">
533
+ <div className="flex items-center gap-3">
534
+ <Button variant="ghost" shape="square" onClick={onCancel} aria-label="Back">
535
+ <ArrowLeft className="h-5 w-5" />
536
+ </Button>
537
+ <div>
538
+ <h1 className="text-3xl font-bold">{form ? "Edit Form" : "New Form"}</h1>
539
+ {form && <p className="text-muted-foreground mt-0.5 text-sm">Editing: {form.name}</p>}
540
+ </div>
541
+ </div>
542
+
543
+ {error && (
544
+ <div className="rounded-lg border border-destructive/50 bg-destructive/5 p-3 text-sm text-destructive">
545
+ {error}
546
+ </div>
547
+ )}
548
+
549
+ {/* Name & Slug */}
550
+ <div className="grid grid-cols-2 gap-4">
551
+ <Input
552
+ label="Name"
553
+ value={name}
554
+ onChange={(e) => handleNameChange(e.target.value)}
555
+ placeholder="Contact Form"
556
+ />
557
+ <Input
558
+ label="Slug"
559
+ value={slug}
560
+ onChange={(e) => setSlug(e.target.value)}
561
+ placeholder="contact-form"
562
+ />
563
+ </div>
564
+
565
+ {/* Fields */}
566
+ <div>
567
+ <div className="flex items-center justify-between mb-3">
568
+ <h2 className="text-lg font-semibold">Fields</h2>
569
+ <Button variant="outline" icon={<Plus />} onClick={addField}>
570
+ Add Field
571
+ </Button>
572
+ </div>
573
+
574
+ {fields.length === 0 ? (
575
+ <div className="border border-dashed rounded-lg p-8 text-center text-sm text-muted-foreground">
576
+ No fields yet. Click "Add Field" to get started.
577
+ </div>
578
+ ) : (
579
+ <div className="space-y-3">
580
+ {fields.map((field, index) => (
581
+ <FieldRow
582
+ key={field.id}
583
+ field={field}
584
+ index={index}
585
+ total={fields.length}
586
+ onChange={(updates) => updateField(index, updates)}
587
+ onRemove={() => removeField(index)}
588
+ onMove={(dir) => moveField(index, dir)}
589
+ />
590
+ ))}
591
+ </div>
592
+ )}
593
+ </div>
594
+
595
+ {/* Settings */}
596
+ <div>
597
+ <h2 className="text-lg font-semibold mb-3">Settings</h2>
598
+ <div className="grid grid-cols-2 gap-4">
599
+ <Input
600
+ label="Confirmation Message"
601
+ value={settings.confirmationMessage ?? "Thank you for your submission."}
602
+ onChange={(e) =>
603
+ setSettings((s) => ({
604
+ ...s,
605
+ confirmationMessage: e.target.value,
606
+ }))
607
+ }
608
+ />
609
+ <Input
610
+ label="Submit Button Label"
611
+ value={settings.submitLabel ?? "Submit"}
612
+ onChange={(e) => setSettings((s) => ({ ...s, submitLabel: e.target.value }))}
613
+ />
614
+ <div>
615
+ <Select
616
+ label="Spam Protection"
617
+ hideLabel={false}
618
+ value={spamProtection}
619
+ onValueChange={(v) =>
620
+ setSettings((s) => ({
621
+ ...s,
622
+ spamProtection: (v ?? "honeypot") as FormSettings["spamProtection"],
623
+ }))
624
+ }
625
+ items={SPAM_ITEMS}
626
+ />
627
+ </div>
628
+ {spamProtection === "turnstile" &&
629
+ turnstileStatus &&
630
+ (!turnstileStatus.hasSiteKey || !turnstileStatus.hasSecretKey) && (
631
+ <div className="col-span-2 rounded-lg border border-yellow-500/50 bg-yellow-500/5 p-3 text-sm text-yellow-200">
632
+ Turnstile requires a site key and secret key.{" "}
633
+ {!turnstileStatus.hasSiteKey && !turnstileStatus.hasSecretKey
634
+ ? "Neither is configured."
635
+ : !turnstileStatus.hasSiteKey
636
+ ? "Site key is missing."
637
+ : "Secret key is missing."}{" "}
638
+ Set them in the plugin settings.
639
+ </div>
640
+ )}
641
+ <Input
642
+ label="Retention (days, 0 = forever)"
643
+ type="number"
644
+ value={String(settings.retentionDays ?? 0)}
645
+ onChange={(e) =>
646
+ setSettings((s) => ({
647
+ ...s,
648
+ retentionDays: parseInt(e.target.value) || 0,
649
+ }))
650
+ }
651
+ />
652
+ <div className="col-span-2">
653
+ <Input
654
+ label="Redirect URL (optional)"
655
+ type="url"
656
+ value={settings.redirectUrl ?? ""}
657
+ onChange={(e) => setSettings((s) => ({ ...s, redirectUrl: e.target.value }))}
658
+ placeholder="https://example.com/thank-you"
659
+ />
660
+ </div>
661
+ </div>
662
+ </div>
663
+
664
+ {/* Actions */}
665
+ <div className="flex items-center gap-3 pt-4 border-t">
666
+ <Button onClick={() => void handleSave()} disabled={saving}>
667
+ {saving && <Loader />}
668
+ {form ? "Save Changes" : "Create Form"}
669
+ </Button>
670
+ <Button variant="outline" onClick={onCancel}>
671
+ Cancel
672
+ </Button>
673
+ </div>
674
+ </div>
675
+ );
676
+ }
677
+
678
+ // =============================================================================
679
+ // Field Row (inline field editor within FormEditor)
680
+ // =============================================================================
681
+
682
+ function FieldRow({
683
+ field,
684
+ index,
685
+ total,
686
+ onChange,
687
+ onRemove,
688
+ onMove,
689
+ }: {
690
+ field: FormField;
691
+ index: number;
692
+ total: number;
693
+ onChange: (updates: Partial<FormField>) => void;
694
+ onRemove: () => void;
695
+ onMove: (direction: -1 | 1) => void;
696
+ }) {
697
+ const needsOptions = ["select", "radio", "checkbox-group"].includes(field.type);
698
+
699
+ const handleLabelChange = (label: string) => {
700
+ const updates: Partial<FormField> = { label };
701
+ if (!field.name || field.name === autoName(field.label)) {
702
+ updates.name = autoName(label);
703
+ }
704
+ onChange(updates);
705
+ };
706
+
707
+ return (
708
+ <div className="border rounded-lg p-3 space-y-3">
709
+ <div className="flex items-start gap-2">
710
+ <span className="text-xs text-muted-foreground font-mono w-6 text-center pt-8">
711
+ {index + 1}
712
+ </span>
713
+ <div className="flex-1 space-y-2">
714
+ <div className="grid grid-cols-2 gap-2">
715
+ <Input
716
+ label="Label"
717
+ value={field.label}
718
+ onChange={(e) => handleLabelChange(e.target.value)}
719
+ />
720
+ <Input
721
+ label="Name"
722
+ value={field.name}
723
+ onChange={(e) => onChange({ name: e.target.value })}
724
+ />
725
+ </div>
726
+ <div className="flex items-center gap-4">
727
+ <Select
728
+ label="Type"
729
+ hideLabel={false}
730
+ value={field.type}
731
+ onValueChange={(v) => onChange({ type: v ?? "text" })}
732
+ items={FIELD_TYPE_ITEMS}
733
+ />
734
+ <Select
735
+ label="Width"
736
+ hideLabel={false}
737
+ value={field.width}
738
+ onValueChange={(v) => onChange({ width: v ?? "full" })}
739
+ items={WIDTH_ITEMS}
740
+ />
741
+ <Checkbox
742
+ label="Required"
743
+ checked={field.required}
744
+ onCheckedChange={(checked) => onChange({ required: checked })}
745
+ />
746
+ </div>
747
+ </div>
748
+ <div className="flex items-center gap-0.5 pt-6">
749
+ <Button
750
+ variant="ghost"
751
+ shape="square"
752
+ onClick={() => onMove(-1)}
753
+ disabled={index === 0}
754
+ aria-label="Move up"
755
+ >
756
+ &#9650;
757
+ </Button>
758
+ <Button
759
+ variant="ghost"
760
+ shape="square"
761
+ onClick={() => onMove(1)}
762
+ disabled={index === total - 1}
763
+ aria-label="Move down"
764
+ >
765
+ &#9660;
766
+ </Button>
767
+ <Button
768
+ variant="ghost"
769
+ shape="square"
770
+ onClick={onRemove}
771
+ aria-label="Remove"
772
+ className="text-destructive"
773
+ >
774
+ <Trash className="h-3.5 w-3.5" />
775
+ </Button>
776
+ </div>
777
+ </div>
778
+
779
+ {needsOptions && (
780
+ <OptionsEditor
781
+ options={field.options ?? []}
782
+ onChange={(options) => onChange({ options })}
783
+ />
784
+ )}
785
+ </div>
786
+ );
787
+ }
788
+
789
+ function OptionsEditor({
790
+ options,
791
+ onChange,
792
+ }: {
793
+ options: Array<{ label: string; value: string }>;
794
+ onChange: (options: Array<{ label: string; value: string }>) => void;
795
+ }) {
796
+ const addOption = () => onChange([...options, { label: "", value: "" }]);
797
+ const updateOption = (index: number, updates: Partial<{ label: string; value: string }>) => {
798
+ const next = options.map((o, i) => {
799
+ if (i !== index) return o;
800
+ const updated = { ...o, ...updates };
801
+ if (updates.label && (!o.value || o.value === autoName(o.label))) {
802
+ updated.value = autoName(updates.label);
803
+ }
804
+ return updated;
805
+ });
806
+ onChange(next);
807
+ };
808
+ const removeOption = (index: number) => onChange(options.filter((_, i) => i !== index));
809
+
810
+ return (
811
+ <div className="ml-8 space-y-1">
812
+ <span className="text-xs text-muted-foreground">Options:</span>
813
+ {options.map((opt, i) => (
814
+ <div key={i} className="flex items-center gap-2">
815
+ <Input
816
+ value={opt.label}
817
+ onChange={(e) => updateOption(i, { label: e.target.value })}
818
+ placeholder="Label"
819
+ />
820
+ <Input
821
+ value={opt.value}
822
+ onChange={(e) => updateOption(i, { value: e.target.value })}
823
+ placeholder="value"
824
+ />
825
+ <Button
826
+ variant="ghost"
827
+ shape="square"
828
+ onClick={() => removeOption(i)}
829
+ className="text-destructive"
830
+ aria-label="Remove option"
831
+ >
832
+ <Trash className="h-3 w-3" />
833
+ </Button>
834
+ </div>
835
+ ))}
836
+ <button
837
+ type="button"
838
+ onClick={addOption}
839
+ className="text-xs text-muted-foreground hover:text-foreground"
840
+ >
841
+ + Add option
842
+ </button>
843
+ </div>
844
+ );
845
+ }
846
+
847
+ // =============================================================================
848
+ // Submissions Page
849
+ // =============================================================================
850
+
851
+ function SubmissionsPage() {
852
+ const [forms, setForms] = React.useState<FormItem[]>([]);
853
+ const [selectedFormId, setSelectedFormId] = React.useState<string>("");
854
+ const [submissions, setSubmissions] = React.useState<SubmissionItem[]>([]);
855
+ const [statusFilter, setStatusFilter] = React.useState<string>("");
856
+ const [loading, setLoading] = React.useState(true);
857
+ const [subsLoading, setSubsLoading] = React.useState(false);
858
+ const [selectedSub, setSelectedSub] = React.useState<SubmissionItem | null>(null);
859
+
860
+ React.useEffect(() => {
861
+ void (async () => {
862
+ try {
863
+ const res = await apiFetch("forms/list");
864
+ if (res.ok) {
865
+ const data = await parseApiResponse<{ items: FormItem[] }>(res);
866
+ setForms(data.items);
867
+ if (data.items.length > 0 && data.items[0]) {
868
+ setSelectedFormId(data.items[0].id);
869
+ }
870
+ }
871
+ } finally {
872
+ setLoading(false);
873
+ }
874
+ })();
875
+ }, []);
876
+
877
+ React.useEffect(() => {
878
+ if (!selectedFormId) return;
879
+ setSubsLoading(true);
880
+ setSelectedSub(null);
881
+ void (async () => {
882
+ try {
883
+ const body: Record<string, unknown> = {
884
+ formId: selectedFormId,
885
+ limit: 50,
886
+ };
887
+ if (statusFilter) body.status = statusFilter;
888
+ const res = await apiFetch("submissions/list", body);
889
+ if (res.ok) {
890
+ const data = await parseApiResponse<{ items: SubmissionItem[] }>(res);
891
+ setSubmissions(data.items);
892
+ }
893
+ } finally {
894
+ setSubsLoading(false);
895
+ }
896
+ })();
897
+ }, [selectedFormId, statusFilter]);
898
+
899
+ const handleToggleStar = async (sub: SubmissionItem) => {
900
+ const res = await apiFetch("submissions/update", {
901
+ id: sub.id,
902
+ starred: !sub.starred,
903
+ });
904
+ if (res.ok) {
905
+ setSubmissions((prev) =>
906
+ prev.map((s) => (s.id === sub.id ? { ...s, starred: !s.starred } : s)),
907
+ );
908
+ if (selectedSub?.id === sub.id) setSelectedSub({ ...selectedSub, starred: !sub.starred });
909
+ }
910
+ };
911
+
912
+ const handleMarkRead = async (sub: SubmissionItem) => {
913
+ const newStatus = sub.status === "new" ? "read" : sub.status === "read" ? "archived" : "new";
914
+ const res = await apiFetch("submissions/update", {
915
+ id: sub.id,
916
+ status: newStatus,
917
+ });
918
+ if (res.ok) {
919
+ setSubmissions((prev) =>
920
+ prev.map((s) => (s.id === sub.id ? { ...s, status: newStatus } : s)),
921
+ );
922
+ if (selectedSub?.id === sub.id) setSelectedSub({ ...selectedSub, status: newStatus });
923
+ }
924
+ };
925
+
926
+ const handleDelete = async (sub: SubmissionItem) => {
927
+ if (!confirm("Delete this submission?")) return;
928
+ const res = await apiFetch("submissions/delete", { id: sub.id });
929
+ if (res.ok) {
930
+ setSubmissions((prev) => prev.filter((s) => s.id !== sub.id));
931
+ if (selectedSub?.id === sub.id) setSelectedSub(null);
932
+ }
933
+ };
934
+
935
+ const handleExport = async (format: "csv" | "json") => {
936
+ if (!selectedFormId) return;
937
+ const res = await apiFetch("submissions/export", {
938
+ formId: selectedFormId,
939
+ format,
940
+ });
941
+ if (res.ok) {
942
+ const data = await parseApiResponse<{ data: string; filename?: string }>(res);
943
+ const blob = new Blob([format === "csv" ? data.data : JSON.stringify(data.data, null, 2)], {
944
+ type: format === "csv" ? "text/csv" : "application/json",
945
+ });
946
+ const url = URL.createObjectURL(blob);
947
+ const a = document.createElement("a");
948
+ a.href = url;
949
+ a.download = data.filename ?? `submissions.${format}`;
950
+ a.click();
951
+ URL.revokeObjectURL(url);
952
+ }
953
+ };
954
+
955
+ if (loading) {
956
+ return (
957
+ <div className="flex items-center justify-center py-16">
958
+ <Loader />
959
+ </div>
960
+ );
961
+ }
962
+
963
+ if (forms.length === 0) {
964
+ return (
965
+ <div className="space-y-6">
966
+ <h1 className="text-3xl font-bold">Submissions</h1>
967
+ <EmptyState
968
+ icon={Envelope}
969
+ title="No forms yet"
970
+ description="Create a form first, then submissions will appear here."
971
+ />
972
+ </div>
973
+ );
974
+ }
975
+
976
+ const selectedForm = forms.find((f) => f.id === selectedFormId);
977
+ const formItems = forms.map((f) => ({ label: f.name, value: f.id }));
978
+ const statusItems = [
979
+ { label: "All Status", value: "" },
980
+ { label: "New", value: "new" },
981
+ { label: "Read", value: "read" },
982
+ { label: "Archived", value: "archived" },
983
+ ];
984
+
985
+ return (
986
+ <div className="space-y-6">
987
+ <div className="flex items-center justify-between">
988
+ <h1 className="text-3xl font-bold">Submissions</h1>
989
+ <div className="flex items-center gap-2">
990
+ <Button
991
+ variant="outline"
992
+ icon={<Export />}
993
+ onClick={() => void handleExport("csv")}
994
+ disabled={submissions.length === 0}
995
+ >
996
+ CSV
997
+ </Button>
998
+ <Button
999
+ variant="outline"
1000
+ icon={<Export />}
1001
+ onClick={() => void handleExport("json")}
1002
+ disabled={submissions.length === 0}
1003
+ >
1004
+ JSON
1005
+ </Button>
1006
+ </div>
1007
+ </div>
1008
+
1009
+ {/* Filters */}
1010
+ <div className="flex items-center gap-3">
1011
+ <div className="w-56">
1012
+ <Select
1013
+ value={selectedFormId}
1014
+ onValueChange={(v) => setSelectedFormId(v ?? "")}
1015
+ items={formItems}
1016
+ aria-label="Select form"
1017
+ />
1018
+ </div>
1019
+ <div className="w-40">
1020
+ <Select
1021
+ value={statusFilter}
1022
+ onValueChange={(v) => setStatusFilter(v ?? "")}
1023
+ items={statusItems}
1024
+ aria-label="Filter by status"
1025
+ />
1026
+ </div>
1027
+ {selectedForm && (
1028
+ <span className="text-sm text-muted-foreground">
1029
+ {selectedForm.submissionCount} total
1030
+ </span>
1031
+ )}
1032
+ </div>
1033
+
1034
+ <div className="flex gap-6">
1035
+ {/* Submissions table */}
1036
+ <div className="flex-1 min-w-0">
1037
+ {subsLoading ? (
1038
+ <div className="flex items-center justify-center py-8">
1039
+ <Loader />
1040
+ </div>
1041
+ ) : submissions.length === 0 ? (
1042
+ <EmptyState
1043
+ icon={Envelope}
1044
+ title="No submissions"
1045
+ description={
1046
+ statusFilter
1047
+ ? "No submissions match the current filter."
1048
+ : "This form hasn't received any submissions yet."
1049
+ }
1050
+ />
1051
+ ) : (
1052
+ <div className="border rounded-lg overflow-x-auto">
1053
+ <table className="w-full text-sm">
1054
+ <thead>
1055
+ <tr className="border-b bg-muted/50">
1056
+ <th className="w-8 p-3" />
1057
+ <th className="text-left p-3 font-medium">Date</th>
1058
+ <th className="text-left p-3 font-medium">Preview</th>
1059
+ <th className="text-left p-3 font-medium">Status</th>
1060
+ <th className="text-right p-3 font-medium">Actions</th>
1061
+ </tr>
1062
+ </thead>
1063
+ <tbody>
1064
+ {submissions.map((sub) => {
1065
+ const previewValues = Object.entries(sub.data)
1066
+ .filter(([, v]) => typeof v === "string" && v.length > 0)
1067
+ .slice(0, 2)
1068
+ .map(([, v]) => String(v))
1069
+ .join(" / ");
1070
+
1071
+ return (
1072
+ <tr
1073
+ key={sub.id}
1074
+ className={`border-b last:border-0 hover:bg-muted/30 cursor-pointer ${
1075
+ selectedSub?.id === sub.id ? "bg-muted/50" : ""
1076
+ }`}
1077
+ onClick={() => setSelectedSub(sub)}
1078
+ >
1079
+ <td className="p-3">
1080
+ <button
1081
+ type="button"
1082
+ onClick={(e) => {
1083
+ e.stopPropagation();
1084
+ void handleToggleStar(sub);
1085
+ }}
1086
+ className="text-yellow-500 hover:text-yellow-600"
1087
+ >
1088
+ {sub.starred ? (
1089
+ <StarIcon className="h-4 w-4" weight="fill" />
1090
+ ) : (
1091
+ <StarIcon className="h-4 w-4" />
1092
+ )}
1093
+ </button>
1094
+ </td>
1095
+ <td className="p-3 text-muted-foreground whitespace-nowrap">
1096
+ {formatDate(sub.createdAt)}
1097
+ </td>
1098
+ <td className="p-3 truncate max-w-xs">
1099
+ {previewValues || (
1100
+ <span className="text-muted-foreground italic">Empty</span>
1101
+ )}
1102
+ </td>
1103
+ <td className="p-3">
1104
+ <Badge
1105
+ variant={
1106
+ sub.status === "new"
1107
+ ? "success"
1108
+ : sub.status === "archived"
1109
+ ? "default"
1110
+ : "warning"
1111
+ }
1112
+ >
1113
+ {sub.status}
1114
+ </Badge>
1115
+ </td>
1116
+ <td className="p-3 text-right">
1117
+ <div className="flex items-center justify-end gap-1">
1118
+ <Button
1119
+ variant="ghost"
1120
+ shape="square"
1121
+ onClick={(e: React.MouseEvent) => {
1122
+ e.stopPropagation();
1123
+ void handleMarkRead(sub);
1124
+ }}
1125
+ aria-label="Toggle status"
1126
+ >
1127
+ <Eye className="h-4 w-4" />
1128
+ </Button>
1129
+ <Button
1130
+ variant="ghost"
1131
+ shape="square"
1132
+ className="text-destructive"
1133
+ onClick={(e: React.MouseEvent) => {
1134
+ e.stopPropagation();
1135
+ void handleDelete(sub);
1136
+ }}
1137
+ aria-label="Delete"
1138
+ >
1139
+ <Trash className="h-4 w-4" />
1140
+ </Button>
1141
+ </div>
1142
+ </td>
1143
+ </tr>
1144
+ );
1145
+ })}
1146
+ </tbody>
1147
+ </table>
1148
+ </div>
1149
+ )}
1150
+ </div>
1151
+
1152
+ {/* Detail panel */}
1153
+ {selectedSub && (
1154
+ <div className="w-80 shrink-0 border rounded-lg p-4 space-y-4 self-start sticky top-4">
1155
+ <div className="flex items-center justify-between">
1156
+ <h3 className="font-semibold">Submission Detail</h3>
1157
+ <Badge
1158
+ variant={
1159
+ selectedSub.status === "new"
1160
+ ? "success"
1161
+ : selectedSub.status === "archived"
1162
+ ? "default"
1163
+ : "warning"
1164
+ }
1165
+ >
1166
+ {selectedSub.status}
1167
+ </Badge>
1168
+ </div>
1169
+ <p className="text-xs text-muted-foreground">{formatDateTime(selectedSub.createdAt)}</p>
1170
+
1171
+ <dl className="space-y-2">
1172
+ {Object.entries(selectedSub.data).map(([key, value]) => (
1173
+ <div key={key}>
1174
+ <dt className="text-xs font-medium text-muted-foreground">{key}</dt>
1175
+ <dd className="text-sm mt-0.5 break-words">{stringifyValue(value)}</dd>
1176
+ </div>
1177
+ ))}
1178
+ </dl>
1179
+
1180
+ {selectedSub.meta.country && (
1181
+ <p className="text-xs text-muted-foreground">Country: {selectedSub.meta.country}</p>
1182
+ )}
1183
+
1184
+ {selectedSub.notes && (
1185
+ <div>
1186
+ <dt className="text-xs font-medium text-muted-foreground">Notes</dt>
1187
+ <dd className="text-sm mt-0.5">{selectedSub.notes}</dd>
1188
+ </div>
1189
+ )}
1190
+ </div>
1191
+ )}
1192
+ </div>
1193
+ </div>
1194
+ );
1195
+ }
1196
+
1197
+ // =============================================================================
1198
+ // Dashboard Widget
1199
+ // =============================================================================
1200
+
1201
+ function RecentSubmissionsWidget() {
1202
+ const [forms, setForms] = React.useState<FormItem[]>([]);
1203
+ const [submissions, setSubmissions] = React.useState<SubmissionItem[]>([]);
1204
+ const [loading, setLoading] = React.useState(true);
1205
+
1206
+ React.useEffect(() => {
1207
+ void (async () => {
1208
+ try {
1209
+ const formsRes = await apiFetch("forms/list");
1210
+ if (!formsRes.ok) return;
1211
+ const formsData = await parseApiResponse<{ items: FormItem[] }>(formsRes);
1212
+ setForms(formsData.items);
1213
+
1214
+ if (formsData.items.length > 0 && formsData.items[0]) {
1215
+ const subsRes = await apiFetch("submissions/list", {
1216
+ formId: formsData.items[0].id,
1217
+ limit: 5,
1218
+ });
1219
+ if (subsRes.ok) {
1220
+ const subsData = await parseApiResponse<{
1221
+ items: SubmissionItem[];
1222
+ }>(subsRes);
1223
+ setSubmissions(subsData.items);
1224
+ }
1225
+ }
1226
+ } finally {
1227
+ setLoading(false);
1228
+ }
1229
+ })();
1230
+ }, []);
1231
+
1232
+ if (loading) {
1233
+ return (
1234
+ <div className="flex items-center justify-center py-4">
1235
+ <Loader />
1236
+ </div>
1237
+ );
1238
+ }
1239
+
1240
+ if (forms.length === 0) {
1241
+ return (
1242
+ <div className="text-center text-sm text-muted-foreground py-4">No forms configured</div>
1243
+ );
1244
+ }
1245
+
1246
+ if (submissions.length === 0) {
1247
+ return <div className="text-center text-sm text-muted-foreground py-4">No submissions yet</div>;
1248
+ }
1249
+
1250
+ const formMap = new Map(forms.map((f) => [f.id, f.name]));
1251
+
1252
+ return (
1253
+ <div className="space-y-2">
1254
+ {submissions.map((sub) => {
1255
+ const preview = Object.values(sub.data)
1256
+ .filter((v) => typeof v === "string" && v.length > 0)
1257
+ .slice(0, 1)
1258
+ .map(String)
1259
+ .join("");
1260
+
1261
+ return (
1262
+ <div key={sub.id} className="flex items-center justify-between text-xs">
1263
+ <div className="flex items-center gap-2 min-w-0">
1264
+ <Badge variant={sub.status === "new" ? "success" : "default"}>{sub.status}</Badge>
1265
+ <span className="truncate">{preview || formMap.get(sub.formId) || "Submission"}</span>
1266
+ </div>
1267
+ <span className="text-muted-foreground whitespace-nowrap ml-2">
1268
+ {formatDate(sub.createdAt)}
1269
+ </span>
1270
+ </div>
1271
+ );
1272
+ })}
1273
+ </div>
1274
+ );
1275
+ }
1276
+
1277
+ // =============================================================================
1278
+ // Exports
1279
+ // =============================================================================
1280
+
1281
+ export const pages: PluginAdminExports["pages"] = {
1282
+ "/": FormsListPage,
1283
+ "/submissions": SubmissionsPage,
1284
+ };
1285
+
1286
+ export const widgets: PluginAdminExports["widgets"] = {
1287
+ "recent-submissions": RecentSubmissionsWidget,
1288
+ };