@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/schemas.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schemas for route input validation.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { z } from "astro/zod";
|
|
6
|
+
|
|
7
|
+
/** Matches http(s) scheme at start of URL */
|
|
8
|
+
const HTTP_SCHEME_RE = /^https?:\/\//i;
|
|
9
|
+
|
|
10
|
+
/** Validates that a URL string uses http or https scheme. Rejects javascript:/data: URI XSS vectors. */
|
|
11
|
+
const httpUrl = z
|
|
12
|
+
.string()
|
|
13
|
+
.url()
|
|
14
|
+
.refine((url) => HTTP_SCHEME_RE.test(url), "URL must use http or https");
|
|
15
|
+
|
|
16
|
+
// ─── Field Schemas ───────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const fieldOptionSchema = z.object({
|
|
19
|
+
label: z.string().min(1),
|
|
20
|
+
value: z.string().min(1),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const fieldValidationSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
minLength: z.number().int().min(0).optional(),
|
|
26
|
+
maxLength: z.number().int().min(1).optional(),
|
|
27
|
+
min: z.number().optional(),
|
|
28
|
+
max: z.number().optional(),
|
|
29
|
+
pattern: z.string().optional(),
|
|
30
|
+
patternMessage: z.string().optional(),
|
|
31
|
+
accept: z.string().optional(),
|
|
32
|
+
maxFileSize: z.number().int().min(1).optional(),
|
|
33
|
+
})
|
|
34
|
+
.optional();
|
|
35
|
+
|
|
36
|
+
const fieldConditionSchema = z
|
|
37
|
+
.object({
|
|
38
|
+
field: z.string().min(1),
|
|
39
|
+
op: z.enum(["eq", "neq", "filled", "empty"]),
|
|
40
|
+
value: z.string().optional(),
|
|
41
|
+
})
|
|
42
|
+
.optional();
|
|
43
|
+
|
|
44
|
+
export const fieldTypeSchema = z.enum([
|
|
45
|
+
"text",
|
|
46
|
+
"email",
|
|
47
|
+
"textarea",
|
|
48
|
+
"number",
|
|
49
|
+
"tel",
|
|
50
|
+
"url",
|
|
51
|
+
"date",
|
|
52
|
+
"select",
|
|
53
|
+
"radio",
|
|
54
|
+
"checkbox",
|
|
55
|
+
"checkbox-group",
|
|
56
|
+
"file",
|
|
57
|
+
"hidden",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const formFieldSchema = z.object({
|
|
61
|
+
id: z.string().min(1),
|
|
62
|
+
type: fieldTypeSchema,
|
|
63
|
+
label: z.string().min(1),
|
|
64
|
+
name: z
|
|
65
|
+
.string()
|
|
66
|
+
.min(1)
|
|
67
|
+
.regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/, "Invalid field name"),
|
|
68
|
+
placeholder: z.string().optional(),
|
|
69
|
+
helpText: z.string().optional(),
|
|
70
|
+
required: z.boolean(),
|
|
71
|
+
validation: fieldValidationSchema,
|
|
72
|
+
options: z.array(fieldOptionSchema).optional(),
|
|
73
|
+
defaultValue: z.string().optional(),
|
|
74
|
+
width: z.enum(["full", "half"]).default("full"),
|
|
75
|
+
condition: fieldConditionSchema,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const formPageSchema = z.object({
|
|
79
|
+
title: z.string().optional(),
|
|
80
|
+
fields: z.array(formFieldSchema).min(1, "Each page must have at least one field"),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ─── Settings Schema ─────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
const autoresponderSchema = z
|
|
86
|
+
.object({
|
|
87
|
+
subject: z.string().min(1),
|
|
88
|
+
body: z.string().min(1),
|
|
89
|
+
})
|
|
90
|
+
.optional();
|
|
91
|
+
|
|
92
|
+
const formSettingsSchema = z.object({
|
|
93
|
+
confirmationMessage: z.string().min(1).default("Thank you for your submission."),
|
|
94
|
+
redirectUrl: httpUrl.optional().or(z.literal("")),
|
|
95
|
+
notifyEmails: z.array(z.string().email()).default([]),
|
|
96
|
+
digestEnabled: z.boolean().default(false),
|
|
97
|
+
digestHour: z.number().int().min(0).max(23).default(9),
|
|
98
|
+
autoresponder: autoresponderSchema,
|
|
99
|
+
webhookUrl: httpUrl.optional().or(z.literal("")),
|
|
100
|
+
retentionDays: z.number().int().min(0).default(0),
|
|
101
|
+
spamProtection: z.enum(["none", "honeypot", "turnstile"]).default("honeypot"),
|
|
102
|
+
submitLabel: z.string().min(1).default("Submit"),
|
|
103
|
+
nextLabel: z.string().optional(),
|
|
104
|
+
prevLabel: z.string().optional(),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ─── Form CRUD Schemas ──────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
export const formCreateSchema = z.object({
|
|
110
|
+
name: z.string().min(1).max(200),
|
|
111
|
+
slug: z
|
|
112
|
+
.string()
|
|
113
|
+
.min(1)
|
|
114
|
+
.max(100)
|
|
115
|
+
.regex(/^[a-z][a-z0-9-]*$/, "Slug must be lowercase alphanumeric with hyphens"),
|
|
116
|
+
pages: z.array(formPageSchema).min(1),
|
|
117
|
+
settings: formSettingsSchema,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export const formUpdateSchema = z.object({
|
|
121
|
+
id: z.string().min(1),
|
|
122
|
+
name: z.string().min(1).max(200).optional(),
|
|
123
|
+
slug: z
|
|
124
|
+
.string()
|
|
125
|
+
.min(1)
|
|
126
|
+
.max(100)
|
|
127
|
+
.regex(/^[a-z][a-z0-9-]*$/)
|
|
128
|
+
.optional(),
|
|
129
|
+
pages: z.array(formPageSchema).min(1).optional(),
|
|
130
|
+
settings: formSettingsSchema.partial().optional(),
|
|
131
|
+
status: z.enum(["active", "paused"]).optional(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
export const formDeleteSchema = z.object({
|
|
135
|
+
id: z.string().min(1),
|
|
136
|
+
deleteSubmissions: z.boolean().default(true),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
export const formDuplicateSchema = z.object({
|
|
140
|
+
id: z.string().min(1),
|
|
141
|
+
name: z.string().min(1).max(200).optional(),
|
|
142
|
+
slug: z
|
|
143
|
+
.string()
|
|
144
|
+
.min(1)
|
|
145
|
+
.max(100)
|
|
146
|
+
.regex(/^[a-z][a-z0-9-]*$/)
|
|
147
|
+
.optional(),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
export const definitionSchema = z.object({
|
|
151
|
+
id: z.string().min(1),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
export type DefinitionInput = z.infer<typeof definitionSchema>;
|
|
155
|
+
|
|
156
|
+
// ─── Submission Schemas ──────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
export const submitSchema = z.object({
|
|
159
|
+
formId: z.string().min(1),
|
|
160
|
+
data: z.record(z.string(), z.unknown()),
|
|
161
|
+
files: z
|
|
162
|
+
.record(
|
|
163
|
+
z.string(),
|
|
164
|
+
z.object({
|
|
165
|
+
filename: z.string(),
|
|
166
|
+
contentType: z.string(),
|
|
167
|
+
bytes: z.custom<ArrayBuffer>(),
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
.optional(),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
export const submissionsListSchema = z.object({
|
|
174
|
+
formId: z.string().min(1),
|
|
175
|
+
status: z.enum(["new", "read", "archived"]).optional(),
|
|
176
|
+
starred: z.boolean().optional(),
|
|
177
|
+
cursor: z.string().optional(),
|
|
178
|
+
limit: z.number().int().min(1).max(100).default(50),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
export const submissionGetSchema = z.object({
|
|
182
|
+
id: z.string().min(1),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
export const submissionUpdateSchema = z.object({
|
|
186
|
+
id: z.string().min(1),
|
|
187
|
+
status: z.enum(["new", "read", "archived"]).optional(),
|
|
188
|
+
starred: z.boolean().optional(),
|
|
189
|
+
notes: z.string().optional(),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
export const submissionDeleteSchema = z.object({
|
|
193
|
+
id: z.string().min(1),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
export const exportSchema = z.object({
|
|
197
|
+
formId: z.string().min(1),
|
|
198
|
+
format: z.enum(["csv", "json"]).default("csv"),
|
|
199
|
+
status: z.enum(["new", "read", "archived"]).optional(),
|
|
200
|
+
from: z.string().datetime().optional(),
|
|
201
|
+
to: z.string().datetime().optional(),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ─── Type Exports ────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
export type FormCreateInput = z.infer<typeof formCreateSchema>;
|
|
207
|
+
export type FormUpdateInput = z.infer<typeof formUpdateSchema>;
|
|
208
|
+
export type FormDeleteInput = z.infer<typeof formDeleteSchema>;
|
|
209
|
+
export type FormDuplicateInput = z.infer<typeof formDuplicateSchema>;
|
|
210
|
+
export type SubmitInput = z.infer<typeof submitSchema>;
|
|
211
|
+
export type SubmissionsListInput = z.infer<typeof submissionsListSchema>;
|
|
212
|
+
export type SubmissionGetInput = z.infer<typeof submissionGetSchema>;
|
|
213
|
+
export type SubmissionUpdateInput = z.infer<typeof submissionUpdateSchema>;
|
|
214
|
+
export type SubmissionDeleteInput = z.infer<typeof submissionDeleteSchema>;
|
|
215
|
+
export type ExportInput = z.infer<typeof exportSchema>;
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage type definition for the forms plugin.
|
|
3
|
+
*
|
|
4
|
+
* Declares the two storage collections and their indexes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { PluginStorageConfig } from "emdash";
|
|
8
|
+
|
|
9
|
+
export type FormsStorage = PluginStorageConfig & {
|
|
10
|
+
forms: {
|
|
11
|
+
indexes: ["status", "createdAt"];
|
|
12
|
+
uniqueIndexes: ["slug"];
|
|
13
|
+
};
|
|
14
|
+
submissions: {
|
|
15
|
+
indexes: [
|
|
16
|
+
"formId",
|
|
17
|
+
"status",
|
|
18
|
+
"starred",
|
|
19
|
+
"createdAt",
|
|
20
|
+
["formId", "createdAt"],
|
|
21
|
+
["formId", "status"],
|
|
22
|
+
];
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const FORMS_STORAGE_CONFIG = {
|
|
27
|
+
forms: {
|
|
28
|
+
indexes: ["status", "createdAt"] as const,
|
|
29
|
+
uniqueIndexes: ["slug"] as const,
|
|
30
|
+
},
|
|
31
|
+
submissions: {
|
|
32
|
+
indexes: [
|
|
33
|
+
"formId",
|
|
34
|
+
"status",
|
|
35
|
+
"starred",
|
|
36
|
+
"createdAt",
|
|
37
|
+
["formId", "createdAt"],
|
|
38
|
+
["formId", "status"],
|
|
39
|
+
] as const,
|
|
40
|
+
},
|
|
41
|
+
} satisfies PluginStorageConfig;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional minimal styles for EmDash forms.
|
|
3
|
+
*
|
|
4
|
+
* Uses CSS custom properties for theming.
|
|
5
|
+
* Import this stylesheet in your site to get basic form styling:
|
|
6
|
+
*
|
|
7
|
+
* import "@emdash-cms/plugin-forms/styles";
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
.ec-form {
|
|
11
|
+
--ec-form-gap: 1rem;
|
|
12
|
+
--ec-form-field-border: 1px solid #d1d5db;
|
|
13
|
+
--ec-form-field-radius: 6px;
|
|
14
|
+
--ec-form-field-padding: 0.5rem 0.75rem;
|
|
15
|
+
--ec-form-field-bg: #fff;
|
|
16
|
+
--ec-form-error-color: #dc2626;
|
|
17
|
+
--ec-form-required-color: #dc2626;
|
|
18
|
+
--ec-form-help-color: #6b7280;
|
|
19
|
+
--ec-form-submit-bg: #111827;
|
|
20
|
+
--ec-form-submit-color: #fff;
|
|
21
|
+
--ec-form-submit-radius: 6px;
|
|
22
|
+
--ec-form-submit-padding: 0.625rem 1.25rem;
|
|
23
|
+
|
|
24
|
+
display: flex;
|
|
25
|
+
flex-direction: column;
|
|
26
|
+
gap: var(--ec-form-gap);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.ec-form-page {
|
|
30
|
+
display: flex;
|
|
31
|
+
flex-wrap: wrap;
|
|
32
|
+
gap: var(--ec-form-gap);
|
|
33
|
+
border: none;
|
|
34
|
+
margin: 0;
|
|
35
|
+
padding: 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.ec-form-page-title {
|
|
39
|
+
width: 100%;
|
|
40
|
+
font-size: 1.125rem;
|
|
41
|
+
font-weight: 600;
|
|
42
|
+
padding: 0;
|
|
43
|
+
margin-bottom: 0.25rem;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.ec-form-field {
|
|
47
|
+
display: flex;
|
|
48
|
+
flex-direction: column;
|
|
49
|
+
gap: 0.25rem;
|
|
50
|
+
width: 100%;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.ec-form-field--half {
|
|
54
|
+
width: calc(50% - var(--ec-form-gap) / 2);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.ec-form-label {
|
|
58
|
+
font-size: 0.875rem;
|
|
59
|
+
font-weight: 500;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.ec-form-required {
|
|
63
|
+
color: var(--ec-form-required-color);
|
|
64
|
+
margin-left: 0.125rem;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.ec-form-input,
|
|
68
|
+
.ec-form select,
|
|
69
|
+
.ec-form textarea {
|
|
70
|
+
border: var(--ec-form-field-border);
|
|
71
|
+
border-radius: var(--ec-form-field-radius);
|
|
72
|
+
padding: var(--ec-form-field-padding);
|
|
73
|
+
background: var(--ec-form-field-bg);
|
|
74
|
+
font: inherit;
|
|
75
|
+
width: 100%;
|
|
76
|
+
box-sizing: border-box;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.ec-form-input:focus,
|
|
80
|
+
.ec-form select:focus,
|
|
81
|
+
.ec-form textarea:focus {
|
|
82
|
+
outline: 2px solid #2563eb;
|
|
83
|
+
outline-offset: -1px;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.ec-form textarea {
|
|
87
|
+
min-height: 6rem;
|
|
88
|
+
resize: vertical;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.ec-form-help {
|
|
92
|
+
font-size: 0.75rem;
|
|
93
|
+
color: var(--ec-form-help-color);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.ec-form-error {
|
|
97
|
+
font-size: 0.75rem;
|
|
98
|
+
color: var(--ec-form-error-color);
|
|
99
|
+
min-height: 1em;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.ec-form-error:empty {
|
|
103
|
+
display: none;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.ec-form-radio-group,
|
|
107
|
+
.ec-form-checkbox-group {
|
|
108
|
+
display: flex;
|
|
109
|
+
flex-direction: column;
|
|
110
|
+
gap: 0.375rem;
|
|
111
|
+
border: none;
|
|
112
|
+
padding: 0;
|
|
113
|
+
margin: 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.ec-form-radio-label,
|
|
117
|
+
.ec-form-checkbox-label {
|
|
118
|
+
display: flex;
|
|
119
|
+
align-items: center;
|
|
120
|
+
gap: 0.5rem;
|
|
121
|
+
font-size: 0.875rem;
|
|
122
|
+
cursor: pointer;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.ec-form-nav {
|
|
126
|
+
display: flex;
|
|
127
|
+
gap: 0.75rem;
|
|
128
|
+
align-items: center;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.ec-form-submit,
|
|
132
|
+
.ec-form-next {
|
|
133
|
+
background: var(--ec-form-submit-bg);
|
|
134
|
+
color: var(--ec-form-submit-color);
|
|
135
|
+
border: none;
|
|
136
|
+
border-radius: var(--ec-form-submit-radius);
|
|
137
|
+
padding: var(--ec-form-submit-padding);
|
|
138
|
+
font: inherit;
|
|
139
|
+
font-weight: 500;
|
|
140
|
+
cursor: pointer;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.ec-form-submit:hover,
|
|
144
|
+
.ec-form-next:hover {
|
|
145
|
+
opacity: 0.9;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.ec-form-submit:disabled {
|
|
149
|
+
opacity: 0.6;
|
|
150
|
+
cursor: not-allowed;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.ec-form-prev {
|
|
154
|
+
background: transparent;
|
|
155
|
+
border: var(--ec-form-field-border);
|
|
156
|
+
border-radius: var(--ec-form-submit-radius);
|
|
157
|
+
padding: var(--ec-form-submit-padding);
|
|
158
|
+
font: inherit;
|
|
159
|
+
font-weight: 500;
|
|
160
|
+
cursor: pointer;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.ec-form-progress {
|
|
164
|
+
font-size: 0.875rem;
|
|
165
|
+
color: var(--ec-form-help-color);
|
|
166
|
+
text-align: center;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.ec-form-status {
|
|
170
|
+
padding: 0.75rem;
|
|
171
|
+
border-radius: var(--ec-form-field-radius);
|
|
172
|
+
font-size: 0.875rem;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.ec-form-status:empty {
|
|
176
|
+
display: none;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.ec-form-status--success {
|
|
180
|
+
background: #f0fdf4;
|
|
181
|
+
color: #166534;
|
|
182
|
+
border: 1px solid #bbf7d0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.ec-form-status--error {
|
|
186
|
+
background: #fef2f2;
|
|
187
|
+
color: #991b1b;
|
|
188
|
+
border: 1px solid #fecaca;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.ec-form-turnstile {
|
|
192
|
+
margin-top: 0.5rem;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* Responsive: stack half-width fields on small screens */
|
|
196
|
+
@media (max-width: 640px) {
|
|
197
|
+
.ec-form-field--half {
|
|
198
|
+
width: 100%;
|
|
199
|
+
}
|
|
200
|
+
}
|
package/src/turnstile.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turnstile verification helper.
|
|
3
|
+
*
|
|
4
|
+
* Verifies a Turnstile token server-side via the Cloudflare API.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
|
8
|
+
|
|
9
|
+
export interface TurnstileResult {
|
|
10
|
+
success: boolean;
|
|
11
|
+
errorCodes: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Verify a Turnstile response token.
|
|
16
|
+
*
|
|
17
|
+
* @param token - The `cf-turnstile-response` token from the client
|
|
18
|
+
* @param secretKey - The Turnstile secret key
|
|
19
|
+
* @param httpFetch - The capability-gated fetch function from ctx.http
|
|
20
|
+
* @param remoteIp - Optional client IP for additional verification
|
|
21
|
+
*/
|
|
22
|
+
export async function verifyTurnstile(
|
|
23
|
+
token: string,
|
|
24
|
+
secretKey: string,
|
|
25
|
+
httpFetch: (url: string, init?: RequestInit) => Promise<Response>,
|
|
26
|
+
remoteIp?: string | null,
|
|
27
|
+
): Promise<TurnstileResult> {
|
|
28
|
+
const body: Record<string, string> = {
|
|
29
|
+
secret: secretKey,
|
|
30
|
+
response: token,
|
|
31
|
+
};
|
|
32
|
+
if (remoteIp) {
|
|
33
|
+
body.remoteip = remoteIp;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const res = await httpFetch(VERIFY_URL, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { "Content-Type": "application/json" },
|
|
39
|
+
body: JSON.stringify(body),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const data = (await res.json()) as {
|
|
43
|
+
success: boolean;
|
|
44
|
+
"error-codes"?: string[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
success: data.success,
|
|
49
|
+
errorCodes: data["error-codes"] ?? [],
|
|
50
|
+
};
|
|
51
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for the forms plugin.
|
|
3
|
+
*
|
|
4
|
+
* These define the data model stored in plugin storage.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ─── Form Definitions ────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export interface FormDefinition {
|
|
10
|
+
name: string;
|
|
11
|
+
slug: string;
|
|
12
|
+
pages: FormPage[];
|
|
13
|
+
settings: FormSettings;
|
|
14
|
+
status: "active" | "paused";
|
|
15
|
+
submissionCount: number;
|
|
16
|
+
lastSubmissionAt: string | null;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
updatedAt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FormPage {
|
|
22
|
+
/** Page title shown in multi-page progress indicator. Optional for single-page forms. */
|
|
23
|
+
title?: string;
|
|
24
|
+
fields: FormField[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FormSettings {
|
|
28
|
+
/** Message shown after successful submission */
|
|
29
|
+
confirmationMessage: string;
|
|
30
|
+
/** Redirect URL after submission (overrides confirmation message) */
|
|
31
|
+
redirectUrl?: string;
|
|
32
|
+
/** Email addresses for submission notifications */
|
|
33
|
+
notifyEmails: string[];
|
|
34
|
+
/** Enable daily digest instead of per-submission notifications */
|
|
35
|
+
digestEnabled: boolean;
|
|
36
|
+
/** Hour (0-23) to send digest, in site timezone */
|
|
37
|
+
digestHour: number;
|
|
38
|
+
/** Autoresponder email sent to the submitter */
|
|
39
|
+
autoresponder?: {
|
|
40
|
+
subject: string;
|
|
41
|
+
body: string;
|
|
42
|
+
};
|
|
43
|
+
/** Webhook URL for submission notifications */
|
|
44
|
+
webhookUrl?: string;
|
|
45
|
+
/** Days to retain submissions (0 = forever) */
|
|
46
|
+
retentionDays: number;
|
|
47
|
+
/** Spam protection strategy */
|
|
48
|
+
spamProtection: "none" | "honeypot" | "turnstile";
|
|
49
|
+
/** Submit button text */
|
|
50
|
+
submitLabel: string;
|
|
51
|
+
/** Label for Next button on multi-page forms */
|
|
52
|
+
nextLabel?: string;
|
|
53
|
+
/** Label for Previous button on multi-page forms */
|
|
54
|
+
prevLabel?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Form Fields ─────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export interface FormField {
|
|
60
|
+
id: string;
|
|
61
|
+
type: FieldType;
|
|
62
|
+
label: string;
|
|
63
|
+
/** HTML input name, unique per form */
|
|
64
|
+
name: string;
|
|
65
|
+
placeholder?: string;
|
|
66
|
+
helpText?: string;
|
|
67
|
+
required: boolean;
|
|
68
|
+
validation?: FieldValidation;
|
|
69
|
+
/** For select, radio, checkbox-group */
|
|
70
|
+
options?: FieldOption[];
|
|
71
|
+
defaultValue?: string;
|
|
72
|
+
/** Layout hint */
|
|
73
|
+
width: "full" | "half";
|
|
74
|
+
/** Conditional visibility */
|
|
75
|
+
condition?: FieldCondition;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type FieldType =
|
|
79
|
+
| "text"
|
|
80
|
+
| "email"
|
|
81
|
+
| "textarea"
|
|
82
|
+
| "number"
|
|
83
|
+
| "tel"
|
|
84
|
+
| "url"
|
|
85
|
+
| "date"
|
|
86
|
+
| "select"
|
|
87
|
+
| "radio"
|
|
88
|
+
| "checkbox"
|
|
89
|
+
| "checkbox-group"
|
|
90
|
+
| "file"
|
|
91
|
+
| "hidden";
|
|
92
|
+
|
|
93
|
+
export interface FieldValidation {
|
|
94
|
+
minLength?: number;
|
|
95
|
+
maxLength?: number;
|
|
96
|
+
min?: number;
|
|
97
|
+
max?: number;
|
|
98
|
+
/** Regex pattern */
|
|
99
|
+
pattern?: string;
|
|
100
|
+
/** Error message for pattern mismatch */
|
|
101
|
+
patternMessage?: string;
|
|
102
|
+
/** File types, e.g. ".pdf,.doc" */
|
|
103
|
+
accept?: string;
|
|
104
|
+
/** Max file size in bytes */
|
|
105
|
+
maxFileSize?: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface FieldOption {
|
|
109
|
+
label: string;
|
|
110
|
+
value: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface FieldCondition {
|
|
114
|
+
/** Name of the controlling field */
|
|
115
|
+
field: string;
|
|
116
|
+
op: "eq" | "neq" | "filled" | "empty";
|
|
117
|
+
value?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Submissions ─────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export interface Submission {
|
|
123
|
+
formId: string;
|
|
124
|
+
data: Record<string, unknown>;
|
|
125
|
+
files?: SubmissionFile[];
|
|
126
|
+
status: "new" | "read" | "archived";
|
|
127
|
+
starred: boolean;
|
|
128
|
+
notes?: string;
|
|
129
|
+
createdAt: string;
|
|
130
|
+
meta: SubmissionMeta;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface SubmissionFile {
|
|
134
|
+
fieldName: string;
|
|
135
|
+
filename: string;
|
|
136
|
+
contentType: string;
|
|
137
|
+
size: number;
|
|
138
|
+
/** Reference to media library item */
|
|
139
|
+
mediaId: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface SubmissionMeta {
|
|
143
|
+
ip: string | null;
|
|
144
|
+
userAgent: string | null;
|
|
145
|
+
referer: string | null;
|
|
146
|
+
country: string | null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Helpers ─────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/** Get all fields across all pages */
|
|
152
|
+
export function getFormFields(form: FormDefinition): FormField[] {
|
|
153
|
+
return form.pages.flatMap((p) => p.fields);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Check if a form has multiple pages */
|
|
157
|
+
export function isMultiPage(form: FormDefinition): boolean {
|
|
158
|
+
return form.pages.length > 1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Check if a form has any file fields */
|
|
162
|
+
export function hasFileFields(form: FormDefinition): boolean {
|
|
163
|
+
return getFormFields(form).some((f) => f.type === "file");
|
|
164
|
+
}
|