@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/package.json +40 -0
- package/src/admin.tsx +1288 -0
- package/src/astro/Form.astro +26 -0
- package/src/astro/FormEmbed.astro +301 -0
- package/src/astro/index.ts +11 -0
- package/src/client/index.ts +536 -0
- package/src/format.ts +160 -0
- package/src/handlers/cron.ts +151 -0
- package/src/handlers/forms.ts +269 -0
- package/src/handlers/submissions.ts +191 -0
- package/src/handlers/submit.ts +297 -0
- package/src/index.ts +230 -0
- package/src/schemas.ts +215 -0
- package/src/storage.ts +41 -0
- package/src/styles/forms.css +200 -0
- package/src/turnstile.ts +51 -0
- package/src/types.ts +164 -0
- package/src/validation.ts +205 -0
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
|
+
▲
|
|
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
|
+
▼
|
|
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
|
+
};
|