@volchoklv/newsletter-kit 1.0.0
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 +505 -0
- package/dist/adapters/email/index.d.ts +119 -0
- package/dist/adapters/email/index.js +417 -0
- package/dist/adapters/email/index.js.map +1 -0
- package/dist/adapters/storage/index.d.ts +215 -0
- package/dist/adapters/storage/index.js +415 -0
- package/dist/adapters/storage/index.js.map +1 -0
- package/dist/components/index.d.ts +198 -0
- package/dist/components/index.js +505 -0
- package/dist/components/index.js.map +1 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.js +1762 -0
- package/dist/index.js.map +1 -0
- package/dist/server/index.d.ts +77 -0
- package/dist/server/index.js +530 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types-BmajlhNp.d.ts +226 -0
- package/package.json +95 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1762 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/utils/crypto.ts
|
|
4
|
+
import { randomBytes } from "crypto";
|
|
5
|
+
function generateToken(length = 32) {
|
|
6
|
+
return randomBytes(length).toString("hex");
|
|
7
|
+
}
|
|
8
|
+
function generateUrlSafeToken(length = 32) {
|
|
9
|
+
return randomBytes(length).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// src/utils/validation.ts
|
|
13
|
+
function isValidEmail(email) {
|
|
14
|
+
if (!email || typeof email !== "string") return false;
|
|
15
|
+
const trimmed = email.trim();
|
|
16
|
+
if (trimmed.length === 0 || trimmed.length > 254) return false;
|
|
17
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
18
|
+
if (!emailRegex.test(trimmed)) return false;
|
|
19
|
+
const [localPart, domain] = trimmed.split("@");
|
|
20
|
+
if (!localPart || localPart.length > 64) return false;
|
|
21
|
+
if (localPart.startsWith(".") || localPart.endsWith(".")) return false;
|
|
22
|
+
if (localPart.includes("..")) return false;
|
|
23
|
+
if (!domain || domain.length > 253) return false;
|
|
24
|
+
if (domain.startsWith(".") || domain.endsWith(".")) return false;
|
|
25
|
+
if (domain.startsWith("-") || domain.endsWith("-")) return false;
|
|
26
|
+
if (!domain.includes(".")) return false;
|
|
27
|
+
const tld = domain.split(".").pop();
|
|
28
|
+
if (!tld || tld.length < 2) return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
function normalizeEmail(email, options = {}) {
|
|
32
|
+
let normalized = email.toLowerCase().trim();
|
|
33
|
+
if (options.normalizeGmail) {
|
|
34
|
+
const [localPart, domain] = normalized.split("@");
|
|
35
|
+
if (domain === "gmail.com" || domain === "googlemail.com") {
|
|
36
|
+
const cleanLocal = localPart.split("+")[0].replace(/\./g, "");
|
|
37
|
+
normalized = `${cleanLocal}@gmail.com`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return normalized;
|
|
41
|
+
}
|
|
42
|
+
var DISPOSABLE_DOMAINS = /* @__PURE__ */ new Set([
|
|
43
|
+
"tempmail.com",
|
|
44
|
+
"throwaway.email",
|
|
45
|
+
"guerrillamail.com",
|
|
46
|
+
"sharklasers.com",
|
|
47
|
+
"mailinator.com",
|
|
48
|
+
"yopmail.com",
|
|
49
|
+
"10minutemail.com",
|
|
50
|
+
"temp-mail.org",
|
|
51
|
+
"fakeinbox.com",
|
|
52
|
+
"trashmail.com",
|
|
53
|
+
"getnada.com",
|
|
54
|
+
"maildrop.cc",
|
|
55
|
+
"dispostable.com",
|
|
56
|
+
"tempail.com",
|
|
57
|
+
"mohmal.com"
|
|
58
|
+
]);
|
|
59
|
+
function isDisposableEmail(email) {
|
|
60
|
+
const domain = email.toLowerCase().split("@")[1];
|
|
61
|
+
return DISPOSABLE_DOMAINS.has(domain);
|
|
62
|
+
}
|
|
63
|
+
function sanitizeString(input) {
|
|
64
|
+
return input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/utils/rate-limit.ts
|
|
68
|
+
function createRateLimiter(config) {
|
|
69
|
+
const { max, windowSeconds, identifier } = config;
|
|
70
|
+
const store = /* @__PURE__ */ new Map();
|
|
71
|
+
setInterval(() => {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
for (const [key, entry] of store.entries()) {
|
|
74
|
+
if (entry.resetAt < now) {
|
|
75
|
+
store.delete(key);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}, windowSeconds * 1e3);
|
|
79
|
+
return {
|
|
80
|
+
/**
|
|
81
|
+
* Check if request should be rate limited
|
|
82
|
+
* Returns true if request is allowed, false if rate limited
|
|
83
|
+
*/
|
|
84
|
+
async check(req) {
|
|
85
|
+
const id = identifier ? identifier(req) : getClientIP(req);
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const windowMs = windowSeconds * 1e3;
|
|
88
|
+
let entry = store.get(id);
|
|
89
|
+
if (!entry || entry.resetAt < now) {
|
|
90
|
+
entry = {
|
|
91
|
+
count: 0,
|
|
92
|
+
resetAt: now + windowMs
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
entry.count++;
|
|
96
|
+
store.set(id, entry);
|
|
97
|
+
const allowed = entry.count <= max;
|
|
98
|
+
const remaining = Math.max(0, max - entry.count);
|
|
99
|
+
return {
|
|
100
|
+
allowed,
|
|
101
|
+
remaining,
|
|
102
|
+
resetAt: new Date(entry.resetAt)
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
/**
|
|
106
|
+
* Get current limit status without incrementing
|
|
107
|
+
*/
|
|
108
|
+
async status(req) {
|
|
109
|
+
const id = identifier ? identifier(req) : getClientIP(req);
|
|
110
|
+
const entry = store.get(id);
|
|
111
|
+
if (!entry || entry.resetAt < Date.now()) {
|
|
112
|
+
return { remaining: max, resetAt: null };
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
remaining: Math.max(0, max - entry.count),
|
|
116
|
+
resetAt: new Date(entry.resetAt)
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
/**
|
|
120
|
+
* Reset rate limit for an identifier
|
|
121
|
+
*/
|
|
122
|
+
async reset(req) {
|
|
123
|
+
const id = identifier ? identifier(req) : getClientIP(req);
|
|
124
|
+
store.delete(id);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function getClientIP(req) {
|
|
129
|
+
const headers = req.headers;
|
|
130
|
+
const cfConnectingIP = headers.get("cf-connecting-ip");
|
|
131
|
+
if (cfConnectingIP) return cfConnectingIP;
|
|
132
|
+
const xForwardedFor = headers.get("x-forwarded-for");
|
|
133
|
+
if (xForwardedFor) {
|
|
134
|
+
return xForwardedFor.split(",")[0].trim();
|
|
135
|
+
}
|
|
136
|
+
const xRealIP = headers.get("x-real-ip");
|
|
137
|
+
if (xRealIP) return xRealIP;
|
|
138
|
+
const xVercelForwardedFor = headers.get("x-vercel-forwarded-for");
|
|
139
|
+
if (xVercelForwardedFor) return xVercelForwardedFor.split(",")[0].trim();
|
|
140
|
+
return "unknown";
|
|
141
|
+
}
|
|
142
|
+
function createRateLimitHeaders(limit, remaining, resetAt) {
|
|
143
|
+
const headers = new Headers();
|
|
144
|
+
headers.set("X-RateLimit-Limit", limit.toString());
|
|
145
|
+
headers.set("X-RateLimit-Remaining", remaining.toString());
|
|
146
|
+
headers.set("X-RateLimit-Reset", Math.floor(resetAt.getTime() / 1e3).toString());
|
|
147
|
+
return headers;
|
|
148
|
+
}
|
|
149
|
+
var defaultRateLimitConfig = {
|
|
150
|
+
max: 5,
|
|
151
|
+
windowSeconds: 60
|
|
152
|
+
// 5 requests per minute
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// src/adapters/storage/memory.ts
|
|
156
|
+
function createMemoryAdapter() {
|
|
157
|
+
const subscribers = /* @__PURE__ */ new Map();
|
|
158
|
+
const tokenIndex = /* @__PURE__ */ new Map();
|
|
159
|
+
return {
|
|
160
|
+
async createSubscriber(input, token) {
|
|
161
|
+
const email = input.email.toLowerCase();
|
|
162
|
+
const confirmToken = token || generateToken();
|
|
163
|
+
const now = /* @__PURE__ */ new Date();
|
|
164
|
+
const existing = subscribers.get(email);
|
|
165
|
+
if (existing?.id) {
|
|
166
|
+
for (const [t, e] of tokenIndex.entries()) {
|
|
167
|
+
if (e === email) {
|
|
168
|
+
tokenIndex.delete(t);
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const subscriber = {
|
|
174
|
+
id: existing?.id || crypto.randomUUID(),
|
|
175
|
+
email,
|
|
176
|
+
status: "pending",
|
|
177
|
+
source: input.source,
|
|
178
|
+
tags: input.tags || [],
|
|
179
|
+
metadata: input.metadata,
|
|
180
|
+
consentIp: input.ip,
|
|
181
|
+
consentAt: now,
|
|
182
|
+
confirmedAt: void 0,
|
|
183
|
+
unsubscribedAt: void 0,
|
|
184
|
+
createdAt: existing?.createdAt || now,
|
|
185
|
+
updatedAt: now
|
|
186
|
+
};
|
|
187
|
+
subscribers.set(email, subscriber);
|
|
188
|
+
tokenIndex.set(confirmToken, email);
|
|
189
|
+
return { ...subscriber, id: confirmToken };
|
|
190
|
+
},
|
|
191
|
+
async getSubscriberByEmail(email) {
|
|
192
|
+
return subscribers.get(email.toLowerCase()) || null;
|
|
193
|
+
},
|
|
194
|
+
async getSubscriberByToken(token) {
|
|
195
|
+
const email = tokenIndex.get(token);
|
|
196
|
+
if (!email) return null;
|
|
197
|
+
return subscribers.get(email) || null;
|
|
198
|
+
},
|
|
199
|
+
async confirmSubscriber(token) {
|
|
200
|
+
const email = tokenIndex.get(token);
|
|
201
|
+
if (!email) return null;
|
|
202
|
+
const subscriber = subscribers.get(email);
|
|
203
|
+
if (!subscriber) return null;
|
|
204
|
+
const updated = {
|
|
205
|
+
...subscriber,
|
|
206
|
+
status: "confirmed",
|
|
207
|
+
confirmedAt: /* @__PURE__ */ new Date(),
|
|
208
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
209
|
+
};
|
|
210
|
+
subscribers.set(email, updated);
|
|
211
|
+
tokenIndex.delete(token);
|
|
212
|
+
return updated;
|
|
213
|
+
},
|
|
214
|
+
async unsubscribe(email) {
|
|
215
|
+
const subscriber = subscribers.get(email.toLowerCase());
|
|
216
|
+
if (!subscriber) return false;
|
|
217
|
+
const updated = {
|
|
218
|
+
...subscriber,
|
|
219
|
+
status: "unsubscribed",
|
|
220
|
+
unsubscribedAt: /* @__PURE__ */ new Date(),
|
|
221
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
222
|
+
};
|
|
223
|
+
subscribers.set(email.toLowerCase(), updated);
|
|
224
|
+
return true;
|
|
225
|
+
},
|
|
226
|
+
async listSubscribers(options) {
|
|
227
|
+
let results = Array.from(subscribers.values());
|
|
228
|
+
if (options?.status) {
|
|
229
|
+
results = results.filter((s) => s.status === options.status);
|
|
230
|
+
}
|
|
231
|
+
if (options?.source) {
|
|
232
|
+
results = results.filter((s) => s.source === options.source);
|
|
233
|
+
}
|
|
234
|
+
if (options?.tags?.length) {
|
|
235
|
+
results = results.filter(
|
|
236
|
+
(s) => options.tags.some((tag) => s.tags?.includes(tag))
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
240
|
+
const total = results.length;
|
|
241
|
+
const offset = options?.offset || 0;
|
|
242
|
+
const limit = options?.limit || 100;
|
|
243
|
+
return {
|
|
244
|
+
subscribers: results.slice(offset, offset + limit),
|
|
245
|
+
total
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
async deleteSubscriber(email) {
|
|
249
|
+
const normalizedEmail = email.toLowerCase();
|
|
250
|
+
const existed = subscribers.has(normalizedEmail);
|
|
251
|
+
subscribers.delete(normalizedEmail);
|
|
252
|
+
for (const [token, e] of tokenIndex.entries()) {
|
|
253
|
+
if (e === normalizedEmail) {
|
|
254
|
+
tokenIndex.delete(token);
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return existed;
|
|
259
|
+
},
|
|
260
|
+
async updateSubscriber(email, data) {
|
|
261
|
+
const normalizedEmail = email.toLowerCase();
|
|
262
|
+
const subscriber = subscribers.get(normalizedEmail);
|
|
263
|
+
if (!subscriber) return null;
|
|
264
|
+
const updated = {
|
|
265
|
+
...subscriber,
|
|
266
|
+
...data.source !== void 0 && { source: data.source },
|
|
267
|
+
...data.tags !== void 0 && { tags: data.tags },
|
|
268
|
+
...data.metadata !== void 0 && { metadata: data.metadata },
|
|
269
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
270
|
+
};
|
|
271
|
+
subscribers.set(normalizedEmail, updated);
|
|
272
|
+
return updated;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function createNoopAdapter() {
|
|
277
|
+
return {
|
|
278
|
+
async createSubscriber(input) {
|
|
279
|
+
return {
|
|
280
|
+
id: "noop",
|
|
281
|
+
email: input.email,
|
|
282
|
+
status: "pending",
|
|
283
|
+
source: input.source,
|
|
284
|
+
tags: input.tags,
|
|
285
|
+
metadata: input.metadata,
|
|
286
|
+
consentIp: input.ip,
|
|
287
|
+
consentAt: /* @__PURE__ */ new Date(),
|
|
288
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
289
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
290
|
+
};
|
|
291
|
+
},
|
|
292
|
+
async getSubscriberByEmail() {
|
|
293
|
+
return null;
|
|
294
|
+
},
|
|
295
|
+
async getSubscriberByToken() {
|
|
296
|
+
return null;
|
|
297
|
+
},
|
|
298
|
+
async confirmSubscriber() {
|
|
299
|
+
return null;
|
|
300
|
+
},
|
|
301
|
+
async unsubscribe() {
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/server/handlers.ts
|
|
308
|
+
function createNewsletterHandlers(config) {
|
|
309
|
+
const {
|
|
310
|
+
emailAdapter,
|
|
311
|
+
storageAdapter = createMemoryAdapter(),
|
|
312
|
+
doubleOptIn = true,
|
|
313
|
+
baseUrl,
|
|
314
|
+
confirmPath = "/api/newsletter/confirm",
|
|
315
|
+
unsubscribePath = "/api/newsletter/unsubscribe",
|
|
316
|
+
honeypotField = "website",
|
|
317
|
+
rateLimit = defaultRateLimitConfig,
|
|
318
|
+
validateEmail: customValidateEmail,
|
|
319
|
+
allowedSources,
|
|
320
|
+
defaultTags = [],
|
|
321
|
+
onSubscribe,
|
|
322
|
+
onConfirm,
|
|
323
|
+
onUnsubscribe,
|
|
324
|
+
onError
|
|
325
|
+
} = config;
|
|
326
|
+
const rateLimiter = rateLimit ? createRateLimiter(rateLimit) : null;
|
|
327
|
+
function jsonResponse(data, status = 200, headers) {
|
|
328
|
+
const responseHeaders = new Headers(headers);
|
|
329
|
+
responseHeaders.set("Content-Type", "application/json");
|
|
330
|
+
return new Response(JSON.stringify(data), {
|
|
331
|
+
status,
|
|
332
|
+
headers: responseHeaders
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
async function handleSubscribe(req) {
|
|
336
|
+
const body = await req.json();
|
|
337
|
+
const { email, source, tags = [], metadata } = body;
|
|
338
|
+
if (honeypotField && body[honeypotField]) {
|
|
339
|
+
return {
|
|
340
|
+
success: true,
|
|
341
|
+
message: doubleOptIn ? "Please check your email to confirm your subscription." : "You have been subscribed!"
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (!email || !isValidEmail(email)) {
|
|
345
|
+
return {
|
|
346
|
+
success: false,
|
|
347
|
+
message: "Please enter a valid email address."
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
if (customValidateEmail) {
|
|
351
|
+
const isValid = await customValidateEmail(email);
|
|
352
|
+
if (!isValid) {
|
|
353
|
+
return {
|
|
354
|
+
success: false,
|
|
355
|
+
message: "This email address is not allowed."
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (source && allowedSources && !allowedSources.includes(source)) {
|
|
360
|
+
return {
|
|
361
|
+
success: false,
|
|
362
|
+
message: "Invalid subscription source."
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
const normalizedEmail = normalizeEmail(email);
|
|
366
|
+
const ip = getClientIP(req);
|
|
367
|
+
try {
|
|
368
|
+
const existing = await storageAdapter.getSubscriberByEmail(normalizedEmail);
|
|
369
|
+
if (existing?.status === "confirmed") {
|
|
370
|
+
return {
|
|
371
|
+
success: true,
|
|
372
|
+
message: "You are already subscribed!",
|
|
373
|
+
subscriber: existing
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
const token = generateToken();
|
|
377
|
+
const subscriber = await storageAdapter.createSubscriber(
|
|
378
|
+
{
|
|
379
|
+
email: normalizedEmail,
|
|
380
|
+
source,
|
|
381
|
+
tags: [...defaultTags, ...tags],
|
|
382
|
+
metadata,
|
|
383
|
+
ip
|
|
384
|
+
},
|
|
385
|
+
token
|
|
386
|
+
);
|
|
387
|
+
if (doubleOptIn) {
|
|
388
|
+
const confirmUrl = `${baseUrl}${confirmPath}?token=${token}`;
|
|
389
|
+
await emailAdapter.sendConfirmation(normalizedEmail, token, confirmUrl);
|
|
390
|
+
if (emailAdapter.notifyAdmin) {
|
|
391
|
+
await emailAdapter.notifyAdmin(subscriber);
|
|
392
|
+
}
|
|
393
|
+
if (onSubscribe) {
|
|
394
|
+
await onSubscribe(subscriber);
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
success: true,
|
|
398
|
+
message: "Please check your email to confirm your subscription.",
|
|
399
|
+
subscriber,
|
|
400
|
+
requiresConfirmation: true
|
|
401
|
+
};
|
|
402
|
+
} else {
|
|
403
|
+
const confirmed = await storageAdapter.confirmSubscriber(token);
|
|
404
|
+
await emailAdapter.sendWelcome(normalizedEmail);
|
|
405
|
+
if (emailAdapter.notifyAdmin && confirmed) {
|
|
406
|
+
await emailAdapter.notifyAdmin(confirmed);
|
|
407
|
+
}
|
|
408
|
+
if (onSubscribe && confirmed) {
|
|
409
|
+
await onSubscribe(confirmed);
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
success: true,
|
|
413
|
+
message: "You have been subscribed!",
|
|
414
|
+
subscriber: confirmed || subscriber,
|
|
415
|
+
requiresConfirmation: false
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (onError) {
|
|
420
|
+
await onError(error, "subscribe");
|
|
421
|
+
}
|
|
422
|
+
console.error("[newsletter-kit] Subscribe error:", error);
|
|
423
|
+
return {
|
|
424
|
+
success: false,
|
|
425
|
+
message: "Something went wrong. Please try again."
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
async function handleConfirm(token) {
|
|
430
|
+
if (!token) {
|
|
431
|
+
return {
|
|
432
|
+
success: false,
|
|
433
|
+
message: "Invalid confirmation link."
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
const subscriber = await storageAdapter.confirmSubscriber(token);
|
|
438
|
+
if (!subscriber) {
|
|
439
|
+
return {
|
|
440
|
+
success: false,
|
|
441
|
+
message: "This confirmation link is invalid or has expired."
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
await emailAdapter.sendWelcome(subscriber.email);
|
|
445
|
+
if (onConfirm) {
|
|
446
|
+
await onConfirm(subscriber);
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
success: true,
|
|
450
|
+
message: "Your subscription has been confirmed!",
|
|
451
|
+
subscriber
|
|
452
|
+
};
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (onError) {
|
|
455
|
+
await onError(error, "confirm");
|
|
456
|
+
}
|
|
457
|
+
console.error("[newsletter-kit] Confirm error:", error);
|
|
458
|
+
return {
|
|
459
|
+
success: false,
|
|
460
|
+
message: "Something went wrong. Please try again."
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
async function handleUnsubscribe(email) {
|
|
465
|
+
if (!email || !isValidEmail(email)) {
|
|
466
|
+
return {
|
|
467
|
+
success: false,
|
|
468
|
+
message: "Invalid email address."
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
const normalizedEmail = normalizeEmail(email);
|
|
472
|
+
try {
|
|
473
|
+
const success = await storageAdapter.unsubscribe(normalizedEmail);
|
|
474
|
+
if (!success) {
|
|
475
|
+
return {
|
|
476
|
+
success: false,
|
|
477
|
+
message: "Email not found in our list."
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
if (emailAdapter.sendUnsubscribed) {
|
|
481
|
+
const resubscribeUrl = `${baseUrl}`;
|
|
482
|
+
await emailAdapter.sendUnsubscribed(normalizedEmail, resubscribeUrl);
|
|
483
|
+
}
|
|
484
|
+
if (onUnsubscribe) {
|
|
485
|
+
await onUnsubscribe(normalizedEmail);
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
success: true,
|
|
489
|
+
message: "You have been unsubscribed."
|
|
490
|
+
};
|
|
491
|
+
} catch (error) {
|
|
492
|
+
if (onError) {
|
|
493
|
+
await onError(error, "unsubscribe");
|
|
494
|
+
}
|
|
495
|
+
console.error("[newsletter-kit] Unsubscribe error:", error);
|
|
496
|
+
return {
|
|
497
|
+
success: false,
|
|
498
|
+
message: "Something went wrong. Please try again."
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
/**
|
|
504
|
+
* POST /api/newsletter/subscribe
|
|
505
|
+
*/
|
|
506
|
+
subscribe: async (req) => {
|
|
507
|
+
if (rateLimiter) {
|
|
508
|
+
const { allowed, remaining, resetAt } = await rateLimiter.check(req);
|
|
509
|
+
const headers = createRateLimitHeaders(rateLimit.max, remaining, resetAt);
|
|
510
|
+
if (!allowed) {
|
|
511
|
+
return jsonResponse(
|
|
512
|
+
{
|
|
513
|
+
success: false,
|
|
514
|
+
message: "Too many requests. Please try again later."
|
|
515
|
+
},
|
|
516
|
+
429,
|
|
517
|
+
headers
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const result = await handleSubscribe(req);
|
|
522
|
+
return jsonResponse(result, result.success ? 200 : 400);
|
|
523
|
+
},
|
|
524
|
+
/**
|
|
525
|
+
* GET /api/newsletter/confirm?token=xxx
|
|
526
|
+
*
|
|
527
|
+
* Can also return a redirect for better UX
|
|
528
|
+
*/
|
|
529
|
+
confirm: async (req) => {
|
|
530
|
+
const url = new URL(req.url);
|
|
531
|
+
const token = url.searchParams.get("token");
|
|
532
|
+
const result = await handleConfirm(token || "");
|
|
533
|
+
const acceptsJson = req.headers.get("accept")?.includes("application/json");
|
|
534
|
+
if (acceptsJson) {
|
|
535
|
+
return jsonResponse(result, result.success ? 200 : 400);
|
|
536
|
+
}
|
|
537
|
+
const redirectUrl = new URL(baseUrl);
|
|
538
|
+
redirectUrl.searchParams.set("confirmed", result.success ? "true" : "false");
|
|
539
|
+
if (!result.success) {
|
|
540
|
+
redirectUrl.searchParams.set("error", result.message);
|
|
541
|
+
}
|
|
542
|
+
return Response.redirect(redirectUrl.toString(), 302);
|
|
543
|
+
},
|
|
544
|
+
/**
|
|
545
|
+
* POST /api/newsletter/unsubscribe
|
|
546
|
+
* Also supports GET with email query param
|
|
547
|
+
*/
|
|
548
|
+
unsubscribe: async (req) => {
|
|
549
|
+
let email;
|
|
550
|
+
if (req.method === "GET") {
|
|
551
|
+
const url = new URL(req.url);
|
|
552
|
+
email = url.searchParams.get("email") || "";
|
|
553
|
+
} else {
|
|
554
|
+
const body = await req.json();
|
|
555
|
+
email = body.email;
|
|
556
|
+
}
|
|
557
|
+
const result = await handleUnsubscribe(email);
|
|
558
|
+
return jsonResponse(result, result.success ? 200 : 400);
|
|
559
|
+
},
|
|
560
|
+
/**
|
|
561
|
+
* Internal handlers for custom implementations
|
|
562
|
+
*/
|
|
563
|
+
handlers: {
|
|
564
|
+
subscribe: handleSubscribe,
|
|
565
|
+
confirm: handleConfirm,
|
|
566
|
+
unsubscribe: handleUnsubscribe
|
|
567
|
+
},
|
|
568
|
+
/**
|
|
569
|
+
* Access to storage adapter for admin features
|
|
570
|
+
*/
|
|
571
|
+
storage: storageAdapter,
|
|
572
|
+
/**
|
|
573
|
+
* Get subscriber by email (for admin/API use)
|
|
574
|
+
*/
|
|
575
|
+
getSubscriber: async (email) => {
|
|
576
|
+
return storageAdapter.getSubscriberByEmail(normalizeEmail(email));
|
|
577
|
+
},
|
|
578
|
+
/**
|
|
579
|
+
* List subscribers (for admin/export use)
|
|
580
|
+
*/
|
|
581
|
+
listSubscribers: storageAdapter.listSubscribers
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// src/components/newsletter-form.tsx
|
|
586
|
+
import { useState, useCallback } from "react";
|
|
587
|
+
|
|
588
|
+
// src/utils/cn.ts
|
|
589
|
+
function cn(...inputs) {
|
|
590
|
+
return inputs.filter(Boolean).join(" ");
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// src/components/newsletter-form.tsx
|
|
594
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
595
|
+
function NewsletterForm({
|
|
596
|
+
endpoint = "/api/newsletter/subscribe",
|
|
597
|
+
source,
|
|
598
|
+
tags,
|
|
599
|
+
honeypotField = "website",
|
|
600
|
+
className,
|
|
601
|
+
formClassName,
|
|
602
|
+
inputClassName,
|
|
603
|
+
buttonClassName,
|
|
604
|
+
messageClassName,
|
|
605
|
+
placeholder = "Enter your email",
|
|
606
|
+
buttonText = "Subscribe",
|
|
607
|
+
loadingText = "Subscribing...",
|
|
608
|
+
successMessage,
|
|
609
|
+
errorMessage,
|
|
610
|
+
showMessage = true,
|
|
611
|
+
onSuccess,
|
|
612
|
+
onError,
|
|
613
|
+
onSubmit,
|
|
614
|
+
disabled = false,
|
|
615
|
+
metadata
|
|
616
|
+
}) {
|
|
617
|
+
const [state, setState] = useState({
|
|
618
|
+
status: "idle",
|
|
619
|
+
message: "",
|
|
620
|
+
email: ""
|
|
621
|
+
});
|
|
622
|
+
const handleSubmit = useCallback(
|
|
623
|
+
async (e) => {
|
|
624
|
+
e.preventDefault();
|
|
625
|
+
const formData = new FormData(e.currentTarget);
|
|
626
|
+
const email = formData.get("email");
|
|
627
|
+
const honeypot = formData.get(honeypotField);
|
|
628
|
+
if (honeypot) {
|
|
629
|
+
setState({
|
|
630
|
+
status: "success",
|
|
631
|
+
message: successMessage || "Thanks for subscribing!",
|
|
632
|
+
email: ""
|
|
633
|
+
});
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (!email || !email.includes("@")) {
|
|
637
|
+
setState({
|
|
638
|
+
status: "error",
|
|
639
|
+
message: "Please enter a valid email address.",
|
|
640
|
+
email
|
|
641
|
+
});
|
|
642
|
+
onError?.("Please enter a valid email address.");
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
setState({ status: "loading", message: "", email });
|
|
646
|
+
onSubmit?.(email);
|
|
647
|
+
try {
|
|
648
|
+
const response = await fetch(endpoint, {
|
|
649
|
+
method: "POST",
|
|
650
|
+
headers: { "Content-Type": "application/json" },
|
|
651
|
+
body: JSON.stringify({
|
|
652
|
+
email,
|
|
653
|
+
source,
|
|
654
|
+
tags,
|
|
655
|
+
metadata
|
|
656
|
+
})
|
|
657
|
+
});
|
|
658
|
+
const data = await response.json();
|
|
659
|
+
if (response.ok && data.success) {
|
|
660
|
+
const message = successMessage || data.message || "Thanks for subscribing!";
|
|
661
|
+
setState({
|
|
662
|
+
status: "success",
|
|
663
|
+
message,
|
|
664
|
+
email: ""
|
|
665
|
+
});
|
|
666
|
+
onSuccess?.(email, message);
|
|
667
|
+
} else {
|
|
668
|
+
const message = errorMessage || data.message || "Something went wrong. Please try again.";
|
|
669
|
+
setState({
|
|
670
|
+
status: "error",
|
|
671
|
+
message,
|
|
672
|
+
email
|
|
673
|
+
});
|
|
674
|
+
onError?.(message);
|
|
675
|
+
}
|
|
676
|
+
} catch (error) {
|
|
677
|
+
const message = errorMessage || "Network error. Please try again.";
|
|
678
|
+
setState({
|
|
679
|
+
status: "error",
|
|
680
|
+
message,
|
|
681
|
+
email
|
|
682
|
+
});
|
|
683
|
+
onError?.(message);
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
[endpoint, source, tags, metadata, honeypotField, successMessage, errorMessage, onSuccess, onError, onSubmit]
|
|
687
|
+
);
|
|
688
|
+
const isLoading = state.status === "loading";
|
|
689
|
+
const isDisabled = disabled || isLoading;
|
|
690
|
+
return /* @__PURE__ */ jsxs("div", { className: cn("w-full", className), children: [
|
|
691
|
+
/* @__PURE__ */ jsxs(
|
|
692
|
+
"form",
|
|
693
|
+
{
|
|
694
|
+
onSubmit: handleSubmit,
|
|
695
|
+
className: cn("flex flex-col sm:flex-row gap-2", formClassName),
|
|
696
|
+
children: [
|
|
697
|
+
/* @__PURE__ */ jsx(
|
|
698
|
+
"input",
|
|
699
|
+
{
|
|
700
|
+
type: "text",
|
|
701
|
+
name: honeypotField,
|
|
702
|
+
autoComplete: "off",
|
|
703
|
+
tabIndex: -1,
|
|
704
|
+
"aria-hidden": "true",
|
|
705
|
+
className: "absolute -left-[9999px] opacity-0 pointer-events-none"
|
|
706
|
+
}
|
|
707
|
+
),
|
|
708
|
+
/* @__PURE__ */ jsx(
|
|
709
|
+
"input",
|
|
710
|
+
{
|
|
711
|
+
type: "email",
|
|
712
|
+
name: "email",
|
|
713
|
+
placeholder,
|
|
714
|
+
required: true,
|
|
715
|
+
disabled: isDisabled,
|
|
716
|
+
defaultValue: state.email,
|
|
717
|
+
"aria-label": "Email address",
|
|
718
|
+
"aria-describedby": state.message ? "newsletter-message" : void 0,
|
|
719
|
+
className: cn(
|
|
720
|
+
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm",
|
|
721
|
+
"ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
|
722
|
+
"placeholder:text-muted-foreground",
|
|
723
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
724
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
725
|
+
inputClassName
|
|
726
|
+
)
|
|
727
|
+
}
|
|
728
|
+
),
|
|
729
|
+
/* @__PURE__ */ jsx(
|
|
730
|
+
"button",
|
|
731
|
+
{
|
|
732
|
+
type: "submit",
|
|
733
|
+
disabled: isDisabled,
|
|
734
|
+
className: cn(
|
|
735
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium",
|
|
736
|
+
"ring-offset-background transition-colors",
|
|
737
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
738
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
739
|
+
"bg-primary text-primary-foreground hover:bg-primary/90",
|
|
740
|
+
"h-10 px-4 py-2",
|
|
741
|
+
"min-w-[120px]",
|
|
742
|
+
buttonClassName
|
|
743
|
+
),
|
|
744
|
+
children: isLoading ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
745
|
+
/* @__PURE__ */ jsxs(
|
|
746
|
+
"svg",
|
|
747
|
+
{
|
|
748
|
+
className: "mr-2 h-4 w-4 animate-spin",
|
|
749
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
750
|
+
fill: "none",
|
|
751
|
+
viewBox: "0 0 24 24",
|
|
752
|
+
children: [
|
|
753
|
+
/* @__PURE__ */ jsx(
|
|
754
|
+
"circle",
|
|
755
|
+
{
|
|
756
|
+
className: "opacity-25",
|
|
757
|
+
cx: "12",
|
|
758
|
+
cy: "12",
|
|
759
|
+
r: "10",
|
|
760
|
+
stroke: "currentColor",
|
|
761
|
+
strokeWidth: "4"
|
|
762
|
+
}
|
|
763
|
+
),
|
|
764
|
+
/* @__PURE__ */ jsx(
|
|
765
|
+
"path",
|
|
766
|
+
{
|
|
767
|
+
className: "opacity-75",
|
|
768
|
+
fill: "currentColor",
|
|
769
|
+
d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
770
|
+
}
|
|
771
|
+
)
|
|
772
|
+
]
|
|
773
|
+
}
|
|
774
|
+
),
|
|
775
|
+
loadingText
|
|
776
|
+
] }) : buttonText
|
|
777
|
+
}
|
|
778
|
+
)
|
|
779
|
+
]
|
|
780
|
+
}
|
|
781
|
+
),
|
|
782
|
+
showMessage && state.message && /* @__PURE__ */ jsx(
|
|
783
|
+
"p",
|
|
784
|
+
{
|
|
785
|
+
id: "newsletter-message",
|
|
786
|
+
role: state.status === "error" ? "alert" : "status",
|
|
787
|
+
className: cn(
|
|
788
|
+
"mt-2 text-sm",
|
|
789
|
+
state.status === "success" && "text-green-600 dark:text-green-400",
|
|
790
|
+
state.status === "error" && "text-red-600 dark:text-red-400",
|
|
791
|
+
messageClassName
|
|
792
|
+
),
|
|
793
|
+
children: state.message
|
|
794
|
+
}
|
|
795
|
+
)
|
|
796
|
+
] });
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// src/components/newsletter-blocks.tsx
|
|
800
|
+
import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
801
|
+
function NewsletterBlock({
|
|
802
|
+
title = "Subscribe to our newsletter",
|
|
803
|
+
description = "Get the latest updates delivered directly to your inbox.",
|
|
804
|
+
containerClassName,
|
|
805
|
+
titleClassName,
|
|
806
|
+
descriptionClassName,
|
|
807
|
+
...formProps
|
|
808
|
+
}) {
|
|
809
|
+
return /* @__PURE__ */ jsx2(
|
|
810
|
+
"section",
|
|
811
|
+
{
|
|
812
|
+
className: cn(
|
|
813
|
+
"w-full py-12 md:py-16 lg:py-20",
|
|
814
|
+
"bg-muted/50",
|
|
815
|
+
containerClassName
|
|
816
|
+
),
|
|
817
|
+
children: /* @__PURE__ */ jsx2("div", { className: "container mx-auto px-4 md:px-6", children: /* @__PURE__ */ jsxs2("div", { className: "flex flex-col items-center justify-center space-y-4 text-center", children: [
|
|
818
|
+
/* @__PURE__ */ jsxs2("div", { className: "space-y-2", children: [
|
|
819
|
+
/* @__PURE__ */ jsx2(
|
|
820
|
+
"h2",
|
|
821
|
+
{
|
|
822
|
+
className: cn(
|
|
823
|
+
"text-2xl font-bold tracking-tight sm:text-3xl md:text-4xl",
|
|
824
|
+
titleClassName
|
|
825
|
+
),
|
|
826
|
+
children: title
|
|
827
|
+
}
|
|
828
|
+
),
|
|
829
|
+
/* @__PURE__ */ jsx2(
|
|
830
|
+
"p",
|
|
831
|
+
{
|
|
832
|
+
className: cn(
|
|
833
|
+
"max-w-[600px] text-muted-foreground md:text-lg",
|
|
834
|
+
descriptionClassName
|
|
835
|
+
),
|
|
836
|
+
children: description
|
|
837
|
+
}
|
|
838
|
+
)
|
|
839
|
+
] }),
|
|
840
|
+
/* @__PURE__ */ jsx2("div", { className: "w-full max-w-md", children: /* @__PURE__ */ jsx2(NewsletterForm, { ...formProps }) })
|
|
841
|
+
] }) })
|
|
842
|
+
}
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
function NewsletterCard({
|
|
846
|
+
title = "Newsletter",
|
|
847
|
+
description = "Subscribe to get updates.",
|
|
848
|
+
cardClassName,
|
|
849
|
+
titleClassName,
|
|
850
|
+
descriptionClassName,
|
|
851
|
+
...formProps
|
|
852
|
+
}) {
|
|
853
|
+
return /* @__PURE__ */ jsx2(
|
|
854
|
+
"div",
|
|
855
|
+
{
|
|
856
|
+
className: cn(
|
|
857
|
+
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
|
858
|
+
"p-6",
|
|
859
|
+
cardClassName
|
|
860
|
+
),
|
|
861
|
+
children: /* @__PURE__ */ jsxs2("div", { className: "space-y-4", children: [
|
|
862
|
+
/* @__PURE__ */ jsxs2("div", { className: "space-y-1.5", children: [
|
|
863
|
+
/* @__PURE__ */ jsx2(
|
|
864
|
+
"h3",
|
|
865
|
+
{
|
|
866
|
+
className: cn(
|
|
867
|
+
"text-lg font-semibold leading-none tracking-tight",
|
|
868
|
+
titleClassName
|
|
869
|
+
),
|
|
870
|
+
children: title
|
|
871
|
+
}
|
|
872
|
+
),
|
|
873
|
+
/* @__PURE__ */ jsx2(
|
|
874
|
+
"p",
|
|
875
|
+
{
|
|
876
|
+
className: cn(
|
|
877
|
+
"text-sm text-muted-foreground",
|
|
878
|
+
descriptionClassName
|
|
879
|
+
),
|
|
880
|
+
children: description
|
|
881
|
+
}
|
|
882
|
+
)
|
|
883
|
+
] }),
|
|
884
|
+
/* @__PURE__ */ jsx2(NewsletterForm, { ...formProps })
|
|
885
|
+
] })
|
|
886
|
+
}
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
function NewsletterInline({
|
|
890
|
+
label,
|
|
891
|
+
labelClassName,
|
|
892
|
+
...formProps
|
|
893
|
+
}) {
|
|
894
|
+
return /* @__PURE__ */ jsxs2("div", { className: "flex flex-col gap-2", children: [
|
|
895
|
+
label && /* @__PURE__ */ jsx2(
|
|
896
|
+
"label",
|
|
897
|
+
{
|
|
898
|
+
className: cn(
|
|
899
|
+
"text-sm font-medium leading-none",
|
|
900
|
+
labelClassName
|
|
901
|
+
),
|
|
902
|
+
children: label
|
|
903
|
+
}
|
|
904
|
+
),
|
|
905
|
+
/* @__PURE__ */ jsx2(NewsletterForm, { ...formProps })
|
|
906
|
+
] });
|
|
907
|
+
}
|
|
908
|
+
function NewsletterModalContent({
|
|
909
|
+
title = "Subscribe to our newsletter",
|
|
910
|
+
description = "We'll send you the best content, no spam.",
|
|
911
|
+
titleClassName,
|
|
912
|
+
descriptionClassName,
|
|
913
|
+
...formProps
|
|
914
|
+
}) {
|
|
915
|
+
return /* @__PURE__ */ jsxs2("div", { className: "space-y-4", children: [
|
|
916
|
+
/* @__PURE__ */ jsxs2("div", { className: "space-y-2 text-center", children: [
|
|
917
|
+
/* @__PURE__ */ jsx2(
|
|
918
|
+
"h2",
|
|
919
|
+
{
|
|
920
|
+
className: cn(
|
|
921
|
+
"text-lg font-semibold leading-none tracking-tight",
|
|
922
|
+
titleClassName
|
|
923
|
+
),
|
|
924
|
+
children: title
|
|
925
|
+
}
|
|
926
|
+
),
|
|
927
|
+
/* @__PURE__ */ jsx2(
|
|
928
|
+
"p",
|
|
929
|
+
{
|
|
930
|
+
className: cn(
|
|
931
|
+
"text-sm text-muted-foreground",
|
|
932
|
+
descriptionClassName
|
|
933
|
+
),
|
|
934
|
+
children: description
|
|
935
|
+
}
|
|
936
|
+
)
|
|
937
|
+
] }),
|
|
938
|
+
/* @__PURE__ */ jsx2(
|
|
939
|
+
NewsletterForm,
|
|
940
|
+
{
|
|
941
|
+
...formProps,
|
|
942
|
+
formClassName: "flex-col",
|
|
943
|
+
buttonClassName: "w-full"
|
|
944
|
+
}
|
|
945
|
+
)
|
|
946
|
+
] });
|
|
947
|
+
}
|
|
948
|
+
function NewsletterFooter({
|
|
949
|
+
title = "Newsletter",
|
|
950
|
+
description,
|
|
951
|
+
containerClassName,
|
|
952
|
+
titleClassName,
|
|
953
|
+
descriptionClassName,
|
|
954
|
+
privacyText,
|
|
955
|
+
privacyLink,
|
|
956
|
+
...formProps
|
|
957
|
+
}) {
|
|
958
|
+
return /* @__PURE__ */ jsxs2("div", { className: cn("space-y-4", containerClassName), children: [
|
|
959
|
+
title && /* @__PURE__ */ jsx2(
|
|
960
|
+
"h3",
|
|
961
|
+
{
|
|
962
|
+
className: cn(
|
|
963
|
+
"text-sm font-semibold uppercase tracking-wider",
|
|
964
|
+
titleClassName
|
|
965
|
+
),
|
|
966
|
+
children: title
|
|
967
|
+
}
|
|
968
|
+
),
|
|
969
|
+
description && /* @__PURE__ */ jsx2(
|
|
970
|
+
"p",
|
|
971
|
+
{
|
|
972
|
+
className: cn(
|
|
973
|
+
"text-sm text-muted-foreground",
|
|
974
|
+
descriptionClassName
|
|
975
|
+
),
|
|
976
|
+
children: description
|
|
977
|
+
}
|
|
978
|
+
),
|
|
979
|
+
/* @__PURE__ */ jsx2(NewsletterForm, { ...formProps }),
|
|
980
|
+
privacyText && /* @__PURE__ */ jsx2("p", { className: "text-xs text-muted-foreground", children: privacyLink ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
981
|
+
privacyText.replace(/\.$/, ""),
|
|
982
|
+
".",
|
|
983
|
+
" ",
|
|
984
|
+
/* @__PURE__ */ jsx2("a", { href: privacyLink, className: "underline hover:text-foreground", children: "Privacy Policy" })
|
|
985
|
+
] }) : privacyText })
|
|
986
|
+
] });
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// src/hooks/use-newsletter.ts
|
|
990
|
+
import { useState as useState2, useCallback as useCallback2 } from "react";
|
|
991
|
+
function useNewsletter(options = {}) {
|
|
992
|
+
const {
|
|
993
|
+
endpoint = "/api/newsletter/subscribe",
|
|
994
|
+
source,
|
|
995
|
+
tags,
|
|
996
|
+
metadata,
|
|
997
|
+
onSuccess,
|
|
998
|
+
onError
|
|
999
|
+
} = options;
|
|
1000
|
+
const [state, setState] = useState2({
|
|
1001
|
+
status: "idle",
|
|
1002
|
+
message: "",
|
|
1003
|
+
email: null
|
|
1004
|
+
});
|
|
1005
|
+
const subscribe = useCallback2(
|
|
1006
|
+
async (email) => {
|
|
1007
|
+
if (!email || !email.includes("@")) {
|
|
1008
|
+
const errorMsg = "Please enter a valid email address.";
|
|
1009
|
+
setState({
|
|
1010
|
+
status: "error",
|
|
1011
|
+
message: errorMsg,
|
|
1012
|
+
email
|
|
1013
|
+
});
|
|
1014
|
+
onError?.(errorMsg);
|
|
1015
|
+
return false;
|
|
1016
|
+
}
|
|
1017
|
+
setState({ status: "loading", message: "", email });
|
|
1018
|
+
try {
|
|
1019
|
+
const response = await fetch(endpoint, {
|
|
1020
|
+
method: "POST",
|
|
1021
|
+
headers: { "Content-Type": "application/json" },
|
|
1022
|
+
body: JSON.stringify({
|
|
1023
|
+
email,
|
|
1024
|
+
source,
|
|
1025
|
+
tags,
|
|
1026
|
+
metadata
|
|
1027
|
+
})
|
|
1028
|
+
});
|
|
1029
|
+
const data = await response.json();
|
|
1030
|
+
if (response.ok && data.success) {
|
|
1031
|
+
const message = data.message || "Thanks for subscribing!";
|
|
1032
|
+
setState({
|
|
1033
|
+
status: "success",
|
|
1034
|
+
message,
|
|
1035
|
+
email
|
|
1036
|
+
});
|
|
1037
|
+
onSuccess?.(email, message);
|
|
1038
|
+
return true;
|
|
1039
|
+
} else {
|
|
1040
|
+
const message = data.message || "Something went wrong. Please try again.";
|
|
1041
|
+
setState({
|
|
1042
|
+
status: "error",
|
|
1043
|
+
message,
|
|
1044
|
+
email
|
|
1045
|
+
});
|
|
1046
|
+
onError?.(message);
|
|
1047
|
+
return false;
|
|
1048
|
+
}
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
const message = "Network error. Please try again.";
|
|
1051
|
+
setState({
|
|
1052
|
+
status: "error",
|
|
1053
|
+
message,
|
|
1054
|
+
email
|
|
1055
|
+
});
|
|
1056
|
+
onError?.(message);
|
|
1057
|
+
return false;
|
|
1058
|
+
}
|
|
1059
|
+
},
|
|
1060
|
+
[endpoint, source, tags, metadata, onSuccess, onError]
|
|
1061
|
+
);
|
|
1062
|
+
const reset = useCallback2(() => {
|
|
1063
|
+
setState({
|
|
1064
|
+
status: "idle",
|
|
1065
|
+
message: "",
|
|
1066
|
+
email: null
|
|
1067
|
+
});
|
|
1068
|
+
}, []);
|
|
1069
|
+
return {
|
|
1070
|
+
...state,
|
|
1071
|
+
subscribe,
|
|
1072
|
+
reset,
|
|
1073
|
+
isLoading: state.status === "loading",
|
|
1074
|
+
isSuccess: state.status === "success",
|
|
1075
|
+
isError: state.status === "error"
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// src/adapters/email/resend.ts
|
|
1080
|
+
function createResendAdapter(config) {
|
|
1081
|
+
const { apiKey, from, replyTo, templates, adminEmail } = config;
|
|
1082
|
+
async function sendEmail(params) {
|
|
1083
|
+
const { Resend } = await import("resend");
|
|
1084
|
+
const resend = new Resend(apiKey);
|
|
1085
|
+
await resend.emails.send({
|
|
1086
|
+
from,
|
|
1087
|
+
replyTo,
|
|
1088
|
+
to: params.to,
|
|
1089
|
+
subject: params.subject,
|
|
1090
|
+
html: params.html,
|
|
1091
|
+
text: params.text
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
const defaultTemplates = {
|
|
1095
|
+
confirmation: {
|
|
1096
|
+
subject: "Confirm your subscription",
|
|
1097
|
+
text: void 0,
|
|
1098
|
+
html: ({ confirmUrl, email }) => `
|
|
1099
|
+
<!DOCTYPE html>
|
|
1100
|
+
<html>
|
|
1101
|
+
<head>
|
|
1102
|
+
<meta charset="utf-8">
|
|
1103
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1104
|
+
</head>
|
|
1105
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
1106
|
+
<h1 style="color: #111; font-size: 24px; margin-bottom: 20px;">Confirm your subscription</h1>
|
|
1107
|
+
<p>Thanks for subscribing! Please confirm your email address by clicking the button below:</p>
|
|
1108
|
+
<a href="${confirmUrl}" style="display: inline-block; background-color: #000; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0;">
|
|
1109
|
+
Confirm Subscription
|
|
1110
|
+
</a>
|
|
1111
|
+
<p style="color: #666; font-size: 14px; margin-top: 30px;">
|
|
1112
|
+
If you didn't subscribe to this newsletter, you can safely ignore this email.
|
|
1113
|
+
</p>
|
|
1114
|
+
<p style="color: #999; font-size: 12px; margin-top: 20px;">
|
|
1115
|
+
This confirmation was requested for ${email}
|
|
1116
|
+
</p>
|
|
1117
|
+
</body>
|
|
1118
|
+
</html>
|
|
1119
|
+
`
|
|
1120
|
+
},
|
|
1121
|
+
welcome: {
|
|
1122
|
+
subject: "Welcome to our newsletter!",
|
|
1123
|
+
text: void 0,
|
|
1124
|
+
html: ({ email }) => `
|
|
1125
|
+
<!DOCTYPE html>
|
|
1126
|
+
<html>
|
|
1127
|
+
<head>
|
|
1128
|
+
<meta charset="utf-8">
|
|
1129
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1130
|
+
</head>
|
|
1131
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
1132
|
+
<h1 style="color: #111; font-size: 24px; margin-bottom: 20px;">You're subscribed! \u{1F389}</h1>
|
|
1133
|
+
<p>Thanks for confirming your subscription. You'll now receive our updates at <strong>${email}</strong>.</p>
|
|
1134
|
+
<p>We're excited to have you!</p>
|
|
1135
|
+
</body>
|
|
1136
|
+
</html>
|
|
1137
|
+
`
|
|
1138
|
+
},
|
|
1139
|
+
unsubscribed: {
|
|
1140
|
+
subject: "You have been unsubscribed",
|
|
1141
|
+
text: void 0,
|
|
1142
|
+
html: ({ email, resubscribeUrl }) => `
|
|
1143
|
+
<!DOCTYPE html>
|
|
1144
|
+
<html>
|
|
1145
|
+
<head>
|
|
1146
|
+
<meta charset="utf-8">
|
|
1147
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1148
|
+
</head>
|
|
1149
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
1150
|
+
<h1 style="color: #111; font-size: 24px; margin-bottom: 20px;">Unsubscribed</h1>
|
|
1151
|
+
<p>You have been unsubscribed from our newsletter. We're sorry to see you go!</p>
|
|
1152
|
+
${resubscribeUrl ? `<p><a href="${resubscribeUrl}">Changed your mind? Resubscribe here.</a></p>` : ""}
|
|
1153
|
+
<p style="color: #999; font-size: 12px; margin-top: 20px;">
|
|
1154
|
+
This email was sent to ${email}
|
|
1155
|
+
</p>
|
|
1156
|
+
</body>
|
|
1157
|
+
</html>
|
|
1158
|
+
`
|
|
1159
|
+
}
|
|
1160
|
+
};
|
|
1161
|
+
return {
|
|
1162
|
+
async sendConfirmation(email, token, confirmUrl) {
|
|
1163
|
+
const template = templates?.confirmation ?? defaultTemplates.confirmation;
|
|
1164
|
+
const subject = template.subject ?? defaultTemplates.confirmation.subject;
|
|
1165
|
+
const html = template.html ?? defaultTemplates.confirmation.html;
|
|
1166
|
+
await sendEmail({
|
|
1167
|
+
to: email,
|
|
1168
|
+
subject,
|
|
1169
|
+
html: html({ confirmUrl, email }),
|
|
1170
|
+
text: template.text?.({ confirmUrl, email })
|
|
1171
|
+
});
|
|
1172
|
+
},
|
|
1173
|
+
async sendWelcome(email) {
|
|
1174
|
+
const template = templates?.welcome ?? defaultTemplates.welcome;
|
|
1175
|
+
const subject = template.subject ?? defaultTemplates.welcome.subject;
|
|
1176
|
+
const html = template.html ?? defaultTemplates.welcome.html;
|
|
1177
|
+
await sendEmail({
|
|
1178
|
+
to: email,
|
|
1179
|
+
subject,
|
|
1180
|
+
html: html({ email }),
|
|
1181
|
+
text: template.text?.({ email })
|
|
1182
|
+
});
|
|
1183
|
+
},
|
|
1184
|
+
async sendUnsubscribed(email, resubscribeUrl) {
|
|
1185
|
+
const template = templates?.unsubscribed ?? defaultTemplates.unsubscribed;
|
|
1186
|
+
const subject = template.subject ?? defaultTemplates.unsubscribed.subject;
|
|
1187
|
+
const html = template.html ?? defaultTemplates.unsubscribed.html;
|
|
1188
|
+
await sendEmail({
|
|
1189
|
+
to: email,
|
|
1190
|
+
subject,
|
|
1191
|
+
html: html({ email, resubscribeUrl }),
|
|
1192
|
+
text: template.text?.({ email, resubscribeUrl })
|
|
1193
|
+
});
|
|
1194
|
+
},
|
|
1195
|
+
async notifyAdmin(subscriber) {
|
|
1196
|
+
if (!adminEmail) return;
|
|
1197
|
+
await sendEmail({
|
|
1198
|
+
to: adminEmail,
|
|
1199
|
+
subject: `New newsletter subscriber: ${subscriber.email}`,
|
|
1200
|
+
html: `
|
|
1201
|
+
<!DOCTYPE html>
|
|
1202
|
+
<html>
|
|
1203
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; padding: 20px;">
|
|
1204
|
+
<h2>New Newsletter Subscriber</h2>
|
|
1205
|
+
<p><strong>Email:</strong> ${subscriber.email}</p>
|
|
1206
|
+
<p><strong>Status:</strong> ${subscriber.status}</p>
|
|
1207
|
+
${subscriber.source ? `<p><strong>Source:</strong> ${subscriber.source}</p>` : ""}
|
|
1208
|
+
${subscriber.tags?.length ? `<p><strong>Tags:</strong> ${subscriber.tags.join(", ")}</p>` : ""}
|
|
1209
|
+
<p><strong>Subscribed at:</strong> ${subscriber.createdAt.toISOString()}</p>
|
|
1210
|
+
</body>
|
|
1211
|
+
</html>
|
|
1212
|
+
`
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// src/adapters/email/nodemailer.ts
|
|
1219
|
+
function createNodemailerAdapter(config) {
|
|
1220
|
+
const { smtp, from, replyTo, templates, adminEmail } = config;
|
|
1221
|
+
async function sendEmail(params) {
|
|
1222
|
+
const nodemailer = await import("nodemailer");
|
|
1223
|
+
const transporter = nodemailer.createTransport(smtp);
|
|
1224
|
+
await transporter.sendMail({
|
|
1225
|
+
from,
|
|
1226
|
+
replyTo,
|
|
1227
|
+
to: params.to,
|
|
1228
|
+
subject: params.subject,
|
|
1229
|
+
html: params.html,
|
|
1230
|
+
text: params.text
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
const defaultTemplates = {
|
|
1234
|
+
confirmation: {
|
|
1235
|
+
subject: "Confirm your subscription",
|
|
1236
|
+
text: void 0,
|
|
1237
|
+
html: ({ confirmUrl, email }) => `
|
|
1238
|
+
<!DOCTYPE html>
|
|
1239
|
+
<html>
|
|
1240
|
+
<head>
|
|
1241
|
+
<meta charset="utf-8">
|
|
1242
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1243
|
+
</head>
|
|
1244
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
1245
|
+
<h1 style="color: #111; font-size: 24px; margin-bottom: 20px;">Confirm your subscription</h1>
|
|
1246
|
+
<p>Thanks for subscribing! Please confirm your email address by clicking the button below:</p>
|
|
1247
|
+
<a href="${confirmUrl}" style="display: inline-block; background-color: #000; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0;">
|
|
1248
|
+
Confirm Subscription
|
|
1249
|
+
</a>
|
|
1250
|
+
<p style="color: #666; font-size: 14px; margin-top: 30px;">
|
|
1251
|
+
If you didn't subscribe to this newsletter, you can safely ignore this email.
|
|
1252
|
+
</p>
|
|
1253
|
+
<p style="color: #999; font-size: 12px; margin-top: 20px;">
|
|
1254
|
+
This confirmation was requested for ${email}
|
|
1255
|
+
</p>
|
|
1256
|
+
</body>
|
|
1257
|
+
</html>
|
|
1258
|
+
`
|
|
1259
|
+
},
|
|
1260
|
+
welcome: {
|
|
1261
|
+
subject: "Welcome to our newsletter!",
|
|
1262
|
+
text: void 0,
|
|
1263
|
+
html: ({ email }) => `
|
|
1264
|
+
<!DOCTYPE html>
|
|
1265
|
+
<html>
|
|
1266
|
+
<head>
|
|
1267
|
+
<meta charset="utf-8">
|
|
1268
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1269
|
+
</head>
|
|
1270
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
1271
|
+
<h1 style="color: #111; font-size: 24px; margin-bottom: 20px;">You're subscribed! \u{1F389}</h1>
|
|
1272
|
+
<p>Thanks for confirming your subscription. You'll now receive our updates at <strong>${email}</strong>.</p>
|
|
1273
|
+
<p>We're excited to have you!</p>
|
|
1274
|
+
</body>
|
|
1275
|
+
</html>
|
|
1276
|
+
`
|
|
1277
|
+
},
|
|
1278
|
+
unsubscribed: {
|
|
1279
|
+
subject: "You have been unsubscribed",
|
|
1280
|
+
text: void 0,
|
|
1281
|
+
html: ({ email, resubscribeUrl }) => `
|
|
1282
|
+
<!DOCTYPE html>
|
|
1283
|
+
<html>
|
|
1284
|
+
<head>
|
|
1285
|
+
<meta charset="utf-8">
|
|
1286
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1287
|
+
</head>
|
|
1288
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
1289
|
+
<h1 style="color: #111; font-size: 24px; margin-bottom: 20px;">Unsubscribed</h1>
|
|
1290
|
+
<p>You have been unsubscribed from our newsletter. We're sorry to see you go!</p>
|
|
1291
|
+
${resubscribeUrl ? `<p><a href="${resubscribeUrl}">Changed your mind? Resubscribe here.</a></p>` : ""}
|
|
1292
|
+
<p style="color: #999; font-size: 12px; margin-top: 20px;">
|
|
1293
|
+
This email was sent to ${email}
|
|
1294
|
+
</p>
|
|
1295
|
+
</body>
|
|
1296
|
+
</html>
|
|
1297
|
+
`
|
|
1298
|
+
}
|
|
1299
|
+
};
|
|
1300
|
+
return {
|
|
1301
|
+
async sendConfirmation(email, token, confirmUrl) {
|
|
1302
|
+
const template = templates?.confirmation ?? defaultTemplates.confirmation;
|
|
1303
|
+
const subject = template.subject ?? defaultTemplates.confirmation.subject;
|
|
1304
|
+
const html = template.html ?? defaultTemplates.confirmation.html;
|
|
1305
|
+
await sendEmail({
|
|
1306
|
+
to: email,
|
|
1307
|
+
subject,
|
|
1308
|
+
html: html({ confirmUrl, email }),
|
|
1309
|
+
text: template.text?.({ confirmUrl, email })
|
|
1310
|
+
});
|
|
1311
|
+
},
|
|
1312
|
+
async sendWelcome(email) {
|
|
1313
|
+
const template = templates?.welcome ?? defaultTemplates.welcome;
|
|
1314
|
+
const subject = template.subject ?? defaultTemplates.welcome.subject;
|
|
1315
|
+
const html = template.html ?? defaultTemplates.welcome.html;
|
|
1316
|
+
await sendEmail({
|
|
1317
|
+
to: email,
|
|
1318
|
+
subject,
|
|
1319
|
+
html: html({ email }),
|
|
1320
|
+
text: template.text?.({ email })
|
|
1321
|
+
});
|
|
1322
|
+
},
|
|
1323
|
+
async sendUnsubscribed(email, resubscribeUrl) {
|
|
1324
|
+
const template = templates?.unsubscribed ?? defaultTemplates.unsubscribed;
|
|
1325
|
+
const subject = template.subject ?? defaultTemplates.unsubscribed.subject;
|
|
1326
|
+
const html = template.html ?? defaultTemplates.unsubscribed.html;
|
|
1327
|
+
await sendEmail({
|
|
1328
|
+
to: email,
|
|
1329
|
+
subject,
|
|
1330
|
+
html: html({ email, resubscribeUrl }),
|
|
1331
|
+
text: template.text?.({ email, resubscribeUrl })
|
|
1332
|
+
});
|
|
1333
|
+
},
|
|
1334
|
+
async notifyAdmin(subscriber) {
|
|
1335
|
+
if (!adminEmail) return;
|
|
1336
|
+
await sendEmail({
|
|
1337
|
+
to: adminEmail,
|
|
1338
|
+
subject: `New newsletter subscriber: ${subscriber.email}`,
|
|
1339
|
+
html: `
|
|
1340
|
+
<!DOCTYPE html>
|
|
1341
|
+
<html>
|
|
1342
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; padding: 20px;">
|
|
1343
|
+
<h2>New Newsletter Subscriber</h2>
|
|
1344
|
+
<p><strong>Email:</strong> ${subscriber.email}</p>
|
|
1345
|
+
<p><strong>Status:</strong> ${subscriber.status}</p>
|
|
1346
|
+
${subscriber.source ? `<p><strong>Source:</strong> ${subscriber.source}</p>` : ""}
|
|
1347
|
+
${subscriber.tags?.length ? `<p><strong>Tags:</strong> ${subscriber.tags.join(", ")}</p>` : ""}
|
|
1348
|
+
<p><strong>Subscribed at:</strong> ${subscriber.createdAt.toISOString()}</p>
|
|
1349
|
+
</body>
|
|
1350
|
+
</html>
|
|
1351
|
+
`
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// src/adapters/email/mailchimp.ts
|
|
1358
|
+
function createMailchimpAdapter(config) {
|
|
1359
|
+
const { apiKey, server, listId, from, adminEmail, useAsStorage } = config;
|
|
1360
|
+
async function getClient() {
|
|
1361
|
+
const mailchimp = await import("@mailchimp/mailchimp_marketing");
|
|
1362
|
+
mailchimp.default.setConfig({
|
|
1363
|
+
apiKey,
|
|
1364
|
+
server
|
|
1365
|
+
});
|
|
1366
|
+
return mailchimp.default;
|
|
1367
|
+
}
|
|
1368
|
+
async function addOrUpdateMember(email, status) {
|
|
1369
|
+
const client = await getClient();
|
|
1370
|
+
const subscriberHash = await hashEmail(email);
|
|
1371
|
+
try {
|
|
1372
|
+
await client.lists.setListMember(listId, subscriberHash, {
|
|
1373
|
+
email_address: email,
|
|
1374
|
+
status_if_new: status
|
|
1375
|
+
});
|
|
1376
|
+
} catch (error) {
|
|
1377
|
+
await client.lists.addListMember(listId, {
|
|
1378
|
+
email_address: email,
|
|
1379
|
+
status
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
async function hashEmail(email) {
|
|
1384
|
+
const crypto2 = await import("crypto");
|
|
1385
|
+
return crypto2.createHash("md5").update(email.toLowerCase()).digest("hex");
|
|
1386
|
+
}
|
|
1387
|
+
return {
|
|
1388
|
+
async sendConfirmation(email, _token, _confirmUrl) {
|
|
1389
|
+
if (useAsStorage) {
|
|
1390
|
+
await addOrUpdateMember(email, "pending");
|
|
1391
|
+
}
|
|
1392
|
+
},
|
|
1393
|
+
async sendWelcome(email) {
|
|
1394
|
+
if (useAsStorage) {
|
|
1395
|
+
await addOrUpdateMember(email, "subscribed");
|
|
1396
|
+
}
|
|
1397
|
+
},
|
|
1398
|
+
async sendUnsubscribed(email) {
|
|
1399
|
+
if (useAsStorage) {
|
|
1400
|
+
const client = await getClient();
|
|
1401
|
+
const subscriberHash = await hashEmail(email);
|
|
1402
|
+
await client.lists.updateListMember(listId, subscriberHash, {
|
|
1403
|
+
status: "unsubscribed"
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
},
|
|
1407
|
+
async notifyAdmin(subscriber) {
|
|
1408
|
+
if (!adminEmail) return;
|
|
1409
|
+
console.warn(
|
|
1410
|
+
`[newsletter-kit] Mailchimp adapter: Admin notification not implemented. Consider setting up a Mailchimp webhook or automation. New subscriber: ${subscriber.email}`
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
function createMailchimpStorageAdapter(config) {
|
|
1416
|
+
const { apiKey, server, listId } = config;
|
|
1417
|
+
async function getClient() {
|
|
1418
|
+
const mailchimp = await import("@mailchimp/mailchimp_marketing");
|
|
1419
|
+
mailchimp.default.setConfig({
|
|
1420
|
+
apiKey,
|
|
1421
|
+
server
|
|
1422
|
+
});
|
|
1423
|
+
return mailchimp.default;
|
|
1424
|
+
}
|
|
1425
|
+
async function hashEmail(email) {
|
|
1426
|
+
const crypto2 = await import("crypto");
|
|
1427
|
+
return crypto2.createHash("md5").update(email.toLowerCase()).digest("hex");
|
|
1428
|
+
}
|
|
1429
|
+
return {
|
|
1430
|
+
async createSubscriber(input) {
|
|
1431
|
+
const client = await getClient();
|
|
1432
|
+
await client.lists.addListMember(listId, {
|
|
1433
|
+
email_address: input.email,
|
|
1434
|
+
status: "pending",
|
|
1435
|
+
// Mailchimp handles double opt-in
|
|
1436
|
+
tags: input.tags,
|
|
1437
|
+
merge_fields: {
|
|
1438
|
+
SOURCE: input.source || ""
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
return {
|
|
1442
|
+
id: await hashEmail(input.email),
|
|
1443
|
+
email: input.email,
|
|
1444
|
+
status: "pending",
|
|
1445
|
+
source: input.source,
|
|
1446
|
+
tags: input.tags,
|
|
1447
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1448
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1449
|
+
};
|
|
1450
|
+
},
|
|
1451
|
+
async getSubscriberByEmail(email) {
|
|
1452
|
+
const client = await getClient();
|
|
1453
|
+
const hash = await hashEmail(email);
|
|
1454
|
+
try {
|
|
1455
|
+
const member = await client.lists.getListMember(listId, hash);
|
|
1456
|
+
return {
|
|
1457
|
+
id: hash,
|
|
1458
|
+
email: member.email_address,
|
|
1459
|
+
status: member.status === "subscribed" ? "confirmed" : member.status,
|
|
1460
|
+
tags: member.tags?.map((t) => t.name),
|
|
1461
|
+
createdAt: new Date(member.timestamp_signup || member.timestamp_opt || Date.now()),
|
|
1462
|
+
updatedAt: new Date(member.last_changed || Date.now())
|
|
1463
|
+
};
|
|
1464
|
+
} catch {
|
|
1465
|
+
return null;
|
|
1466
|
+
}
|
|
1467
|
+
},
|
|
1468
|
+
// Note: Mailchimp doesn't use tokens - it has its own confirmation system
|
|
1469
|
+
async getSubscriberByToken() {
|
|
1470
|
+
console.warn("[newsletter-kit] Mailchimp uses its own confirmation system");
|
|
1471
|
+
return null;
|
|
1472
|
+
},
|
|
1473
|
+
async confirmSubscriber(_token) {
|
|
1474
|
+
console.warn("[newsletter-kit] Mailchimp handles confirmation automatically");
|
|
1475
|
+
return null;
|
|
1476
|
+
},
|
|
1477
|
+
async unsubscribe(email) {
|
|
1478
|
+
const client = await getClient();
|
|
1479
|
+
const hash = await hashEmail(email);
|
|
1480
|
+
await client.lists.updateListMember(listId, hash, {
|
|
1481
|
+
status: "unsubscribed"
|
|
1482
|
+
});
|
|
1483
|
+
return true;
|
|
1484
|
+
}
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// src/adapters/storage/prisma.ts
|
|
1489
|
+
function mapToSubscriber(record) {
|
|
1490
|
+
return {
|
|
1491
|
+
id: record.id,
|
|
1492
|
+
email: record.email,
|
|
1493
|
+
status: record.status,
|
|
1494
|
+
source: record.source,
|
|
1495
|
+
tags: record.tags,
|
|
1496
|
+
metadata: record.metadata,
|
|
1497
|
+
consentIp: record.consentIp,
|
|
1498
|
+
consentAt: record.consentAt ? new Date(record.consentAt) : void 0,
|
|
1499
|
+
confirmedAt: record.confirmedAt ? new Date(record.confirmedAt) : void 0,
|
|
1500
|
+
unsubscribedAt: record.unsubscribedAt ? new Date(record.unsubscribedAt) : void 0,
|
|
1501
|
+
createdAt: new Date(record.createdAt),
|
|
1502
|
+
updatedAt: new Date(record.updatedAt)
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
function createPrismaAdapter(config) {
|
|
1506
|
+
const { prisma } = config;
|
|
1507
|
+
const model = prisma.newsletterSubscriber;
|
|
1508
|
+
return {
|
|
1509
|
+
async createSubscriber(input, token) {
|
|
1510
|
+
const confirmToken = token || generateToken();
|
|
1511
|
+
const record = await model.upsert({
|
|
1512
|
+
where: { email: input.email.toLowerCase() },
|
|
1513
|
+
update: {
|
|
1514
|
+
token: confirmToken,
|
|
1515
|
+
source: input.source,
|
|
1516
|
+
tags: input.tags || [],
|
|
1517
|
+
metadata: input.metadata,
|
|
1518
|
+
consentIp: input.ip,
|
|
1519
|
+
consentAt: /* @__PURE__ */ new Date(),
|
|
1520
|
+
// Reset to pending if resubscribing
|
|
1521
|
+
status: "pending",
|
|
1522
|
+
unsubscribedAt: null
|
|
1523
|
+
},
|
|
1524
|
+
create: {
|
|
1525
|
+
email: input.email.toLowerCase(),
|
|
1526
|
+
token: confirmToken,
|
|
1527
|
+
status: "pending",
|
|
1528
|
+
source: input.source,
|
|
1529
|
+
tags: input.tags || [],
|
|
1530
|
+
metadata: input.metadata,
|
|
1531
|
+
consentIp: input.ip,
|
|
1532
|
+
consentAt: /* @__PURE__ */ new Date()
|
|
1533
|
+
}
|
|
1534
|
+
});
|
|
1535
|
+
return mapToSubscriber(record);
|
|
1536
|
+
},
|
|
1537
|
+
async getSubscriberByEmail(email) {
|
|
1538
|
+
const record = await model.findUnique({
|
|
1539
|
+
where: { email: email.toLowerCase() }
|
|
1540
|
+
});
|
|
1541
|
+
return record ? mapToSubscriber(record) : null;
|
|
1542
|
+
},
|
|
1543
|
+
async getSubscriberByToken(token) {
|
|
1544
|
+
const record = await model.findFirst({
|
|
1545
|
+
where: { token }
|
|
1546
|
+
});
|
|
1547
|
+
return record ? mapToSubscriber(record) : null;
|
|
1548
|
+
},
|
|
1549
|
+
async confirmSubscriber(token) {
|
|
1550
|
+
try {
|
|
1551
|
+
const record = await model.update({
|
|
1552
|
+
where: { token },
|
|
1553
|
+
data: {
|
|
1554
|
+
status: "confirmed",
|
|
1555
|
+
confirmedAt: /* @__PURE__ */ new Date(),
|
|
1556
|
+
token: null
|
|
1557
|
+
// Clear token after use
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
return mapToSubscriber(record);
|
|
1561
|
+
} catch {
|
|
1562
|
+
return null;
|
|
1563
|
+
}
|
|
1564
|
+
},
|
|
1565
|
+
async unsubscribe(email) {
|
|
1566
|
+
try {
|
|
1567
|
+
await model.update({
|
|
1568
|
+
where: { email: email.toLowerCase() },
|
|
1569
|
+
data: {
|
|
1570
|
+
status: "unsubscribed",
|
|
1571
|
+
unsubscribedAt: /* @__PURE__ */ new Date()
|
|
1572
|
+
}
|
|
1573
|
+
});
|
|
1574
|
+
return true;
|
|
1575
|
+
} catch {
|
|
1576
|
+
return false;
|
|
1577
|
+
}
|
|
1578
|
+
},
|
|
1579
|
+
async listSubscribers(options) {
|
|
1580
|
+
const where = {};
|
|
1581
|
+
if (options?.status) {
|
|
1582
|
+
where.status = options.status;
|
|
1583
|
+
}
|
|
1584
|
+
if (options?.source) {
|
|
1585
|
+
where.source = options.source;
|
|
1586
|
+
}
|
|
1587
|
+
if (options?.tags?.length) {
|
|
1588
|
+
where.tags = { hasSome: options.tags };
|
|
1589
|
+
}
|
|
1590
|
+
const [records, total] = await Promise.all([
|
|
1591
|
+
model.findMany({
|
|
1592
|
+
where,
|
|
1593
|
+
take: options?.limit || 100,
|
|
1594
|
+
skip: options?.offset || 0,
|
|
1595
|
+
orderBy: { createdAt: "desc" }
|
|
1596
|
+
}),
|
|
1597
|
+
model.count({ where })
|
|
1598
|
+
]);
|
|
1599
|
+
return {
|
|
1600
|
+
subscribers: records.map((r) => mapToSubscriber(r)),
|
|
1601
|
+
total
|
|
1602
|
+
};
|
|
1603
|
+
},
|
|
1604
|
+
async deleteSubscriber(email) {
|
|
1605
|
+
try {
|
|
1606
|
+
await model.delete({
|
|
1607
|
+
where: { email: email.toLowerCase() }
|
|
1608
|
+
});
|
|
1609
|
+
return true;
|
|
1610
|
+
} catch {
|
|
1611
|
+
return false;
|
|
1612
|
+
}
|
|
1613
|
+
},
|
|
1614
|
+
async updateSubscriber(email, data) {
|
|
1615
|
+
try {
|
|
1616
|
+
const record = await model.update({
|
|
1617
|
+
where: { email: email.toLowerCase() },
|
|
1618
|
+
data: {
|
|
1619
|
+
...data.source !== void 0 && { source: data.source },
|
|
1620
|
+
...data.tags !== void 0 && { tags: data.tags },
|
|
1621
|
+
...data.metadata !== void 0 && { metadata: data.metadata }
|
|
1622
|
+
}
|
|
1623
|
+
});
|
|
1624
|
+
return mapToSubscriber(record);
|
|
1625
|
+
} catch {
|
|
1626
|
+
return null;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// src/adapters/storage/supabase.ts
|
|
1633
|
+
function mapToSubscriber2(record) {
|
|
1634
|
+
return {
|
|
1635
|
+
id: record.id,
|
|
1636
|
+
email: record.email,
|
|
1637
|
+
status: record.status,
|
|
1638
|
+
source: record.source,
|
|
1639
|
+
tags: record.tags,
|
|
1640
|
+
metadata: record.metadata,
|
|
1641
|
+
consentIp: record.consent_ip,
|
|
1642
|
+
consentAt: record.consent_at ? new Date(record.consent_at) : void 0,
|
|
1643
|
+
confirmedAt: record.confirmed_at ? new Date(record.confirmed_at) : void 0,
|
|
1644
|
+
unsubscribedAt: record.unsubscribed_at ? new Date(record.unsubscribed_at) : void 0,
|
|
1645
|
+
createdAt: new Date(record.created_at),
|
|
1646
|
+
updatedAt: new Date(record.updated_at)
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
function createSupabaseAdapter(config) {
|
|
1650
|
+
const { supabase, tableName = "newsletter_subscribers" } = config;
|
|
1651
|
+
return {
|
|
1652
|
+
async createSubscriber(input, token) {
|
|
1653
|
+
const confirmToken = token || generateToken();
|
|
1654
|
+
const email = input.email.toLowerCase();
|
|
1655
|
+
const { data, error } = await supabase.from(tableName).upsert(
|
|
1656
|
+
{
|
|
1657
|
+
email,
|
|
1658
|
+
token: confirmToken,
|
|
1659
|
+
status: "pending",
|
|
1660
|
+
source: input.source,
|
|
1661
|
+
tags: input.tags || [],
|
|
1662
|
+
metadata: input.metadata,
|
|
1663
|
+
consent_ip: input.ip,
|
|
1664
|
+
consent_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1665
|
+
unsubscribed_at: null
|
|
1666
|
+
},
|
|
1667
|
+
{ onConflict: "email" }
|
|
1668
|
+
);
|
|
1669
|
+
if (error) throw error;
|
|
1670
|
+
const { data: record } = await supabase.from(tableName).select().eq("email", email).single();
|
|
1671
|
+
return mapToSubscriber2(record);
|
|
1672
|
+
},
|
|
1673
|
+
async getSubscriberByEmail(email) {
|
|
1674
|
+
const { data, error } = await supabase.from(tableName).select().eq("email", email.toLowerCase()).single();
|
|
1675
|
+
if (error || !data) return null;
|
|
1676
|
+
return mapToSubscriber2(data);
|
|
1677
|
+
},
|
|
1678
|
+
async getSubscriberByToken(token) {
|
|
1679
|
+
const { data, error } = await supabase.from(tableName).select().eq("token", token).single();
|
|
1680
|
+
if (error || !data) return null;
|
|
1681
|
+
return mapToSubscriber2(data);
|
|
1682
|
+
},
|
|
1683
|
+
async confirmSubscriber(token) {
|
|
1684
|
+
const { error } = await supabase.from(tableName).update({
|
|
1685
|
+
status: "confirmed",
|
|
1686
|
+
confirmed_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1687
|
+
token: null
|
|
1688
|
+
}).eq("token", token);
|
|
1689
|
+
if (error) return null;
|
|
1690
|
+
const { data } = await supabase.from(tableName).select().eq("token", null).single();
|
|
1691
|
+
return data ? mapToSubscriber2(data) : null;
|
|
1692
|
+
},
|
|
1693
|
+
async unsubscribe(email) {
|
|
1694
|
+
const { error } = await supabase.from(tableName).update({
|
|
1695
|
+
status: "unsubscribed",
|
|
1696
|
+
unsubscribed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1697
|
+
}).eq("email", email.toLowerCase());
|
|
1698
|
+
return !error;
|
|
1699
|
+
},
|
|
1700
|
+
async listSubscribers(options) {
|
|
1701
|
+
const limit = options?.limit || 100;
|
|
1702
|
+
const offset = options?.offset || 0;
|
|
1703
|
+
let query = supabase.from(tableName).select();
|
|
1704
|
+
if (options?.status) {
|
|
1705
|
+
query = query.eq("status", options.status);
|
|
1706
|
+
}
|
|
1707
|
+
if (options?.source) {
|
|
1708
|
+
query = query.eq("source", options.source);
|
|
1709
|
+
}
|
|
1710
|
+
if (options?.tags?.length) {
|
|
1711
|
+
query = query.contains("tags", options.tags);
|
|
1712
|
+
}
|
|
1713
|
+
const { data, error } = await query.order("created_at", { ascending: false }).range(offset, offset + limit - 1);
|
|
1714
|
+
if (error) throw error;
|
|
1715
|
+
return {
|
|
1716
|
+
subscribers: (data || []).map((r) => mapToSubscriber2(r)),
|
|
1717
|
+
total: (data || []).length
|
|
1718
|
+
// Note: Supabase doesn't return total count easily
|
|
1719
|
+
};
|
|
1720
|
+
},
|
|
1721
|
+
async deleteSubscriber(email) {
|
|
1722
|
+
const { error } = await supabase.from(tableName).delete().eq("email", email.toLowerCase());
|
|
1723
|
+
return !error;
|
|
1724
|
+
},
|
|
1725
|
+
async updateSubscriber(email, data) {
|
|
1726
|
+
const updateData = {};
|
|
1727
|
+
if (data.source !== void 0) updateData.source = data.source;
|
|
1728
|
+
if (data.tags !== void 0) updateData.tags = data.tags;
|
|
1729
|
+
if (data.metadata !== void 0) updateData.metadata = data.metadata;
|
|
1730
|
+
const { error } = await supabase.from(tableName).update(updateData).eq("email", email.toLowerCase());
|
|
1731
|
+
if (error) return null;
|
|
1732
|
+
return this.getSubscriberByEmail(email);
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
export {
|
|
1737
|
+
NewsletterBlock,
|
|
1738
|
+
NewsletterCard,
|
|
1739
|
+
NewsletterFooter,
|
|
1740
|
+
NewsletterForm,
|
|
1741
|
+
NewsletterInline,
|
|
1742
|
+
NewsletterModalContent,
|
|
1743
|
+
createMailchimpAdapter,
|
|
1744
|
+
createMailchimpStorageAdapter,
|
|
1745
|
+
createMemoryAdapter,
|
|
1746
|
+
createNewsletterHandlers,
|
|
1747
|
+
createNodemailerAdapter,
|
|
1748
|
+
createNoopAdapter,
|
|
1749
|
+
createPrismaAdapter,
|
|
1750
|
+
createRateLimiter,
|
|
1751
|
+
createResendAdapter,
|
|
1752
|
+
createSupabaseAdapter,
|
|
1753
|
+
generateToken,
|
|
1754
|
+
generateUrlSafeToken,
|
|
1755
|
+
getClientIP,
|
|
1756
|
+
isDisposableEmail,
|
|
1757
|
+
isValidEmail,
|
|
1758
|
+
normalizeEmail,
|
|
1759
|
+
sanitizeString,
|
|
1760
|
+
useNewsletter
|
|
1761
|
+
};
|
|
1762
|
+
//# sourceMappingURL=index.js.map
|