@seriphxyz/astro 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +207 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +111 -0
- package/dist/loader.d.ts +105 -0
- package/dist/loader.js +141 -0
- package/package.json +50 -0
- package/src/Comments.astro +391 -0
- package/src/Form.astro +225 -0
- package/src/Reactions.astro +290 -0
- package/src/index.ts +212 -0
- package/src/loader.ts +264 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Seriph Comments Component
|
|
4
|
+
*
|
|
5
|
+
* Displays threaded comments with a form to post new comments.
|
|
6
|
+
* Customize with CSS custom properties.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* <Comments siteKey={import.meta.env.SERIPH_SITE_KEY} pageId={Astro.url.pathname} />
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
/** Your site key (required) */
|
|
14
|
+
siteKey: string;
|
|
15
|
+
/** Unique identifier for this page (e.g., slug or URL path) */
|
|
16
|
+
pageId: string;
|
|
17
|
+
/** Base URL of your Seriph instance (default: 'https://seriph.xyz') */
|
|
18
|
+
endpoint?: string;
|
|
19
|
+
/** Theme preset: 'light' (default), 'dark', or 'auto' (uses prefers-color-scheme) */
|
|
20
|
+
theme?: "light" | "dark" | "auto";
|
|
21
|
+
/** CSS class to add to the container */
|
|
22
|
+
class?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_ENDPOINT = "https://seriph.xyz";
|
|
26
|
+
const API_PATH = "/api/v1";
|
|
27
|
+
|
|
28
|
+
const {
|
|
29
|
+
siteKey,
|
|
30
|
+
pageId,
|
|
31
|
+
endpoint = DEFAULT_ENDPOINT,
|
|
32
|
+
theme = "light",
|
|
33
|
+
class: className = "",
|
|
34
|
+
} = Astro.props;
|
|
35
|
+
|
|
36
|
+
// Build the API URL
|
|
37
|
+
const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
<div
|
|
41
|
+
class:list={["seriph-comments", `seriph-theme-${theme}`, className]}
|
|
42
|
+
data-seriph-comments
|
|
43
|
+
data-endpoint={baseUrl}
|
|
44
|
+
data-site-key={siteKey}
|
|
45
|
+
data-page-id={pageId}
|
|
46
|
+
>
|
|
47
|
+
<div class="seriph-comments-list"></div>
|
|
48
|
+
|
|
49
|
+
<form class="seriph-comments-form">
|
|
50
|
+
<slot name="form">
|
|
51
|
+
<div class="seriph-form-group">
|
|
52
|
+
<label for="seriph-author-name">Name</label>
|
|
53
|
+
<input type="text" id="seriph-author-name" name="authorName" required />
|
|
54
|
+
</div>
|
|
55
|
+
<div class="seriph-form-group">
|
|
56
|
+
<label for="seriph-author-email">Email (optional)</label>
|
|
57
|
+
<input type="email" id="seriph-author-email" name="authorEmail" />
|
|
58
|
+
</div>
|
|
59
|
+
<div class="seriph-form-group">
|
|
60
|
+
<label for="seriph-content">Comment</label>
|
|
61
|
+
<textarea id="seriph-content" name="content" required rows="3"></textarea>
|
|
62
|
+
</div>
|
|
63
|
+
<button type="submit">Post Comment</button>
|
|
64
|
+
</slot>
|
|
65
|
+
|
|
66
|
+
<!-- Honeypot field for spam protection (hidden from users, catches bots) -->
|
|
67
|
+
<div style="position: absolute; left: -9999px;" aria-hidden="true">
|
|
68
|
+
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" />
|
|
69
|
+
</div>
|
|
70
|
+
</form>
|
|
71
|
+
|
|
72
|
+
<template id="seriph-comment-template">
|
|
73
|
+
<div class="seriph-comment">
|
|
74
|
+
<div class="seriph-comment-header">
|
|
75
|
+
<span class="seriph-comment-author"></span>
|
|
76
|
+
<span class="seriph-comment-date"></span>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="seriph-comment-content"></div>
|
|
79
|
+
<div class="seriph-comment-replies"></div>
|
|
80
|
+
</div>
|
|
81
|
+
</template>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<script>
|
|
85
|
+
interface Comment {
|
|
86
|
+
id: string;
|
|
87
|
+
authorName: string;
|
|
88
|
+
content: string;
|
|
89
|
+
createdAt: string;
|
|
90
|
+
replies: Comment[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Store form load timestamps for spam detection
|
|
94
|
+
const formTimestamps = new WeakMap<Element, number>();
|
|
95
|
+
|
|
96
|
+
document.querySelectorAll("[data-seriph-comments]").forEach((container) => {
|
|
97
|
+
const endpoint = (container as HTMLElement).dataset.endpoint;
|
|
98
|
+
const siteKey = (container as HTMLElement).dataset.siteKey;
|
|
99
|
+
const pageId = (container as HTMLElement).dataset.pageId;
|
|
100
|
+
const list = container.querySelector(".seriph-comments-list");
|
|
101
|
+
const form = container.querySelector(".seriph-comments-form");
|
|
102
|
+
const template = container.querySelector("#seriph-comment-template") as HTMLTemplateElement;
|
|
103
|
+
|
|
104
|
+
if (!endpoint || !siteKey || !pageId || !list || !form || !template) return;
|
|
105
|
+
|
|
106
|
+
// Record when form was loaded (for time-based spam detection)
|
|
107
|
+
formTimestamps.set(form, Math.floor(Date.now() / 1000));
|
|
108
|
+
|
|
109
|
+
async function loadComments() {
|
|
110
|
+
try {
|
|
111
|
+
const response = await fetch(
|
|
112
|
+
`${endpoint}/comments/${encodeURIComponent(pageId!)}`,
|
|
113
|
+
{
|
|
114
|
+
headers: { "X-Seriph-Key": siteKey! },
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
if (!response.ok) return;
|
|
118
|
+
const data = await response.json();
|
|
119
|
+
renderComments(data.comment_threads || []);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
console.error("Failed to load comments:", e);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function renderComments(comments: Comment[]) {
|
|
126
|
+
list!.innerHTML = "";
|
|
127
|
+
if (comments.length === 0) {
|
|
128
|
+
const empty = document.createElement("p");
|
|
129
|
+
empty.className = "seriph-comments-empty";
|
|
130
|
+
empty.textContent = "No comments yet. Be the first!";
|
|
131
|
+
list!.appendChild(empty);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
comments.forEach((comment) => renderComment(comment, list!));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function renderComment(comment: Comment, parent: Element) {
|
|
138
|
+
const clone = template!.content.cloneNode(true) as DocumentFragment;
|
|
139
|
+
const el = clone.querySelector(".seriph-comment") as HTMLElement;
|
|
140
|
+
el.dataset.commentId = comment.id;
|
|
141
|
+
el.querySelector(".seriph-comment-author")!.textContent = comment.authorName;
|
|
142
|
+
el.querySelector(".seriph-comment-date")!.textContent = new Date(
|
|
143
|
+
comment.createdAt,
|
|
144
|
+
).toLocaleDateString();
|
|
145
|
+
el.querySelector(".seriph-comment-content")!.textContent = comment.content;
|
|
146
|
+
|
|
147
|
+
const repliesContainer = el.querySelector(".seriph-comment-replies")!;
|
|
148
|
+
comment.replies?.forEach((reply) => renderComment(reply, repliesContainer));
|
|
149
|
+
|
|
150
|
+
parent.appendChild(clone);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
form.addEventListener("submit", async (e) => {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
const formEl = e.target as HTMLFormElement;
|
|
156
|
+
const formData = new FormData(formEl);
|
|
157
|
+
const submitBtn = formEl.querySelector('[type="submit"]') as HTMLButtonElement;
|
|
158
|
+
|
|
159
|
+
if (submitBtn) submitBtn.disabled = true;
|
|
160
|
+
|
|
161
|
+
// Get honeypot value (should be empty for real users)
|
|
162
|
+
const honeypot = formData.get("_gotcha") as string | null;
|
|
163
|
+
|
|
164
|
+
// Get load timestamp for time-based spam detection
|
|
165
|
+
const loadTimestamp = formTimestamps.get(formEl);
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const response = await fetch(
|
|
169
|
+
`${endpoint}/comments/${encodeURIComponent(pageId)}`,
|
|
170
|
+
{
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: {
|
|
173
|
+
"Content-Type": "application/json",
|
|
174
|
+
"X-Seriph-Key": siteKey!,
|
|
175
|
+
},
|
|
176
|
+
body: JSON.stringify({
|
|
177
|
+
authorName: formData.get("authorName"),
|
|
178
|
+
authorEmail: formData.get("authorEmail") || undefined,
|
|
179
|
+
content: formData.get("content"),
|
|
180
|
+
_gotcha: honeypot || undefined,
|
|
181
|
+
_seriph_ts: loadTimestamp,
|
|
182
|
+
}),
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
if (!response.ok) throw new Error("Failed to post comment");
|
|
187
|
+
|
|
188
|
+
formEl.reset();
|
|
189
|
+
container.dispatchEvent(
|
|
190
|
+
new CustomEvent("seriph:comment-posted", {
|
|
191
|
+
detail: await response.json(),
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// Show pending notice (comment may need approval)
|
|
196
|
+
const notice = document.createElement("p");
|
|
197
|
+
notice.className = "seriph-comment-notice";
|
|
198
|
+
notice.textContent = "Thanks! Your comment is pending approval.";
|
|
199
|
+
formEl.appendChild(notice);
|
|
200
|
+
setTimeout(() => notice.remove(), 5000);
|
|
201
|
+
} catch (e) {
|
|
202
|
+
console.error("Failed to post comment:", e);
|
|
203
|
+
} finally {
|
|
204
|
+
if (submitBtn) submitBtn.disabled = false;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
loadComments();
|
|
209
|
+
});
|
|
210
|
+
</script>
|
|
211
|
+
|
|
212
|
+
<style is:global>
|
|
213
|
+
@layer seriph {
|
|
214
|
+
.seriph-comments {
|
|
215
|
+
--seriph-border-color: #e5e7eb;
|
|
216
|
+
--seriph-bg-color: #f9fafb;
|
|
217
|
+
--seriph-text-color: inherit;
|
|
218
|
+
--seriph-text-muted: #6b7280;
|
|
219
|
+
--seriph-input-bg: white;
|
|
220
|
+
--seriph-focus-color: #3b82f6;
|
|
221
|
+
--seriph-focus-ring: rgba(59, 130, 246, 0.2);
|
|
222
|
+
--seriph-button-bg: #3b82f6;
|
|
223
|
+
--seriph-button-hover: #2563eb;
|
|
224
|
+
--seriph-button-text: white;
|
|
225
|
+
--seriph-notice-bg: #fef3c7;
|
|
226
|
+
--seriph-notice-text: #92400e;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* Dark theme */
|
|
230
|
+
.seriph-comments.seriph-theme-dark {
|
|
231
|
+
--seriph-border-color: #374151;
|
|
232
|
+
--seriph-bg-color: transparent;
|
|
233
|
+
--seriph-text-color: #f3f4f6;
|
|
234
|
+
--seriph-text-muted: #9ca3af;
|
|
235
|
+
--seriph-input-bg: #1f2937;
|
|
236
|
+
--seriph-focus-color: #60a5fa;
|
|
237
|
+
--seriph-focus-ring: rgba(96, 165, 250, 0.3);
|
|
238
|
+
--seriph-button-bg: #3b82f6;
|
|
239
|
+
--seriph-button-hover: #60a5fa;
|
|
240
|
+
--seriph-button-text: white;
|
|
241
|
+
--seriph-notice-bg: #422006;
|
|
242
|
+
--seriph-notice-text: #fcd34d;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* Auto theme - follows prefers-color-scheme */
|
|
246
|
+
@media (prefers-color-scheme: dark) {
|
|
247
|
+
.seriph-comments.seriph-theme-auto {
|
|
248
|
+
--seriph-border-color: #374151;
|
|
249
|
+
--seriph-bg-color: transparent;
|
|
250
|
+
--seriph-text-color: #f3f4f6;
|
|
251
|
+
--seriph-text-muted: #9ca3af;
|
|
252
|
+
--seriph-input-bg: #1f2937;
|
|
253
|
+
--seriph-focus-color: #60a5fa;
|
|
254
|
+
--seriph-focus-ring: rgba(96, 165, 250, 0.3);
|
|
255
|
+
--seriph-button-bg: #3b82f6;
|
|
256
|
+
--seriph-button-hover: #60a5fa;
|
|
257
|
+
--seriph-button-text: white;
|
|
258
|
+
--seriph-notice-bg: #422006;
|
|
259
|
+
--seriph-notice-text: #fcd34d;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.seriph-comments {
|
|
264
|
+
color: var(--seriph-text-color);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.seriph-comments-form {
|
|
268
|
+
margin-top: 1rem;
|
|
269
|
+
padding-top: 1rem;
|
|
270
|
+
border-top: 1px solid var(--seriph-border-color);
|
|
271
|
+
display: grid;
|
|
272
|
+
grid-template-columns: 1fr;
|
|
273
|
+
gap: 0.5rem;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
@media (min-width: 480px) {
|
|
277
|
+
.seriph-comments-form {
|
|
278
|
+
grid-template-columns: 1fr 1fr;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.seriph-comments-form .seriph-form-group:nth-child(3),
|
|
282
|
+
.seriph-comments-form button[type="submit"] {
|
|
283
|
+
grid-column: 1 / -1;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.seriph-form-group {
|
|
288
|
+
margin-bottom: 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.seriph-form-group label {
|
|
292
|
+
display: block;
|
|
293
|
+
margin-bottom: 0.125rem;
|
|
294
|
+
font-weight: 500;
|
|
295
|
+
font-size: 0.75rem;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.seriph-form-group input,
|
|
299
|
+
.seriph-form-group textarea {
|
|
300
|
+
width: 100%;
|
|
301
|
+
padding: 0.375rem 0.5rem;
|
|
302
|
+
border: 1px solid var(--seriph-border-color);
|
|
303
|
+
border-radius: 0.25rem;
|
|
304
|
+
font-size: 0.875rem;
|
|
305
|
+
background: var(--seriph-input-bg);
|
|
306
|
+
color: var(--seriph-text-color);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.seriph-form-group textarea {
|
|
310
|
+
min-height: 4rem;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.seriph-form-group input:focus,
|
|
314
|
+
.seriph-form-group textarea:focus {
|
|
315
|
+
outline: none;
|
|
316
|
+
border-color: var(--seriph-focus-color);
|
|
317
|
+
box-shadow: 0 0 0 2px var(--seriph-focus-ring);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.seriph-comments-form button[type="submit"] {
|
|
321
|
+
padding: 0.375rem 0.75rem;
|
|
322
|
+
background: var(--seriph-button-bg);
|
|
323
|
+
color: var(--seriph-button-text);
|
|
324
|
+
border: none;
|
|
325
|
+
border-radius: 0.25rem;
|
|
326
|
+
font-weight: 500;
|
|
327
|
+
font-size: 0.875rem;
|
|
328
|
+
cursor: pointer;
|
|
329
|
+
transition: background 0.15s;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.seriph-comments-form button[type="submit"]:hover {
|
|
333
|
+
background: var(--seriph-button-hover);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.seriph-comments-form button[type="submit"]:disabled {
|
|
337
|
+
opacity: 0.6;
|
|
338
|
+
cursor: not-allowed;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.seriph-comment {
|
|
342
|
+
padding: 0.75rem;
|
|
343
|
+
background: var(--seriph-bg-color);
|
|
344
|
+
border: 1px solid var(--seriph-border-color);
|
|
345
|
+
border-radius: 0.375rem;
|
|
346
|
+
margin-bottom: 0.5rem;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
.seriph-comment-header {
|
|
350
|
+
display: flex;
|
|
351
|
+
gap: 0.5rem;
|
|
352
|
+
align-items: center;
|
|
353
|
+
margin-bottom: 0.25rem;
|
|
354
|
+
font-size: 0.8125rem;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.seriph-comment-author {
|
|
358
|
+
font-weight: 600;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.seriph-comment-date {
|
|
362
|
+
color: var(--seriph-text-muted);
|
|
363
|
+
font-size: 0.75rem;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
.seriph-comment-content {
|
|
367
|
+
white-space: pre-wrap;
|
|
368
|
+
font-size: 0.875rem;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.seriph-comment-replies {
|
|
372
|
+
margin-left: 1rem;
|
|
373
|
+
margin-top: 0.5rem;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
.seriph-comments-empty {
|
|
377
|
+
color: var(--seriph-text-muted);
|
|
378
|
+
font-style: italic;
|
|
379
|
+
font-size: 0.875rem;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.seriph-comment-notice {
|
|
383
|
+
margin-top: 0.5rem;
|
|
384
|
+
padding: 0.375rem 0.5rem;
|
|
385
|
+
background: var(--seriph-notice-bg);
|
|
386
|
+
color: var(--seriph-notice-text);
|
|
387
|
+
border-radius: 0.25rem;
|
|
388
|
+
font-size: 0.8125rem;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
</style>
|
package/src/Form.astro
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Seriph Form Component
|
|
4
|
+
*
|
|
5
|
+
* A wrapper component that handles form submission to Seriph.
|
|
6
|
+
* You provide your own form fields via the default slot.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* <Form siteKey={import.meta.env.SERIPH_SITE_KEY} formSlug="contact">
|
|
10
|
+
* <input name="name" placeholder="Name" required />
|
|
11
|
+
* <input name="email" type="email" placeholder="Email" required />
|
|
12
|
+
* <textarea name="message" placeholder="Message" required></textarea>
|
|
13
|
+
* <button type="submit">Send</button>
|
|
14
|
+
* </Form>
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
/** Your site key (required) */
|
|
19
|
+
siteKey: string;
|
|
20
|
+
/** The form slug as configured in Seriph (required) */
|
|
21
|
+
formSlug: string;
|
|
22
|
+
/** Base URL of your Seriph instance (default: 'https://seriph.xyz') */
|
|
23
|
+
endpoint?: string;
|
|
24
|
+
/** Theme preset: 'light' (default), 'dark', or 'auto' (uses prefers-color-scheme) */
|
|
25
|
+
theme?: "light" | "dark" | "auto";
|
|
26
|
+
/** CSS class to add to the form */
|
|
27
|
+
class?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_ENDPOINT = "https://seriph.xyz";
|
|
31
|
+
const API_PATH = "/api/v1";
|
|
32
|
+
|
|
33
|
+
const {
|
|
34
|
+
siteKey,
|
|
35
|
+
formSlug,
|
|
36
|
+
endpoint = DEFAULT_ENDPOINT,
|
|
37
|
+
theme = "light",
|
|
38
|
+
class: className = "",
|
|
39
|
+
} = Astro.props;
|
|
40
|
+
|
|
41
|
+
// Build the API URL
|
|
42
|
+
const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
<form
|
|
46
|
+
class:list={["seriph-form", `seriph-theme-${theme}`, className]}
|
|
47
|
+
data-seriph-form
|
|
48
|
+
data-endpoint={baseUrl}
|
|
49
|
+
data-site-key={siteKey}
|
|
50
|
+
data-form-slug={formSlug}
|
|
51
|
+
>
|
|
52
|
+
<slot />
|
|
53
|
+
|
|
54
|
+
<!-- Honeypot field for spam protection (hidden from users, catches bots) -->
|
|
55
|
+
<div style="position: absolute; left: -9999px;" aria-hidden="true">
|
|
56
|
+
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" />
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<!-- Success/Error message container (hidden by default) -->
|
|
60
|
+
<div class="seriph-form-message" style="display: none;" aria-live="polite"></div>
|
|
61
|
+
</form>
|
|
62
|
+
|
|
63
|
+
<script>
|
|
64
|
+
interface FormSubmitResponse {
|
|
65
|
+
success: boolean;
|
|
66
|
+
message: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Store form load timestamps for spam detection
|
|
70
|
+
const formTimestamps = new WeakMap<Element, number>();
|
|
71
|
+
|
|
72
|
+
document.querySelectorAll("[data-seriph-form]").forEach((form) => {
|
|
73
|
+
// Record when form was loaded (for time-based spam detection)
|
|
74
|
+
formTimestamps.set(form, Math.floor(Date.now() / 1000));
|
|
75
|
+
|
|
76
|
+
form.addEventListener("submit", async (e) => {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
|
|
79
|
+
const formEl = e.target as HTMLFormElement;
|
|
80
|
+
const endpoint = formEl.dataset.endpoint;
|
|
81
|
+
const siteKey = formEl.dataset.siteKey;
|
|
82
|
+
const formSlug = formEl.dataset.formSlug;
|
|
83
|
+
const messageEl = formEl.querySelector(".seriph-form-message") as HTMLElement | null;
|
|
84
|
+
const submitBtn = formEl.querySelector('[type="submit"]') as HTMLButtonElement | null;
|
|
85
|
+
|
|
86
|
+
if (!endpoint || !siteKey || !formSlug) {
|
|
87
|
+
const missing = [];
|
|
88
|
+
if (!endpoint) missing.push("endpoint");
|
|
89
|
+
if (!formSlug) missing.push("formSlug");
|
|
90
|
+
if (!siteKey) {
|
|
91
|
+
missing.push("siteKey (check SERIPH_SITE_KEY in your .env)");
|
|
92
|
+
}
|
|
93
|
+
console.error(`Seriph form missing required props: ${missing.join(", ")}`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Collect form data
|
|
98
|
+
const formData = new FormData(formEl);
|
|
99
|
+
const data: Record<string, unknown> = {};
|
|
100
|
+
formData.forEach((value, key) => {
|
|
101
|
+
data[key] = value;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Add timestamp for time-based spam detection
|
|
105
|
+
const loadTimestamp = formTimestamps.get(formEl);
|
|
106
|
+
if (loadTimestamp) {
|
|
107
|
+
data._seriph_ts = loadTimestamp;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Dispatch loading event
|
|
111
|
+
formEl.dispatchEvent(new CustomEvent("seriph:loading"));
|
|
112
|
+
|
|
113
|
+
// Disable submit button
|
|
114
|
+
if (submitBtn) {
|
|
115
|
+
submitBtn.disabled = true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const response = await fetch(`${endpoint}/forms/${formSlug}/submit`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: {
|
|
122
|
+
"Content-Type": "application/json",
|
|
123
|
+
"X-Seriph-Key": siteKey,
|
|
124
|
+
},
|
|
125
|
+
body: JSON.stringify(data),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
const errorText = await response.text();
|
|
130
|
+
throw new Error(errorText || "Submission failed");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const result: FormSubmitResponse = await response.json();
|
|
134
|
+
|
|
135
|
+
// Show success message
|
|
136
|
+
if (messageEl) {
|
|
137
|
+
messageEl.textContent = result.message;
|
|
138
|
+
messageEl.style.display = "block";
|
|
139
|
+
messageEl.className = "seriph-form-message seriph-form-success";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Dispatch success event with response data
|
|
143
|
+
formEl.dispatchEvent(
|
|
144
|
+
new CustomEvent("seriph:success", {
|
|
145
|
+
detail: result,
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Reset form on success
|
|
150
|
+
formEl.reset();
|
|
151
|
+
} catch (error) {
|
|
152
|
+
// Show error message
|
|
153
|
+
if (messageEl) {
|
|
154
|
+
messageEl.textContent = error instanceof Error ? error.message : "Something went wrong";
|
|
155
|
+
messageEl.style.display = "block";
|
|
156
|
+
messageEl.className = "seriph-form-message seriph-form-error";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
formEl.dispatchEvent(
|
|
160
|
+
new CustomEvent("seriph:error", {
|
|
161
|
+
detail: error,
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
} finally {
|
|
165
|
+
// Re-enable submit button
|
|
166
|
+
if (submitBtn) {
|
|
167
|
+
submitBtn.disabled = false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
</script>
|
|
173
|
+
|
|
174
|
+
<style is:global>
|
|
175
|
+
@layer seriph {
|
|
176
|
+
.seriph-form {
|
|
177
|
+
--seriph-success-bg: #dcfce7;
|
|
178
|
+
--seriph-success-text: #166534;
|
|
179
|
+
--seriph-success-border: #bbf7d0;
|
|
180
|
+
--seriph-error-bg: #fef2f2;
|
|
181
|
+
--seriph-error-text: #991b1b;
|
|
182
|
+
--seriph-error-border: #fecaca;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* Dark theme */
|
|
186
|
+
.seriph-form.seriph-theme-dark {
|
|
187
|
+
--seriph-success-bg: rgba(34, 197, 94, 0.15);
|
|
188
|
+
--seriph-success-text: #86efac;
|
|
189
|
+
--seriph-success-border: rgba(34, 197, 94, 0.3);
|
|
190
|
+
--seriph-error-bg: rgba(239, 68, 68, 0.15);
|
|
191
|
+
--seriph-error-text: #fca5a5;
|
|
192
|
+
--seriph-error-border: rgba(239, 68, 68, 0.3);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* Auto theme - follows prefers-color-scheme */
|
|
196
|
+
@media (prefers-color-scheme: dark) {
|
|
197
|
+
.seriph-form.seriph-theme-auto {
|
|
198
|
+
--seriph-success-bg: rgba(34, 197, 94, 0.15);
|
|
199
|
+
--seriph-success-text: #86efac;
|
|
200
|
+
--seriph-success-border: rgba(34, 197, 94, 0.3);
|
|
201
|
+
--seriph-error-bg: rgba(239, 68, 68, 0.15);
|
|
202
|
+
--seriph-error-text: #fca5a5;
|
|
203
|
+
--seriph-error-border: rgba(239, 68, 68, 0.3);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.seriph-form-message {
|
|
208
|
+
margin-top: 1rem;
|
|
209
|
+
padding: 0.75rem 1rem;
|
|
210
|
+
border-radius: 0.375rem;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.seriph-form-success {
|
|
214
|
+
background-color: var(--seriph-success-bg);
|
|
215
|
+
color: var(--seriph-success-text);
|
|
216
|
+
border: 1px solid var(--seriph-success-border);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.seriph-form-error {
|
|
220
|
+
background-color: var(--seriph-error-bg);
|
|
221
|
+
color: var(--seriph-error-text);
|
|
222
|
+
border: 1px solid var(--seriph-error-border);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
</style>
|