@jhits/plugin-newsletter 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@jhits/plugin-newsletter",
3
+ "version": "0.0.1",
4
+ "description": "Newsletter management and email delivery plugin for the JHITS ecosystem",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "main": "./src/index.tsx",
9
+ "types": "./src/index.tsx",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./src/index.tsx",
13
+ "default": "./src/index.tsx"
14
+ },
15
+ "./server": {
16
+ "types": "./src/index.server.ts",
17
+ "default": "./src/index.server.ts"
18
+ }
19
+ },
20
+ "dependencies": {
21
+ "@jhits/plugin-core": "^0.0.1",
22
+ "lucide-react": "^0.562.0",
23
+ "mongodb": "^7.0.0",
24
+ "next-auth": "^4.24.13",
25
+ "nodemailer": "^7.0.12"
26
+ },
27
+ "peerDependencies": {
28
+ "next": ">=15.0.0",
29
+ "react": ">=18.0.0",
30
+ "react-dom": ">=18.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^20.19.27",
34
+ "@types/nodemailer": "^7.0.4",
35
+ "@types/react": "^19",
36
+ "@types/react-dom": "^19",
37
+ "next": "16.1.1",
38
+ "react": "19.2.3",
39
+ "react-dom": "19.2.3",
40
+ "typescript": "^5"
41
+ },
42
+ "files": [
43
+ "src",
44
+ "package.json"
45
+ ]
46
+ }
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Newsletter API Handler
3
+ * Handles all newsletter-related API requests
4
+ */
5
+
6
+ 'use server';
7
+
8
+ import { NextRequest, NextResponse } from 'next/server';
9
+ import { NewsletterApiConfig } from '../types/newsletter';
10
+ import nodemailer from 'nodemailer';
11
+
12
+ /**
13
+ * GET /api/plugin-newsletter/subscribers - List all subscribers
14
+ */
15
+ export async function GET_SUBSCRIBERS(
16
+ req: NextRequest,
17
+ config: NewsletterApiConfig
18
+ ): Promise<NextResponse> {
19
+ try {
20
+ const userId = await config.getUserId?.(req);
21
+ if (!userId) {
22
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
23
+ }
24
+
25
+ const dbConnection = await config.getDb();
26
+ const db = dbConnection.db();
27
+ const subscribers = db.collection('subscribers');
28
+
29
+ const subscriberList = await subscribers
30
+ .find({})
31
+ .sort({ subscribedAt: -1 })
32
+ .toArray();
33
+
34
+ return NextResponse.json(subscriberList);
35
+ } catch (error: any) {
36
+ console.error('[NewsletterAPI] GET_SUBSCRIBERS error:', error);
37
+ return NextResponse.json(
38
+ { error: 'Failed to fetch subscribers', detail: error.message },
39
+ { status: 500 }
40
+ );
41
+ }
42
+ }
43
+
44
+ /**
45
+ * POST /api/plugin-newsletter/subscribers - Subscribe new email
46
+ */
47
+ export async function POST_SUBSCRIBE(
48
+ req: NextRequest,
49
+ config: NewsletterApiConfig
50
+ ): Promise<NextResponse> {
51
+ try {
52
+ const body = await req.json();
53
+ const { email, language } = body;
54
+
55
+ if (!email || !email.includes('@')) {
56
+ return NextResponse.json(
57
+ { error: 'Invalid email address' },
58
+ { status: 400 }
59
+ );
60
+ }
61
+
62
+ const dbConnection = await config.getDb();
63
+ const db = dbConnection.db();
64
+ const subscribers = db.collection('subscribers');
65
+
66
+ // Check if already subscribed
67
+ const existing = await subscribers.findOne({ email: email.toLowerCase() });
68
+ if (existing) {
69
+ return NextResponse.json(
70
+ { error: 'You are already subscribed!' },
71
+ { status: 409 }
72
+ );
73
+ }
74
+
75
+ // Add subscriber
76
+ await subscribers.insertOne({
77
+ email: email.toLowerCase(),
78
+ language: language || 'en',
79
+ subscribedAt: new Date(),
80
+ status: 'active',
81
+ });
82
+
83
+ // Send welcome email if configured
84
+ if (config.emailConfig) {
85
+ const headers = await req.headers;
86
+ const host = headers.get('host') || undefined;
87
+ await sendWelcomeEmail(config, email, language || 'en', host);
88
+ }
89
+
90
+ return NextResponse.json({ message: 'Successfully subscribed' }, { status: 201 });
91
+ } catch (error: any) {
92
+ console.error('[NewsletterAPI] POST_SUBSCRIBE error:', error);
93
+ return NextResponse.json(
94
+ { error: 'Failed to subscribe', detail: error.message },
95
+ { status: 500 }
96
+ );
97
+ }
98
+ }
99
+
100
+ /**
101
+ * GET /api/plugin-newsletter/subscribers/[email] - Get specific subscriber
102
+ */
103
+ export async function GET_SUBSCRIBER(
104
+ req: NextRequest,
105
+ email: string,
106
+ config: NewsletterApiConfig
107
+ ): Promise<NextResponse> {
108
+ try {
109
+ const userId = await config.getUserId?.(req);
110
+ if (!userId) {
111
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
112
+ }
113
+
114
+ const dbConnection = await config.getDb();
115
+ const db = dbConnection.db();
116
+ const subscribers = db.collection('subscribers');
117
+
118
+ const subscriber = await subscribers.findOne({ email: email.toLowerCase() });
119
+ if (!subscriber) {
120
+ return NextResponse.json(
121
+ { error: 'Subscriber not found' },
122
+ { status: 404 }
123
+ );
124
+ }
125
+
126
+ return NextResponse.json(subscriber);
127
+ } catch (error: any) {
128
+ console.error('[NewsletterAPI] GET_SUBSCRIBER error:', error);
129
+ return NextResponse.json(
130
+ { error: 'Failed to fetch subscriber', detail: error.message },
131
+ { status: 500 }
132
+ );
133
+ }
134
+ }
135
+
136
+ /**
137
+ * DELETE /api/plugin-newsletter/subscribers/[email] - Unsubscribe/delete subscriber
138
+ */
139
+ export async function DELETE_SUBSCRIBER(
140
+ req: NextRequest,
141
+ email: string,
142
+ config: NewsletterApiConfig
143
+ ): Promise<NextResponse> {
144
+ try {
145
+ const userId = await config.getUserId?.(req);
146
+ if (!userId) {
147
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
148
+ }
149
+
150
+ const dbConnection = await config.getDb();
151
+ const db = dbConnection.db();
152
+ const subscribers = db.collection('subscribers');
153
+
154
+ const result = await subscribers.deleteOne({ email: email.toLowerCase() });
155
+ if (result.deletedCount === 0) {
156
+ return NextResponse.json(
157
+ { error: 'Subscriber not found' },
158
+ { status: 404 }
159
+ );
160
+ }
161
+
162
+ return NextResponse.json({ message: 'Subscriber successfully removed' });
163
+ } catch (error: any) {
164
+ console.error('[NewsletterAPI] DELETE_SUBSCRIBER error:', error);
165
+ return NextResponse.json(
166
+ { error: 'Failed to delete subscriber', detail: error.message },
167
+ { status: 500 }
168
+ );
169
+ }
170
+ }
171
+
172
+ /**
173
+ * GET /api/plugin-newsletter/settings - Get newsletter settings
174
+ */
175
+ export async function GET_SETTINGS(
176
+ req: NextRequest,
177
+ config: NewsletterApiConfig
178
+ ): Promise<NextResponse> {
179
+ try {
180
+ const userId = await config.getUserId?.(req);
181
+ if (!userId) {
182
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
183
+ }
184
+
185
+ const dbConnection = await config.getDb();
186
+ const db = dbConnection.db();
187
+ const newsletters = db.collection('newsletters');
188
+
189
+ const settings = await newsletters.findOne({ id: 'welcome_automation' });
190
+ return NextResponse.json(settings || {
191
+ id: 'welcome_automation',
192
+ languages: {
193
+ nl: { title: '', message: '' },
194
+ en: { title: '', message: '' },
195
+ sv: { title: '', message: '' },
196
+ },
197
+ });
198
+ } catch (error: any) {
199
+ console.error('[NewsletterAPI] GET_SETTINGS error:', error);
200
+ return NextResponse.json(
201
+ { error: 'Failed to fetch settings', detail: error.message },
202
+ { status: 500 }
203
+ );
204
+ }
205
+ }
206
+
207
+ /**
208
+ * POST /api/plugin-newsletter/settings - Update newsletter settings
209
+ */
210
+ export async function POST_SETTINGS(
211
+ req: NextRequest,
212
+ config: NewsletterApiConfig
213
+ ): Promise<NextResponse> {
214
+ try {
215
+ const userId = await config.getUserId?.(req);
216
+ if (!userId) {
217
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
218
+ }
219
+
220
+ const body = await req.json();
221
+ const dbConnection = await config.getDb();
222
+ const db = dbConnection.db();
223
+ const newsletters = db.collection('newsletters');
224
+
225
+ await newsletters.updateOne(
226
+ { id: 'welcome_automation' },
227
+ {
228
+ $set: {
229
+ id: 'welcome_automation',
230
+ languages: body.languages || {},
231
+ updatedAt: new Date(),
232
+ },
233
+ },
234
+ { upsert: true }
235
+ );
236
+
237
+ return NextResponse.json({ success: true, message: 'Settings updated successfully' });
238
+ } catch (error: any) {
239
+ console.error('[NewsletterAPI] POST_SETTINGS error:', error);
240
+ return NextResponse.json(
241
+ { error: 'Failed to update settings', detail: error.message },
242
+ { status: 500 }
243
+ );
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Send welcome email to new subscriber
249
+ */
250
+ async function sendWelcomeEmail(
251
+ config: NewsletterApiConfig,
252
+ email: string,
253
+ language: string,
254
+ host?: string
255
+ ): Promise<void> {
256
+ if (!config.emailConfig) return;
257
+
258
+ try {
259
+ const transporter = nodemailer.createTransport({
260
+ host: config.emailConfig.host,
261
+ port: config.emailConfig.port,
262
+ secure: true,
263
+ auth: {
264
+ user: config.emailConfig.user,
265
+ pass: config.emailConfig.password,
266
+ },
267
+ connectionTimeout: 10000,
268
+ });
269
+
270
+ const baseUrl = host
271
+ ? (host.includes('localhost') ? 'http' : 'https') + '://' + host
272
+ : config.baseUrl || 'https://bya.jorishummel.com';
273
+
274
+ const slugs: Record<string, string> = {
275
+ sv: '/avmälla',
276
+ nl: '/afmelden',
277
+ en: '/unsubscribe',
278
+ };
279
+ const slug = slugs[language] || slugs.en;
280
+ const unsubscribeUrl = `${baseUrl}${slug}?email=${encodeURIComponent(email)}`;
281
+
282
+ const isDutch = language === 'nl';
283
+ const message = isDutch
284
+ ? `Bedankt dat je deel uitmaakt van de **Botanics & You** community.\n\n` +
285
+ `Wij geloven dat de natuur alles biedt wat we werkelijk nodig hebben. Terwijl we achter de schermen hard werken aan de lancering, nemen we je graag mee op reis door de wereld van kruiden en natuurlijke vitaliteit.\n\n` +
286
+ `**Wat kun je van ons verwachten:**\n` +
287
+ `• Exclusieve updates over onze lancering\n` +
288
+ `• Inzichten in de kracht van lokale kruiden\n` +
289
+ `• Tips voor een diepere verbinding met de natuur\n\n` +
290
+ `Bedankt voor je geduld en interesse in een natuurlijke manier van leven. We spreken je snel!`
291
+ : `Thank you for joining the **Botanics & You** community.\n\n` +
292
+ `We believe that nature provides everything we truly need. While we work hard behind the scenes for our launch, we look forward to taking you on a journey through the world of herbs and natural vitality.\n\n` +
293
+ `**What to expect from us:**\n` +
294
+ `• Exclusive updates on our upcoming launch\n` +
295
+ `• Insights into the healing properties of local herbs\n` +
296
+ `• Tips for restoring your connection with nature\n\n` +
297
+ `Thank you for your patience and your interest in a more natural way of living. We'll be in touch soon!`;
298
+
299
+ const formattedMessage = message
300
+ .replace(/\n/g, '<br/>')
301
+ .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
302
+ .replace(/• (.*?)(<br\/>|$)/g, '<div style="margin-left: 10px; margin-bottom: 5px;">• $1</div>');
303
+
304
+ const html = `
305
+ <!DOCTYPE html>
306
+ <html>
307
+ <head>
308
+ <meta charset="utf-8">
309
+ <style>
310
+ body { background-color: #faf9f6; margin: 0; padding: 0; font-family: 'Georgia', serif; }
311
+ .container { max-width: 600px; margin: 20px auto; background-color: #ffffff; border-radius: 40px; border: 1px solid #1a2e260d; overflow: hidden; }
312
+ .header { padding: 40px 0 20px 0; text-align: center; }
313
+ .logo { width: 180px; height: auto; }
314
+ .content { padding: 0 50px 40px 50px; color: #1a2e26; line-height: 1.8; font-size: 15px; }
315
+ .footer { padding: 40px 50px; text-align: center; font-family: sans-serif; font-size: 10px; color: #a1a1aa; letter-spacing: 1px; border-top: 1px solid #faf9f6; }
316
+ h1 { font-weight: normal; font-style: italic; font-size: 30px; margin-bottom: 30px; color: #1a2e26; text-align: center; }
317
+ .divider { height: 1px; width: 40px; background-color: #1a2e2620; margin: 30px auto; }
318
+ </style>
319
+ </head>
320
+ <body>
321
+ <div class="container">
322
+ <div class="header">
323
+ <img src="cid:botanics-logo" alt="Botanics & You" class="logo">
324
+ </div>
325
+ <div class="content">
326
+ <h1>${isDutch ? 'Welkom!' : 'Welcome!'}</h1>
327
+ ${formattedMessage}
328
+ <div class="divider"></div>
329
+ <p style="text-align: center; font-size: 12px; color: #a1a1aa;">
330
+ <a href="${unsubscribeUrl}" style="color: #a1a1aa; text-decoration: none;">
331
+ ${isDutch ? 'Afmelden' : 'Unsubscribe'}
332
+ </a>
333
+ </p>
334
+ </div>
335
+ <div class="footer">
336
+ © ${new Date().getFullYear()} Botanics & You. All rights reserved.
337
+ </div>
338
+ </div>
339
+ </body>
340
+ </html>
341
+ `;
342
+
343
+ await transporter.sendMail({
344
+ from: config.emailConfig.from,
345
+ to: email,
346
+ subject: isDutch ? 'Welkom bij Botanics & You!' : 'Welcome to Botanics & You!',
347
+ html,
348
+ });
349
+ } catch (error) {
350
+ console.error('[NewsletterAPI] Failed to send welcome email:', error);
351
+ // Don't throw - subscription should still succeed even if email fails
352
+ }
353
+ }
354
+
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Newsletter Plugin API Router
3
+ * Routes API requests to appropriate handlers
4
+ */
5
+
6
+ 'use server';
7
+
8
+ import { NextRequest, NextResponse } from 'next/server';
9
+ import { NewsletterApiConfig } from '../types/newsletter';
10
+ import {
11
+ GET_SUBSCRIBERS,
12
+ POST_SUBSCRIBE,
13
+ GET_SUBSCRIBER,
14
+ DELETE_SUBSCRIBER,
15
+ GET_SETTINGS,
16
+ POST_SETTINGS,
17
+ } from './handler';
18
+
19
+ /**
20
+ * Handle newsletter API requests
21
+ */
22
+ export async function handleNewsletterApi(
23
+ req: NextRequest,
24
+ path: string[],
25
+ config: NewsletterApiConfig
26
+ ): Promise<NextResponse> {
27
+ const method = req.method;
28
+ const route = path[0] || '';
29
+
30
+ try {
31
+ // Route: /api/plugin-newsletter/subscribers
32
+ if (route === 'subscribers') {
33
+ if (path[1]) {
34
+ // /api/plugin-newsletter/subscribers/[email]
35
+ const email = decodeURIComponent(path[1]).toLowerCase();
36
+ if (method === 'GET') {
37
+ return await GET_SUBSCRIBER(req, email, config);
38
+ }
39
+ if (method === 'DELETE') {
40
+ return await DELETE_SUBSCRIBER(req, email, config);
41
+ }
42
+ } else {
43
+ // /api/plugin-newsletter/subscribers
44
+ if (method === 'GET') {
45
+ return await GET_SUBSCRIBERS(req, config);
46
+ }
47
+ if (method === 'POST') {
48
+ return await POST_SUBSCRIBE(req, config);
49
+ }
50
+ }
51
+ }
52
+
53
+ // Route: /api/plugin-newsletter/settings
54
+ if (route === 'settings') {
55
+ if (method === 'GET') {
56
+ return await GET_SETTINGS(req, config);
57
+ }
58
+ if (method === 'POST' || method === 'PUT') {
59
+ return await POST_SETTINGS(req, config);
60
+ }
61
+ }
62
+
63
+ // Method not allowed
64
+ return NextResponse.json(
65
+ { error: `Method ${method} not allowed for route: ${route || '/'}` },
66
+ { status: 405 }
67
+ );
68
+ } catch (error: any) {
69
+ console.error('[NewsletterApiRouter] Error:', error);
70
+ return NextResponse.json(
71
+ { error: error.message || 'Internal server error' },
72
+ { status: 500 }
73
+ );
74
+ }
75
+ }
76
+
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Plugin Newsletter - Server-Only Entry Point
3
+ * This file exports only server-side API handlers
4
+ * Used by the dynamic plugin router via @jhits/plugin-newsletter/server
5
+ */
6
+
7
+ export { handleNewsletterApi as handleApi } from './api/router';
8
+ export { handleNewsletterApi } from './api/router'; // Keep original export for backward compatibility
9
+ export type { NewsletterApiConfig } from './types/newsletter';
10
+
package/src/index.tsx ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Plugin Newsletter - Client Entry Point
3
+ * Main newsletter management interface for the dashboard
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React from 'react';
9
+ import { SubscribersView } from './views/SubscribersView';
10
+ import { SettingsView } from './views/SettingsView';
11
+
12
+ export interface PluginProps {
13
+ subPath: string[];
14
+ siteId: string;
15
+ locale: string;
16
+ }
17
+
18
+ export default function NewsletterPlugin({ subPath, siteId, locale }: PluginProps) {
19
+ const route = subPath[0] || 'subscribers';
20
+
21
+ switch (route) {
22
+ case 'subscribers':
23
+ return <SubscribersView siteId={siteId} locale={locale} />;
24
+ case 'settings':
25
+ return <SettingsView siteId={siteId} locale={locale} />;
26
+ default:
27
+ return <SubscribersView siteId={siteId} locale={locale} />;
28
+ }
29
+ }
30
+
31
+ // Export for use as default
32
+ export { NewsletterPlugin as Index };
33
+
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Newsletter Plugin Types
3
+ */
4
+
5
+ export interface Subscriber {
6
+ _id?: string;
7
+ email: string;
8
+ language: string;
9
+ subscribedAt: Date | string;
10
+ unsubscribedAt?: Date | string;
11
+ status?: 'active' | 'unsubscribed';
12
+ }
13
+
14
+ export interface NewsletterSettings {
15
+ id: string;
16
+ languages: {
17
+ [key: string]: {
18
+ title: string;
19
+ message: string;
20
+ };
21
+ };
22
+ updatedAt?: Date;
23
+ }
24
+
25
+ export interface NewsletterApiConfig {
26
+ getDb: () => Promise<{ db: () => any }>;
27
+ getUserId?: (req: any) => Promise<string | null>;
28
+ emailConfig?: {
29
+ host: string;
30
+ port: number;
31
+ user: string;
32
+ password: string;
33
+ from: string;
34
+ };
35
+ baseUrl?: string;
36
+ [key: string]: any;
37
+ }
38
+
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Newsletter Settings View
3
+ * Interface for managing newsletter welcome email settings
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React, { useState, useEffect } from 'react';
9
+ import { Save, RefreshCw, Mail, Settings2, Globe } from 'lucide-react';
10
+ import { NewsletterSettings } from '../types/newsletter';
11
+
12
+ export interface SettingsViewProps {
13
+ siteId: string;
14
+ locale: string;
15
+ }
16
+
17
+ const SUPPORTED_LANGUAGES = ['nl', 'en', 'sv'];
18
+
19
+ export function SettingsView({ siteId, locale }: SettingsViewProps) {
20
+ const [isLoading, setIsLoading] = useState(true);
21
+ const [isSaving, setIsSaving] = useState(false);
22
+ const [showSuccess, setShowSuccess] = useState(false);
23
+ const [settings, setSettings] = useState<NewsletterSettings>({
24
+ id: 'welcome_automation',
25
+ languages: {
26
+ nl: { title: '', message: '' },
27
+ en: { title: '', message: '' },
28
+ sv: { title: '', message: '' },
29
+ },
30
+ });
31
+
32
+ // Fetch settings on load
33
+ useEffect(() => {
34
+ const fetchSettings = async () => {
35
+ try {
36
+ setIsLoading(true);
37
+ const response = await fetch('/api/plugin-newsletter/settings', {
38
+ credentials: 'include',
39
+ });
40
+ if (!response.ok) {
41
+ throw new Error('Failed to fetch settings');
42
+ }
43
+ const data = await response.json();
44
+ if (data.languages) {
45
+ setSettings({
46
+ id: data.id || 'welcome_automation',
47
+ languages: {
48
+ nl: data.languages.nl || { title: '', message: '' },
49
+ en: data.languages.en || { title: '', message: '' },
50
+ sv: data.languages.sv || { title: '', message: '' },
51
+ },
52
+ });
53
+ }
54
+ } catch (error) {
55
+ console.error('Failed to load settings:', error);
56
+ } finally {
57
+ setIsLoading(false);
58
+ }
59
+ };
60
+ fetchSettings();
61
+ }, []);
62
+
63
+ // Save settings
64
+ const handleSave = async () => {
65
+ try {
66
+ setIsSaving(true);
67
+ const response = await fetch('/api/plugin-newsletter/settings', {
68
+ method: 'POST',
69
+ headers: { 'Content-Type': 'application/json' },
70
+ credentials: 'include',
71
+ body: JSON.stringify({
72
+ id: 'welcome_automation',
73
+ languages: settings.languages,
74
+ }),
75
+ });
76
+
77
+ if (!response.ok) {
78
+ const error = await response.json();
79
+ throw new Error(error.error || 'Failed to save settings');
80
+ }
81
+
82
+ setShowSuccess(true);
83
+ setTimeout(() => setShowSuccess(false), 3000);
84
+ } catch (error: any) {
85
+ console.error('Failed to save settings:', error);
86
+ alert(error.message || 'Failed to save settings');
87
+ } finally {
88
+ setIsSaving(false);
89
+ }
90
+ };
91
+
92
+ // Update language setting
93
+ const updateLanguageSetting = (lang: string, field: 'title' | 'message', value: string) => {
94
+ setSettings(prev => ({
95
+ ...prev,
96
+ languages: {
97
+ ...prev.languages,
98
+ [lang]: {
99
+ ...prev.languages[lang],
100
+ [field]: value,
101
+ },
102
+ },
103
+ }));
104
+ };
105
+
106
+ if (isLoading) {
107
+ return (
108
+ <div className="h-full w-full bg-dashboard-card text-dashboard-text flex items-center justify-center">
109
+ <div className="text-center">
110
+ <RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-4" />
111
+ <p className="text-sm text-neutral-500 dark:text-neutral-400">Loading settings...</p>
112
+ </div>
113
+ </div>
114
+ );
115
+ }
116
+
117
+ return (
118
+ <div className="h-full w-full rounded-[2.5rem] bg-dashboard-card text-dashboard-text overflow-y-auto">
119
+ <div className="max-w-6xl mx-auto p-8">
120
+ {/* Header */}
121
+ <div className="flex items-center justify-between mb-8">
122
+ <div>
123
+ <h1 className="text-3xl font-black text-neutral-950 dark:text-white uppercase tracking-tighter mb-2">
124
+ Newsletter Settings
125
+ </h1>
126
+ <p className="text-sm text-neutral-500 dark:text-neutral-400">
127
+ Configure welcome emails for new subscribers
128
+ </p>
129
+ </div>
130
+ <button
131
+ onClick={handleSave}
132
+ disabled={isSaving}
133
+ className={`inline-flex items-center gap-2 px-6 py-3 rounded-full text-[10px] font-black uppercase tracking-widest transition-colors shadow-lg ${isSaving
134
+ ? 'bg-neutral-400 text-white cursor-not-allowed'
135
+ : showSuccess
136
+ ? 'bg-green-600 text-white'
137
+ : 'bg-primary text-white hover:bg-primary/90'
138
+ }`}
139
+ >
140
+ {isSaving ? (
141
+ <>
142
+ <RefreshCw className="w-4 h-4 animate-spin" />
143
+ Saving...
144
+ </>
145
+ ) : showSuccess ? (
146
+ <>
147
+ <Settings2 className="w-4 h-4" />
148
+ Saved!
149
+ </>
150
+ ) : (
151
+ <>
152
+ <Save className="w-4 h-4" />
153
+ Save Settings
154
+ </>
155
+ )}
156
+ </button>
157
+ </div>
158
+
159
+ {/* Welcome Email Settings */}
160
+ <section className="bg-dashboard-sidebar p-8 rounded-3xl border border-dashboard-border mb-8">
161
+ <div className="flex items-center gap-2 font-bold text-neutral-950 dark:text-white border-b border-dashboard-border pb-4 mb-6">
162
+ <Mail size={20} className="text-primary" />
163
+ Welcome Email Automation
164
+ </div>
165
+ <p className="text-sm text-neutral-500 dark:text-neutral-400 mb-8">
166
+ Configure the welcome email that will be sent automatically when someone subscribes to your newsletter.
167
+ </p>
168
+
169
+ {/* Language Tabs */}
170
+ <div className="space-y-8">
171
+ {SUPPORTED_LANGUAGES.map(lang => (
172
+ <div key={lang} className="bg-dashboard-card p-6 rounded-2xl border border-dashboard-border">
173
+ <div className="flex items-center gap-2 mb-6">
174
+ <Globe size={16} className="text-primary" />
175
+ <h3 className="text-lg font-black text-neutral-950 dark:text-white uppercase tracking-tight">
176
+ {lang.toUpperCase()} - {lang === 'nl' ? 'Dutch' : lang === 'en' ? 'English' : 'Swedish'}
177
+ </h3>
178
+ </div>
179
+ <div className="space-y-4">
180
+ <div>
181
+ <label className="text-xs font-bold text-neutral-500 dark:text-neutral-400 uppercase tracking-widest block mb-2">
182
+ Email Subject
183
+ </label>
184
+ <input
185
+ type="text"
186
+ value={settings.languages[lang]?.title || ''}
187
+ onChange={(e) => updateLanguageSetting(lang, 'title', e.target.value)}
188
+ placeholder={`Welcome email subject (${lang.toUpperCase()})`}
189
+ className="w-full px-4 py-3 bg-dashboard-bg border border-dashboard-border rounded-xl outline-none focus:ring-2 focus:ring-primary transition-colors text-dashboard-text"
190
+ />
191
+ </div>
192
+ <div>
193
+ <label className="text-xs font-bold text-neutral-500 dark:text-neutral-400 uppercase tracking-widest block mb-2">
194
+ Email Message
195
+ </label>
196
+ <textarea
197
+ value={settings.languages[lang]?.message || ''}
198
+ onChange={(e) => updateLanguageSetting(lang, 'message', e.target.value)}
199
+ placeholder={`Welcome email message (${lang.toUpperCase()}). Use **bold** for bold text and • for bullet points.`}
200
+ rows={8}
201
+ className="w-full px-4 py-3 bg-dashboard-bg border border-dashboard-border rounded-xl outline-none focus:ring-2 focus:ring-primary transition-colors text-dashboard-text resize-none font-mono text-sm"
202
+ />
203
+ <p className="text-[10px] text-neutral-500 dark:text-neutral-400 mt-2">
204
+ Supports markdown: **bold**, • bullet points, line breaks
205
+ </p>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ ))}
210
+ </div>
211
+ </section>
212
+ </div>
213
+ </div>
214
+ );
215
+ }
216
+
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Newsletter Subscribers View
3
+ * Main interface for managing newsletter subscribers
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React, { useState, useEffect } from 'react';
9
+ import { Mail, Copy, Check, Trash2, Filter, Users, Globe, Calendar } from 'lucide-react';
10
+ import { Subscriber } from '../types/newsletter';
11
+
12
+ export interface SubscribersViewProps {
13
+ siteId: string;
14
+ locale: string;
15
+ }
16
+
17
+ export function SubscribersView({ siteId, locale }: SubscribersViewProps) {
18
+ const [subscribers, setSubscribers] = useState<Subscriber[]>([]);
19
+ const [isLoading, setIsLoading] = useState(true);
20
+ const [filter, setFilter] = useState<string>('all');
21
+ const [copyStatus, setCopyStatus] = useState(false);
22
+ const [deleteStatus, setDeleteStatus] = useState<string | null>(null);
23
+
24
+ // Fetch subscribers on load
25
+ useEffect(() => {
26
+ const fetchSubscribers = async () => {
27
+ try {
28
+ setIsLoading(true);
29
+ const response = await fetch('/api/plugin-newsletter/subscribers', {
30
+ credentials: 'include',
31
+ });
32
+ if (!response.ok) {
33
+ throw new Error('Failed to fetch subscribers');
34
+ }
35
+ const data = await response.json();
36
+ setSubscribers(Array.isArray(data) ? data : []);
37
+ } catch (error) {
38
+ console.error('Failed to load subscribers:', error);
39
+ } finally {
40
+ setIsLoading(false);
41
+ }
42
+ };
43
+ fetchSubscribers();
44
+ }, []);
45
+
46
+ // Filter subscribers
47
+ const filteredSubscribers = filter === 'all'
48
+ ? subscribers
49
+ : subscribers.filter(s => s.language === filter);
50
+
51
+ // Get unique languages
52
+ const languages = Array.from(new Set(subscribers.map(s => s.language))).sort();
53
+
54
+ // Copy emails to clipboard
55
+ const handleCopyEmails = async () => {
56
+ const emails = filteredSubscribers.map(s => s.email).join(', ');
57
+ try {
58
+ await navigator.clipboard.writeText(emails);
59
+ setCopyStatus(true);
60
+ setTimeout(() => setCopyStatus(false), 2000);
61
+ } catch (error) {
62
+ console.error('Failed to copy emails:', error);
63
+ }
64
+ };
65
+
66
+ // Delete subscriber
67
+ const handleDeleteSubscriber = async (email: string) => {
68
+ if (!confirm(`Are you sure you want to remove ${email} from the newsletter?`)) {
69
+ return;
70
+ }
71
+
72
+ try {
73
+ setDeleteStatus(email);
74
+ const response = await fetch(`/api/plugin-newsletter/subscribers/${encodeURIComponent(email)}`, {
75
+ method: 'DELETE',
76
+ credentials: 'include',
77
+ });
78
+
79
+ if (!response.ok) {
80
+ const error = await response.json();
81
+ throw new Error(error.error || 'Failed to delete subscriber');
82
+ }
83
+
84
+ // Remove from local state
85
+ setSubscribers(prev => prev.filter(sub => sub.email !== email));
86
+ } catch (error: any) {
87
+ console.error('Failed to delete subscriber:', error);
88
+ alert(error.message || 'Failed to delete subscriber');
89
+ } finally {
90
+ setDeleteStatus(null);
91
+ }
92
+ };
93
+
94
+ // Format date
95
+ const formatDate = (dateString: string | Date | undefined) => {
96
+ if (!dateString) return 'N/A';
97
+ const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
98
+ return date.toLocaleDateString(locale, {
99
+ day: 'numeric',
100
+ month: 'short',
101
+ year: 'numeric',
102
+ });
103
+ };
104
+
105
+ return (
106
+ <div className="h-full w-full rounded-[2.5rem] bg-dashboard-card text-dashboard-text overflow-y-auto">
107
+ <div className="max-w-7xl mx-auto p-8">
108
+ {/* Header */}
109
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
110
+ <div>
111
+ <h1 className="text-3xl font-black text-neutral-950 dark:text-white uppercase tracking-tighter mb-2">
112
+ Newsletter Subscribers
113
+ </h1>
114
+ <p className="text-sm text-neutral-500 dark:text-neutral-400">
115
+ Manage your newsletter subscribers and their preferences
116
+ </p>
117
+ </div>
118
+
119
+ <div className="flex items-center gap-3">
120
+ {/* Language Filter */}
121
+ <div className="flex items-center bg-dashboard-sidebar border border-dashboard-border rounded-xl px-4 py-2.5 shadow-sm">
122
+ <Filter size={16} className="text-primary mr-2" />
123
+ <select
124
+ value={filter}
125
+ onChange={(e) => setFilter(e.target.value)}
126
+ className="bg-transparent text-xs font-bold text-dashboard-text outline-none cursor-pointer uppercase tracking-widest"
127
+ >
128
+ <option value="all">All Languages</option>
129
+ {languages.map(lang => (
130
+ <option key={lang} value={lang}>{lang.toUpperCase()}</option>
131
+ ))}
132
+ </select>
133
+ </div>
134
+
135
+ {/* Copy Emails Button */}
136
+ <button
137
+ onClick={handleCopyEmails}
138
+ disabled={filteredSubscribers.length === 0}
139
+ className="inline-flex items-center gap-2 px-6 py-3 rounded-full text-[10px] font-black uppercase tracking-widest transition-colors shadow-lg disabled:opacity-30 disabled:cursor-not-allowed bg-primary text-white hover:bg-primary/90"
140
+ >
141
+ {copyStatus ? (
142
+ <>
143
+ <Check size={14} />
144
+ Copied!
145
+ </>
146
+ ) : (
147
+ <>
148
+ <Copy size={14} />
149
+ Copy List
150
+ </>
151
+ )}
152
+ </button>
153
+ </div>
154
+ </div>
155
+
156
+ {/* Stats */}
157
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
158
+ <div className="bg-dashboard-sidebar p-6 rounded-3xl border border-dashboard-border">
159
+ <div className="flex items-center gap-3 mb-2">
160
+ <Users size={20} className="text-primary" />
161
+ <span className="text-[10px] uppercase tracking-widest text-neutral-500 dark:text-neutral-400 font-black">
162
+ Total Subscribers
163
+ </span>
164
+ </div>
165
+ <p className="text-3xl font-black text-neutral-950 dark:text-white">
166
+ {subscribers.length}
167
+ </p>
168
+ </div>
169
+ <div className="bg-dashboard-sidebar p-6 rounded-3xl border border-dashboard-border">
170
+ <div className="flex items-center gap-3 mb-2">
171
+ <Globe size={20} className="text-primary" />
172
+ <span className="text-[10px] uppercase tracking-widest text-neutral-500 dark:text-neutral-400 font-black">
173
+ Languages
174
+ </span>
175
+ </div>
176
+ <p className="text-3xl font-black text-neutral-950 dark:text-white">
177
+ {languages.length}
178
+ </p>
179
+ </div>
180
+ <div className="bg-dashboard-sidebar p-6 rounded-3xl border border-dashboard-border">
181
+ <div className="flex items-center gap-3 mb-2">
182
+ <Filter size={20} className="text-primary" />
183
+ <span className="text-[10px] uppercase tracking-widest text-neutral-500 dark:text-neutral-400 font-black">
184
+ Filtered
185
+ </span>
186
+ </div>
187
+ <p className="text-3xl font-black text-neutral-950 dark:text-white">
188
+ {filteredSubscribers.length}
189
+ </p>
190
+ </div>
191
+ </div>
192
+
193
+ {/* Subscribers Table */}
194
+ <div className="bg-dashboard-sidebar rounded-3xl border border-dashboard-border shadow-sm overflow-hidden">
195
+ {isLoading ? (
196
+ <div className="flex items-center justify-center py-20">
197
+ <div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
198
+ </div>
199
+ ) : filteredSubscribers.length === 0 ? (
200
+ <div className="py-24 text-center">
201
+ <Users size={64} className="mx-auto text-neutral-300 dark:text-neutral-700 mb-4" />
202
+ <p className="text-neutral-500 dark:text-neutral-400 font-serif italic text-lg">
203
+ {filter === 'all' ? 'No subscribers yet.' : `No subscribers found for ${filter.toUpperCase()}.`}
204
+ </p>
205
+ </div>
206
+ ) : (
207
+ <div className="overflow-x-auto">
208
+ <table className="w-full text-left border-collapse">
209
+ <thead>
210
+ <tr className="bg-dashboard-bg text-dashboard-text text-[10px] uppercase tracking-[0.2em] font-black border-b border-dashboard-border">
211
+ <th className="px-8 py-5">Subscriber</th>
212
+ <th className="px-8 py-5">Language</th>
213
+ <th className="px-8 py-5 text-right">Subscribed</th>
214
+ <th className="px-8 py-5 text-right">Actions</th>
215
+ </tr>
216
+ </thead>
217
+ <tbody className="divide-y divide-dashboard-border">
218
+ {filteredSubscribers.map((subscriber, idx) => (
219
+ <tr
220
+ key={idx}
221
+ className="hover:bg-dashboard-bg transition-colors group"
222
+ >
223
+ <td className="px-8 py-5">
224
+ <div className="flex items-center gap-4">
225
+ <div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary group-hover:bg-primary group-hover:text-white transition-colors">
226
+ <Mail size={18} />
227
+ </div>
228
+ <span className="text-sm font-medium text-dashboard-text tracking-tight">
229
+ {subscriber.email}
230
+ </span>
231
+ </div>
232
+ </td>
233
+ <td className="px-8 py-5">
234
+ <span className="text-[10px] font-black px-3 py-1 bg-primary/10 rounded-full uppercase text-primary border border-primary/20">
235
+ {subscriber.language}
236
+ </span>
237
+ </td>
238
+ <td className="px-8 py-5 text-right text-xs text-neutral-500 dark:text-neutral-400 font-medium">
239
+ <div className="flex items-center justify-end gap-2">
240
+ <Calendar size={14} />
241
+ {formatDate(subscriber.subscribedAt)}
242
+ </div>
243
+ </td>
244
+ <td className="px-8 py-5 text-right">
245
+ <button
246
+ onClick={() => handleDeleteSubscriber(subscriber.email)}
247
+ disabled={deleteStatus === subscriber.email}
248
+ className="p-2.5 rounded-full text-neutral-400 dark:text-neutral-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
249
+ title="Remove subscriber"
250
+ >
251
+ {deleteStatus === subscriber.email ? (
252
+ <div className="w-5 h-5 border-2 border-red-500 border-t-transparent rounded-full animate-spin" />
253
+ ) : (
254
+ <Trash2 size={18} />
255
+ )}
256
+ </button>
257
+ </td>
258
+ </tr>
259
+ ))}
260
+ </tbody>
261
+ </table>
262
+ </div>
263
+ )}
264
+ </div>
265
+ </div>
266
+ </div>
267
+ );
268
+ }
269
+