@seriphxyz/astro 0.1.2 → 0.1.7
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/README.md +43 -0
- package/dist/index.d.ts +8 -63
- package/dist/index.js +14 -111
- package/dist/loader.d.ts +5 -53
- package/dist/loader.js +5 -75
- package/package.json +15 -9
- package/src/Comments.astro +85 -3
- package/src/Subscribe.astro +302 -0
- package/src/SubscribeForm.astro +142 -0
- package/src/index.ts +83 -206
- package/src/loader.ts +19 -150
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Seriph Subscribe Component
|
|
4
|
+
*
|
|
5
|
+
* A subscription form for email updates. Implements double opt-in -
|
|
6
|
+
* users receive a confirmation email and must click to confirm.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* <Subscribe siteKey={import.meta.env.SERIPH_SITE_KEY} />
|
|
10
|
+
*
|
|
11
|
+
* @example With custom styling
|
|
12
|
+
* <Subscribe
|
|
13
|
+
* siteKey={import.meta.env.SERIPH_SITE_KEY}
|
|
14
|
+
* buttonText="Get Updates"
|
|
15
|
+
* placeholder="Enter your email"
|
|
16
|
+
* successMessage="Check your inbox!"
|
|
17
|
+
* />
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
/** Your site key (required) */
|
|
22
|
+
siteKey: string;
|
|
23
|
+
/** Base URL of your Seriph instance (default: 'https://seriph.xyz') */
|
|
24
|
+
endpoint?: string;
|
|
25
|
+
/** Submit button text (default: 'Subscribe') */
|
|
26
|
+
buttonText?: string;
|
|
27
|
+
/** Email input placeholder (default: 'your@email.com') */
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
/** Success message override (default: uses server response) */
|
|
30
|
+
successMessage?: string;
|
|
31
|
+
/** Theme preset: 'light' (default), 'dark', or 'auto' (uses prefers-color-scheme) */
|
|
32
|
+
theme?: "light" | "dark" | "auto";
|
|
33
|
+
/** CSS class to add to the form */
|
|
34
|
+
class?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_ENDPOINT = "https://seriph.xyz";
|
|
38
|
+
const API_PATH = "/api/v1";
|
|
39
|
+
|
|
40
|
+
const {
|
|
41
|
+
siteKey,
|
|
42
|
+
endpoint = DEFAULT_ENDPOINT,
|
|
43
|
+
buttonText = "Subscribe",
|
|
44
|
+
placeholder = "your@email.com",
|
|
45
|
+
successMessage,
|
|
46
|
+
theme = "light",
|
|
47
|
+
class: className = "",
|
|
48
|
+
} = Astro.props;
|
|
49
|
+
|
|
50
|
+
// Build the API URL
|
|
51
|
+
const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
<form
|
|
55
|
+
class:list={["seriph-subscribe", `seriph-theme-${theme}`, className]}
|
|
56
|
+
data-seriph-subscribe
|
|
57
|
+
data-endpoint={baseUrl}
|
|
58
|
+
data-site-key={siteKey}
|
|
59
|
+
data-success-message={successMessage}
|
|
60
|
+
>
|
|
61
|
+
<div class="seriph-subscribe-input-group">
|
|
62
|
+
<input
|
|
63
|
+
type="email"
|
|
64
|
+
name="email"
|
|
65
|
+
required
|
|
66
|
+
placeholder={placeholder}
|
|
67
|
+
class="seriph-subscribe-input"
|
|
68
|
+
autocomplete="email"
|
|
69
|
+
/>
|
|
70
|
+
<button type="submit" class="seriph-subscribe-button">
|
|
71
|
+
{buttonText}
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<!-- Honeypot field for spam protection (hidden from users, catches bots) -->
|
|
76
|
+
<div style="position: absolute; left: -9999px;" aria-hidden="true">
|
|
77
|
+
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" />
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<!-- Success/Error message container (hidden by default) -->
|
|
81
|
+
<div class="seriph-subscribe-message" style="display: none;" aria-live="polite"></div>
|
|
82
|
+
</form>
|
|
83
|
+
|
|
84
|
+
<script>
|
|
85
|
+
interface SubscribeResponse {
|
|
86
|
+
success: boolean;
|
|
87
|
+
message: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
document.querySelectorAll("[data-seriph-subscribe]").forEach((form) => {
|
|
91
|
+
form.addEventListener("submit", async (e) => {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
|
|
94
|
+
const formEl = e.target as HTMLFormElement;
|
|
95
|
+
const endpoint = formEl.dataset.endpoint;
|
|
96
|
+
const siteKey = formEl.dataset.siteKey;
|
|
97
|
+
const customSuccessMessage = formEl.dataset.successMessage;
|
|
98
|
+
const messageEl = formEl.querySelector(".seriph-subscribe-message") as HTMLElement | null;
|
|
99
|
+
const submitBtn = formEl.querySelector('button[type="submit"]') as HTMLButtonElement | null;
|
|
100
|
+
const inputEl = formEl.querySelector('input[name="email"]') as HTMLInputElement | null;
|
|
101
|
+
|
|
102
|
+
if (!endpoint || !siteKey) {
|
|
103
|
+
console.error("Seriph Subscribe: missing siteKey or endpoint");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Collect form data
|
|
108
|
+
const formData = new FormData(formEl);
|
|
109
|
+
const data: Record<string, unknown> = {};
|
|
110
|
+
formData.forEach((value, key) => {
|
|
111
|
+
data[key] = value;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Dispatch loading event
|
|
115
|
+
formEl.dispatchEvent(new CustomEvent("seriph:loading"));
|
|
116
|
+
|
|
117
|
+
// Disable submit button and show loading state
|
|
118
|
+
if (submitBtn) {
|
|
119
|
+
submitBtn.disabled = true;
|
|
120
|
+
submitBtn.dataset.originalText = submitBtn.textContent || "";
|
|
121
|
+
submitBtn.textContent = "...";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch(`${endpoint}/subscribe`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: {
|
|
128
|
+
"Content-Type": "application/json",
|
|
129
|
+
"X-Seriph-Key": siteKey,
|
|
130
|
+
},
|
|
131
|
+
body: JSON.stringify(data),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
const errorText = await response.text();
|
|
136
|
+
throw new Error(errorText || "Subscription failed");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const result: SubscribeResponse = await response.json();
|
|
140
|
+
|
|
141
|
+
// Show success message
|
|
142
|
+
if (messageEl) {
|
|
143
|
+
messageEl.textContent = customSuccessMessage || result.message;
|
|
144
|
+
messageEl.style.display = "block";
|
|
145
|
+
messageEl.className = "seriph-subscribe-message seriph-subscribe-success";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Dispatch success event with response data
|
|
149
|
+
formEl.dispatchEvent(
|
|
150
|
+
new CustomEvent("seriph:success", {
|
|
151
|
+
detail: result,
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Clear input on success
|
|
156
|
+
if (inputEl) {
|
|
157
|
+
inputEl.value = "";
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// Show error message
|
|
161
|
+
if (messageEl) {
|
|
162
|
+
messageEl.textContent = error instanceof Error ? error.message : "Something went wrong";
|
|
163
|
+
messageEl.style.display = "block";
|
|
164
|
+
messageEl.className = "seriph-subscribe-message seriph-subscribe-error";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
formEl.dispatchEvent(
|
|
168
|
+
new CustomEvent("seriph:error", {
|
|
169
|
+
detail: error,
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
} finally {
|
|
173
|
+
// Re-enable submit button
|
|
174
|
+
if (submitBtn) {
|
|
175
|
+
submitBtn.disabled = false;
|
|
176
|
+
submitBtn.textContent = submitBtn.dataset.originalText || "Subscribe";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
</script>
|
|
182
|
+
|
|
183
|
+
<style is:global>
|
|
184
|
+
@layer seriph {
|
|
185
|
+
.seriph-subscribe {
|
|
186
|
+
--seriph-border-color: #d1d5db;
|
|
187
|
+
--seriph-input-bg: #ffffff;
|
|
188
|
+
--seriph-input-text: #111827;
|
|
189
|
+
--seriph-button-bg: #4f46e5;
|
|
190
|
+
--seriph-button-text: #ffffff;
|
|
191
|
+
--seriph-button-hover: #4338ca;
|
|
192
|
+
--seriph-success-bg: #dcfce7;
|
|
193
|
+
--seriph-success-text: #166534;
|
|
194
|
+
--seriph-success-border: #bbf7d0;
|
|
195
|
+
--seriph-error-bg: #fef2f2;
|
|
196
|
+
--seriph-error-text: #991b1b;
|
|
197
|
+
--seriph-error-border: #fecaca;
|
|
198
|
+
--seriph-focus-ring: rgba(79, 70, 229, 0.5);
|
|
199
|
+
|
|
200
|
+
position: relative;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* Dark theme */
|
|
204
|
+
.seriph-subscribe.seriph-theme-dark {
|
|
205
|
+
--seriph-border-color: #4b5563;
|
|
206
|
+
--seriph-input-bg: #1f2937;
|
|
207
|
+
--seriph-input-text: #f9fafb;
|
|
208
|
+
--seriph-button-bg: #6366f1;
|
|
209
|
+
--seriph-button-hover: #818cf8;
|
|
210
|
+
--seriph-success-bg: rgba(34, 197, 94, 0.15);
|
|
211
|
+
--seriph-success-text: #86efac;
|
|
212
|
+
--seriph-success-border: rgba(34, 197, 94, 0.3);
|
|
213
|
+
--seriph-error-bg: rgba(239, 68, 68, 0.15);
|
|
214
|
+
--seriph-error-text: #fca5a5;
|
|
215
|
+
--seriph-error-border: rgba(239, 68, 68, 0.3);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* Auto theme - follows prefers-color-scheme */
|
|
219
|
+
@media (prefers-color-scheme: dark) {
|
|
220
|
+
.seriph-subscribe.seriph-theme-auto {
|
|
221
|
+
--seriph-border-color: #4b5563;
|
|
222
|
+
--seriph-input-bg: #1f2937;
|
|
223
|
+
--seriph-input-text: #f9fafb;
|
|
224
|
+
--seriph-button-bg: #6366f1;
|
|
225
|
+
--seriph-button-hover: #818cf8;
|
|
226
|
+
--seriph-success-bg: rgba(34, 197, 94, 0.15);
|
|
227
|
+
--seriph-success-text: #86efac;
|
|
228
|
+
--seriph-success-border: rgba(34, 197, 94, 0.3);
|
|
229
|
+
--seriph-error-bg: rgba(239, 68, 68, 0.15);
|
|
230
|
+
--seriph-error-text: #fca5a5;
|
|
231
|
+
--seriph-error-border: rgba(239, 68, 68, 0.3);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.seriph-subscribe-input-group {
|
|
236
|
+
display: flex;
|
|
237
|
+
gap: 0.5rem;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.seriph-subscribe-input {
|
|
241
|
+
flex: 1;
|
|
242
|
+
padding: 0.625rem 0.875rem;
|
|
243
|
+
border: 1px solid var(--seriph-border-color);
|
|
244
|
+
border-radius: 0.375rem;
|
|
245
|
+
background-color: var(--seriph-input-bg);
|
|
246
|
+
color: var(--seriph-input-text);
|
|
247
|
+
font-size: 0.875rem;
|
|
248
|
+
line-height: 1.25rem;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.seriph-subscribe-input:focus {
|
|
252
|
+
outline: none;
|
|
253
|
+
border-color: var(--seriph-button-bg);
|
|
254
|
+
box-shadow: 0 0 0 3px var(--seriph-focus-ring);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.seriph-subscribe-input::placeholder {
|
|
258
|
+
color: #9ca3af;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.seriph-subscribe-button {
|
|
262
|
+
padding: 0.625rem 1rem;
|
|
263
|
+
border: none;
|
|
264
|
+
border-radius: 0.375rem;
|
|
265
|
+
background-color: var(--seriph-button-bg);
|
|
266
|
+
color: var(--seriph-button-text);
|
|
267
|
+
font-size: 0.875rem;
|
|
268
|
+
font-weight: 500;
|
|
269
|
+
cursor: pointer;
|
|
270
|
+
transition: background-color 0.15s ease;
|
|
271
|
+
white-space: nowrap;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.seriph-subscribe-button:hover:not(:disabled) {
|
|
275
|
+
background-color: var(--seriph-button-hover);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.seriph-subscribe-button:disabled {
|
|
279
|
+
opacity: 0.6;
|
|
280
|
+
cursor: not-allowed;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.seriph-subscribe-message {
|
|
284
|
+
margin-top: 0.75rem;
|
|
285
|
+
padding: 0.625rem 0.875rem;
|
|
286
|
+
border-radius: 0.375rem;
|
|
287
|
+
font-size: 0.875rem;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.seriph-subscribe-success {
|
|
291
|
+
background-color: var(--seriph-success-bg);
|
|
292
|
+
color: var(--seriph-success-text);
|
|
293
|
+
border: 1px solid var(--seriph-success-border);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.seriph-subscribe-error {
|
|
297
|
+
background-color: var(--seriph-error-bg);
|
|
298
|
+
color: var(--seriph-error-text);
|
|
299
|
+
border: 1px solid var(--seriph-error-border);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
</style>
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Seriph Headless Subscribe Form
|
|
4
|
+
*
|
|
5
|
+
* A headless subscription form that handles API/state but lets you provide your own UI.
|
|
6
|
+
* Use the default slot to provide your own email input and submit button.
|
|
7
|
+
*
|
|
8
|
+
* Required: An input with name="email" and a submit button.
|
|
9
|
+
*
|
|
10
|
+
* @example Basic usage
|
|
11
|
+
* <SubscribeForm siteKey={import.meta.env.SERIPH_SITE_KEY}>
|
|
12
|
+
* <input type="email" name="email" placeholder="you@example.com" required />
|
|
13
|
+
* <button type="submit">Subscribe</button>
|
|
14
|
+
* </SubscribeForm>
|
|
15
|
+
*
|
|
16
|
+
* @example With custom message handling
|
|
17
|
+
* <SubscribeForm siteKey={import.meta.env.SERIPH_SITE_KEY} id="my-form">
|
|
18
|
+
* <input type="email" name="email" class="my-input" required />
|
|
19
|
+
* <button type="submit">Get updates</button>
|
|
20
|
+
* <p class="message"></p>
|
|
21
|
+
* </SubscribeForm>
|
|
22
|
+
* <script>
|
|
23
|
+
* document.getElementById('my-form')?.addEventListener('seriph:success', (e) => {
|
|
24
|
+
* e.target.querySelector('.message').textContent = e.detail.message;
|
|
25
|
+
* });
|
|
26
|
+
* </script>
|
|
27
|
+
*
|
|
28
|
+
* Events:
|
|
29
|
+
* - seriph:loading - Fired when submission starts
|
|
30
|
+
* - seriph:success - Fired on success, detail contains { success, message }
|
|
31
|
+
* - seriph:error - Fired on error, detail contains the Error object
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
interface Props {
|
|
35
|
+
/** Your site key (required) */
|
|
36
|
+
siteKey: string;
|
|
37
|
+
/** Base URL of your Seriph instance (default: 'https://seriph.xyz') */
|
|
38
|
+
endpoint?: string;
|
|
39
|
+
/** HTML id attribute for the form */
|
|
40
|
+
id?: string;
|
|
41
|
+
/** CSS class for the form */
|
|
42
|
+
class?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_ENDPOINT = "https://seriph.xyz";
|
|
46
|
+
const API_PATH = "/api/v1";
|
|
47
|
+
|
|
48
|
+
const {
|
|
49
|
+
siteKey,
|
|
50
|
+
endpoint = DEFAULT_ENDPOINT,
|
|
51
|
+
id,
|
|
52
|
+
class: className = "",
|
|
53
|
+
} = Astro.props;
|
|
54
|
+
|
|
55
|
+
const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
<form
|
|
59
|
+
id={id}
|
|
60
|
+
class:list={["seriph-subscribe-form", className]}
|
|
61
|
+
data-seriph-subscribe-form
|
|
62
|
+
data-endpoint={baseUrl}
|
|
63
|
+
data-site-key={siteKey}
|
|
64
|
+
>
|
|
65
|
+
<slot />
|
|
66
|
+
|
|
67
|
+
<!-- Honeypot field for spam protection -->
|
|
68
|
+
<div style="position: absolute; left: -9999px;" aria-hidden="true">
|
|
69
|
+
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" />
|
|
70
|
+
</div>
|
|
71
|
+
</form>
|
|
72
|
+
|
|
73
|
+
<script>
|
|
74
|
+
interface SubscribeResponse {
|
|
75
|
+
success: boolean;
|
|
76
|
+
message: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
document.querySelectorAll("[data-seriph-subscribe-form]").forEach((form) => {
|
|
80
|
+
form.addEventListener("submit", async (e) => {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
|
|
83
|
+
const formEl = e.target as HTMLFormElement;
|
|
84
|
+
const endpoint = formEl.dataset.endpoint;
|
|
85
|
+
const siteKey = formEl.dataset.siteKey;
|
|
86
|
+
|
|
87
|
+
if (!endpoint || !siteKey) {
|
|
88
|
+
console.error("Seriph SubscribeForm: missing siteKey or endpoint");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Collect form data
|
|
93
|
+
const formData = new FormData(formEl);
|
|
94
|
+
const data: Record<string, unknown> = {};
|
|
95
|
+
formData.forEach((value, key) => {
|
|
96
|
+
data[key] = value;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Dispatch loading event
|
|
100
|
+
formEl.dispatchEvent(new CustomEvent("seriph:loading", { bubbles: true }));
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const response = await fetch(`${endpoint}/subscribe`, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: {
|
|
106
|
+
"Content-Type": "application/json",
|
|
107
|
+
"X-Seriph-Key": siteKey,
|
|
108
|
+
},
|
|
109
|
+
body: JSON.stringify(data),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
const errorText = await response.text();
|
|
114
|
+
throw new Error(errorText || "Subscription failed");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const result: SubscribeResponse = await response.json();
|
|
118
|
+
|
|
119
|
+
// Dispatch success event
|
|
120
|
+
formEl.dispatchEvent(
|
|
121
|
+
new CustomEvent("seriph:success", {
|
|
122
|
+
bubbles: true,
|
|
123
|
+
detail: result,
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Clear email input on success
|
|
128
|
+
const emailInput = formEl.querySelector('input[name="email"]') as HTMLInputElement | null;
|
|
129
|
+
if (emailInput) {
|
|
130
|
+
emailInput.value = "";
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
formEl.dispatchEvent(
|
|
134
|
+
new CustomEvent("seriph:error", {
|
|
135
|
+
bubbles: true,
|
|
136
|
+
detail: error,
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
</script>
|