@jhits/plugin-newsletter 0.0.4 → 0.0.5

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.
Files changed (44) hide show
  1. package/package.json +26 -25
  2. package/src/api/handler.ts +340 -1
  3. package/src/api/router.ts +35 -0
  4. package/src/index.tsx +284 -4
  5. package/src/index.tsx.patch +98 -0
  6. package/src/init.tsx +72 -0
  7. package/src/lib/blocks/BlockRenderer.tsx +125 -0
  8. package/src/lib/email/EmailRenderer.tsx +425 -0
  9. package/src/lib/email/index.ts +6 -0
  10. package/src/lib/mappers/apiMapper.ts +57 -0
  11. package/src/lib/utils/blockHelpers.ts +71 -0
  12. package/src/lib/utils/slugify.ts +43 -0
  13. package/src/registry/BlockRegistry.ts +53 -0
  14. package/src/registry/index.ts +5 -0
  15. package/src/state/EditorContext.tsx +279 -0
  16. package/src/state/index.ts +10 -0
  17. package/src/state/reducer.ts +561 -0
  18. package/src/state/types.ts +154 -0
  19. package/src/types/block.ts +275 -0
  20. package/src/types/newsletter.ts +114 -1
  21. package/src/types/registry.ts +14 -0
  22. package/src/views/CanvasEditor/BlockWrapper.tsx +143 -0
  23. package/src/views/CanvasEditor/CanvasEditorView.tsx +249 -0
  24. package/src/views/CanvasEditor/EditorBody.tsx +95 -0
  25. package/src/views/CanvasEditor/EditorHeader.tsx +139 -0
  26. package/src/views/CanvasEditor/components/CustomBlockItem.tsx +83 -0
  27. package/src/views/CanvasEditor/components/EditorCanvas.tsx +674 -0
  28. package/src/views/CanvasEditor/components/EditorLibrary.tsx +120 -0
  29. package/src/views/CanvasEditor/components/EditorSidebar.tsx +156 -0
  30. package/src/views/CanvasEditor/components/ErrorBanner.tsx +31 -0
  31. package/src/views/CanvasEditor/components/LibraryItem.tsx +71 -0
  32. package/src/views/CanvasEditor/components/SlashCommandDetector.tsx +196 -0
  33. package/src/views/CanvasEditor/components/SlashCommandMenu.tsx +131 -0
  34. package/src/views/CanvasEditor/components/index.ts +16 -0
  35. package/src/views/CanvasEditor/hooks/index.ts +7 -0
  36. package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +136 -0
  37. package/src/views/CanvasEditor/hooks/useNewsletterLoader.ts +34 -0
  38. package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +54 -0
  39. package/src/views/CanvasEditor/hooks/useSlashCommand.ts +106 -0
  40. package/src/views/CanvasEditor/index.ts +12 -0
  41. package/src/views/NewsletterEditor.tsx +38 -0
  42. package/src/views/NewsletterManager.tsx +240 -0
  43. package/src/views/SettingsView.tsx +14 -14
  44. package/src/views/SubscribersView.tsx +20 -20
package/package.json CHANGED
@@ -1,29 +1,28 @@
1
1
  {
2
2
  "name": "@jhits/plugin-newsletter",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Newsletter management and email delivery plugin for the JHITS ecosystem",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
8
- "main": "./src/index.tsx",
9
- "types": "./src/index.tsx",
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
10
  "exports": {
11
11
  ".": {
12
- "types": "./src/index.tsx",
13
- "default": "./src/index.tsx"
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
14
  },
15
15
  "./server": {
16
- "types": "./src/index.server.ts",
17
- "default": "./src/index.server.ts"
16
+ "types": "./dist/index.server.d.ts",
17
+ "default": "./dist/index.server.js"
18
18
  }
19
19
  },
20
20
  "dependencies": {
21
- "@jhits/plugin-core": "^0.0.1",
22
- "lucide-react": "^0.562.0",
23
- "mongodb": "^7.0.0",
21
+ "@jhits/plugin-core": "^0.0.2",
22
+ "lucide-react": "^0.564.0",
23
+ "mongodb": "^7.1.0",
24
24
  "next-auth": "^4.24.13",
25
- "nodemailer": "^7.0.12",
26
- "server-only": "^0.0.1"
25
+ "nodemailer": "^8.0.1"
27
26
  },
28
27
  "peerDependencies": {
29
28
  "next": ">=15.0.0",
@@ -31,19 +30,21 @@
31
30
  "react-dom": ">=18.0.0"
32
31
  },
33
32
  "devDependencies": {
34
- "@types/node": "^20.19.27",
35
- "@types/nodemailer": "^7.0.4",
36
- "@types/react": "^19",
37
- "@types/react-dom": "^19",
38
- "next": "16.1.1",
39
- "react": "19.2.3",
40
- "react-dom": "19.2.3",
41
- "typescript": "^5"
33
+ "@types/node": "^25.2.3",
34
+ "@types/nodemailer": "^7.0.9",
35
+ "@types/react": "^19.2.14",
36
+ "@types/react-dom": "^19.2.3",
37
+ "next": "16.1.6",
38
+ "react": "19.2.4",
39
+ "react-dom": "19.2.4",
40
+ "typescript": "^5.9.3"
42
41
  },
43
42
  "files": [
44
- "src/**/*.{ts,tsx,json}",
45
- "!src/**/*.md",
46
- "!src/**/README.md",
43
+ "dist",
44
+ "src",
47
45
  "package.json"
48
- ]
49
- }
46
+ ],
47
+ "scripts": {
48
+ "build": "tsc"
49
+ }
50
+ }
@@ -6,7 +6,8 @@
6
6
  'use server';
7
7
 
8
8
  import { NextRequest, NextResponse } from 'next/server';
9
- import { NewsletterApiConfig } from '../types/newsletter';
9
+ import { NewsletterApiConfig, Newsletter, NewsletterListItem } from '../types/newsletter';
10
+ import { generateSlugFromTitle } from '../lib/utils/slugify';
10
11
  import nodemailer from 'nodemailer';
11
12
 
12
13
  /**
@@ -352,3 +353,341 @@ async function sendWelcomeEmail(
352
353
  }
353
354
  }
354
355
 
356
+ /**
357
+ * GET /api/plugin-newsletter/newsletters - List all newsletters
358
+ */
359
+ export async function GET_NEWSLETTERS(
360
+ req: NextRequest,
361
+ config: NewsletterApiConfig
362
+ ): Promise<NextResponse> {
363
+ try {
364
+ const userId = await config.getUserId?.(req);
365
+ if (!userId) {
366
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
367
+ }
368
+
369
+ const dbConnection = await config.getDb();
370
+ const db = dbConnection.db();
371
+ const collectionName = config.collectionName || 'newsletters';
372
+ const newsletters = db.collection(collectionName);
373
+
374
+ // Get query parameters
375
+ const { searchParams } = new URL(req.url);
376
+ const status = searchParams.get('status');
377
+ const limit = parseInt(searchParams.get('limit') || '50', 10);
378
+ const skip = parseInt(searchParams.get('skip') || '0', 10);
379
+ const sortBy = searchParams.get('sortBy') || 'updatedAt';
380
+ const sortOrder = searchParams.get('sortOrder') || 'desc';
381
+
382
+ // Build query
383
+ const query: any = {};
384
+ if (status) {
385
+ query['publication.status'] = status;
386
+ }
387
+
388
+ // Build sort
389
+ const sort: any = {};
390
+ sort[sortBy] = sortOrder === 'asc' ? 1 : -1;
391
+
392
+ const newsletterList = await newsletters
393
+ .find(query)
394
+ .sort(sort)
395
+ .limit(limit)
396
+ .skip(skip)
397
+ .toArray();
398
+
399
+ // Convert to list items
400
+ const listItems: NewsletterListItem[] = newsletterList.map((newsletter: any) => ({
401
+ id: newsletter._id?.toString() || newsletter.id,
402
+ title: newsletter.title,
403
+ slug: newsletter.slug,
404
+ status: newsletter.publication?.status || 'draft',
405
+ subject: newsletter.metadata?.subject || '',
406
+ scheduledDate: newsletter.publication?.scheduledDate,
407
+ sentDate: newsletter.publication?.sentDate,
408
+ authorId: newsletter.publication?.authorId,
409
+ updatedAt: newsletter.updatedAt || newsletter.createdAt,
410
+ recipientCount: newsletter.recipientCount,
411
+ }));
412
+
413
+ return NextResponse.json(listItems);
414
+ } catch (error: any) {
415
+ console.error('[NewsletterAPI] GET_NEWSLETTERS error:', error);
416
+ return NextResponse.json(
417
+ { error: 'Failed to fetch newsletters', detail: error.message },
418
+ { status: 500 }
419
+ );
420
+ }
421
+ }
422
+
423
+ /**
424
+ * GET /api/plugin-newsletter/newsletters/[slug] - Get specific newsletter
425
+ */
426
+ export async function GET_NEWSLETTER(
427
+ req: NextRequest,
428
+ slug: string,
429
+ config: NewsletterApiConfig
430
+ ): Promise<NextResponse> {
431
+ try {
432
+ const userId = await config.getUserId?.(req);
433
+ if (!userId) {
434
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
435
+ }
436
+
437
+ const dbConnection = await config.getDb();
438
+ const db = dbConnection.db();
439
+ const collectionName = config.collectionName || 'newsletters';
440
+ const newsletters = db.collection(collectionName);
441
+
442
+ const newsletter = await newsletters.findOne({ slug });
443
+ if (!newsletter) {
444
+ return NextResponse.json(
445
+ { error: 'Newsletter not found' },
446
+ { status: 404 }
447
+ );
448
+ }
449
+
450
+ // Convert MongoDB document to Newsletter format
451
+ const result: Newsletter = {
452
+ id: newsletter._id?.toString() || newsletter.id,
453
+ title: newsletter.title,
454
+ slug: newsletter.slug,
455
+ blocks: newsletter.blocks || [],
456
+ metadata: newsletter.metadata || {
457
+ subject: '',
458
+ previewText: '',
459
+ lang: 'en',
460
+ recipientFilter: { type: 'all' },
461
+ },
462
+ publication: newsletter.publication || {
463
+ status: 'draft',
464
+ updatedAt: new Date().toISOString(),
465
+ },
466
+ createdAt: newsletter.createdAt || new Date().toISOString(),
467
+ updatedAt: newsletter.updatedAt || new Date().toISOString(),
468
+ version: newsletter.version,
469
+ };
470
+
471
+ return NextResponse.json(result);
472
+ } catch (error: any) {
473
+ console.error('[NewsletterAPI] GET_NEWSLETTER error:', error);
474
+ return NextResponse.json(
475
+ { error: 'Failed to fetch newsletter', detail: error.message },
476
+ { status: 500 }
477
+ );
478
+ }
479
+ }
480
+
481
+ /**
482
+ * POST /api/plugin-newsletter/newsletters/new - Create new newsletter
483
+ */
484
+ export async function POST_NEWSLETTER(
485
+ req: NextRequest,
486
+ config: NewsletterApiConfig
487
+ ): Promise<NextResponse> {
488
+ try {
489
+ const userId = await config.getUserId?.(req);
490
+ if (!userId) {
491
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
492
+ }
493
+
494
+ const body = await req.json();
495
+ const { title, blocks, metadata, publication } = body;
496
+
497
+ // Validation
498
+ const errors: string[] = [];
499
+ if (!title || typeof title !== 'string' || title.trim().length === 0) {
500
+ errors.push('Title is required');
501
+ }
502
+ if (!metadata?.subject || typeof metadata.subject !== 'string' || metadata.subject.trim().length === 0) {
503
+ errors.push('Subject is required');
504
+ }
505
+
506
+ if (errors.length > 0) {
507
+ return NextResponse.json({ message: errors[0], allErrors: errors }, { status: 400 });
508
+ }
509
+
510
+ const dbConnection = await config.getDb();
511
+ const db = dbConnection.db();
512
+ const collectionName = config.collectionName || 'newsletters';
513
+ const newsletters = db.collection(collectionName);
514
+
515
+ // Get existing slugs for collision check
516
+ const existingNewsletters = await newsletters.find({}, { projection: { slug: 1 } }).toArray();
517
+ const existingSlugs = existingNewsletters.map((n: any) => n.slug).filter(Boolean);
518
+
519
+ // Use subject as title if title is empty
520
+ const finalTitle = (title?.trim() || metadata?.subject?.trim() || '').trim();
521
+
522
+ // Generate slug
523
+ const slug = generateSlugFromTitle(finalTitle, existingSlugs);
524
+
525
+ const newsletterDocument = {
526
+ title: finalTitle,
527
+ slug,
528
+ blocks: blocks || [],
529
+ metadata: {
530
+ subject: metadata.subject.trim(),
531
+ previewText: metadata.previewText?.trim() || '',
532
+ lang: metadata.lang || 'en',
533
+ recipientFilter: metadata.recipientFilter || { type: 'all' },
534
+ },
535
+ publication: {
536
+ status: publication?.status || 'draft',
537
+ scheduledDate: publication?.scheduledDate,
538
+ authorId: userId,
539
+ updatedAt: new Date().toISOString(),
540
+ },
541
+ createdAt: new Date(),
542
+ updatedAt: new Date(),
543
+ version: 1,
544
+ };
545
+
546
+ const result = await newsletters.insertOne(newsletterDocument);
547
+
548
+ return NextResponse.json({
549
+ message: 'Newsletter created successfully',
550
+ id: result.insertedId.toString(),
551
+ slug,
552
+ }, { status: 201 });
553
+ } catch (error: any) {
554
+ console.error('[NewsletterAPI] POST_NEWSLETTER error:', error);
555
+ return NextResponse.json(
556
+ { error: 'Failed to create newsletter', detail: error.message },
557
+ { status: 500 }
558
+ );
559
+ }
560
+ }
561
+
562
+ /**
563
+ * PUT /api/plugin-newsletter/newsletters/[slug] - Update existing newsletter
564
+ */
565
+ export async function PUT_NEWSLETTER(
566
+ req: NextRequest,
567
+ slug: string,
568
+ config: NewsletterApiConfig
569
+ ): Promise<NextResponse> {
570
+ try {
571
+ const userId = await config.getUserId?.(req);
572
+ if (!userId) {
573
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
574
+ }
575
+
576
+ const body = await req.json();
577
+ const { title, blocks, metadata, publication } = body;
578
+
579
+ // Validation
580
+ // For newsletters, subject is required and can serve as title
581
+ const errors: string[] = [];
582
+ if (!metadata?.subject || typeof metadata.subject !== 'string' || metadata.subject.trim().length === 0) {
583
+ errors.push('Subject is required');
584
+ }
585
+
586
+ // Use subject as title if title is empty (for newsletters, subject is the primary identifier)
587
+ const finalTitle = (title?.trim() || metadata?.subject?.trim() || '').trim();
588
+ if (!finalTitle) {
589
+ errors.push('Title is required (use subject if no title is provided)');
590
+ }
591
+
592
+ if (errors.length > 0) {
593
+ return NextResponse.json({ message: errors[0], allErrors: errors }, { status: 400 });
594
+ }
595
+
596
+ const dbConnection = await config.getDb();
597
+ const db = dbConnection.db();
598
+ const collectionName = config.collectionName || 'newsletters';
599
+ const newsletters = db.collection(collectionName);
600
+
601
+ // Check if newsletter exists
602
+ const existing = await newsletters.findOne({ slug });
603
+ if (!existing) {
604
+ return NextResponse.json(
605
+ { error: 'Newsletter not found' },
606
+ { status: 404 }
607
+ );
608
+ }
609
+
610
+ // Generate new slug if title changed
611
+ let newSlug = slug;
612
+ if (finalTitle !== existing.title) {
613
+ const existingNewsletters = await newsletters.find({ _id: { $ne: existing._id } }, { projection: { slug: 1 } }).toArray();
614
+ const existingSlugs = existingNewsletters.map((n: any) => n.slug).filter(Boolean);
615
+ newSlug = generateSlugFromTitle(finalTitle, existingSlugs);
616
+ }
617
+
618
+ // Update newsletter
619
+ const updateData: any = {
620
+ title: finalTitle,
621
+ slug: newSlug,
622
+ blocks: blocks || [],
623
+ metadata: {
624
+ subject: metadata.subject.trim(),
625
+ previewText: metadata.previewText?.trim() || '',
626
+ lang: metadata.lang || 'en',
627
+ recipientFilter: metadata.recipientFilter || { type: 'all' },
628
+ },
629
+ publication: {
630
+ ...existing.publication,
631
+ status: publication?.status || existing.publication?.status || 'draft',
632
+ scheduledDate: publication?.scheduledDate,
633
+ authorId: userId,
634
+ updatedAt: new Date().toISOString(),
635
+ },
636
+ updatedAt: new Date(),
637
+ version: (existing.version || 1) + 1,
638
+ };
639
+
640
+ await newsletters.updateOne(
641
+ { slug },
642
+ { $set: updateData }
643
+ );
644
+
645
+ return NextResponse.json({
646
+ message: 'Newsletter updated successfully',
647
+ slug: newSlug,
648
+ });
649
+ } catch (error: any) {
650
+ console.error('[NewsletterAPI] PUT_NEWSLETTER error:', error);
651
+ return NextResponse.json(
652
+ { error: 'Failed to update newsletter', detail: error.message },
653
+ { status: 500 }
654
+ );
655
+ }
656
+ }
657
+
658
+ /**
659
+ * DELETE /api/plugin-newsletter/newsletters/[slug] - Delete newsletter
660
+ */
661
+ export async function DELETE_NEWSLETTER(
662
+ req: NextRequest,
663
+ slug: string,
664
+ config: NewsletterApiConfig
665
+ ): Promise<NextResponse> {
666
+ try {
667
+ const userId = await config.getUserId?.(req);
668
+ if (!userId) {
669
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
670
+ }
671
+
672
+ const dbConnection = await config.getDb();
673
+ const db = dbConnection.db();
674
+ const collectionName = config.collectionName || 'newsletters';
675
+ const newsletters = db.collection(collectionName);
676
+
677
+ const result = await newsletters.deleteOne({ slug });
678
+ if (result.deletedCount === 0) {
679
+ return NextResponse.json(
680
+ { error: 'Newsletter not found' },
681
+ { status: 404 }
682
+ );
683
+ }
684
+
685
+ return NextResponse.json({ message: 'Newsletter deleted successfully' });
686
+ } catch (error: any) {
687
+ console.error('[NewsletterAPI] DELETE_NEWSLETTER error:', error);
688
+ return NextResponse.json(
689
+ { error: 'Failed to delete newsletter', detail: error.message },
690
+ { status: 500 }
691
+ );
692
+ }
693
+ }
package/src/api/router.ts CHANGED
@@ -14,6 +14,11 @@ import {
14
14
  DELETE_SUBSCRIBER,
15
15
  GET_SETTINGS,
16
16
  POST_SETTINGS,
17
+ GET_NEWSLETTERS,
18
+ GET_NEWSLETTER,
19
+ POST_NEWSLETTER,
20
+ PUT_NEWSLETTER,
21
+ DELETE_NEWSLETTER,
17
22
  } from './handler';
18
23
 
19
24
  /**
@@ -60,6 +65,36 @@ export async function handleNewsletterApi(
60
65
  }
61
66
  }
62
67
 
68
+ // Route: /api/plugin-newsletter/newsletters
69
+ if (route === 'newsletters') {
70
+ // Special route: /api/plugin-newsletter/newsletters/new
71
+ if (path[1] === 'new' && method === 'POST') {
72
+ return await POST_NEWSLETTER(req, config);
73
+ }
74
+
75
+ if (path[1]) {
76
+ // /api/plugin-newsletter/newsletters/[slug]
77
+ const newsletterSlug = decodeURIComponent(path[1]);
78
+ if (method === 'GET') {
79
+ return await GET_NEWSLETTER(req, newsletterSlug, config);
80
+ }
81
+ if (method === 'PUT') {
82
+ return await PUT_NEWSLETTER(req, newsletterSlug, config);
83
+ }
84
+ if (method === 'DELETE') {
85
+ return await DELETE_NEWSLETTER(req, newsletterSlug, config);
86
+ }
87
+ } else {
88
+ // /api/plugin-newsletter/newsletters
89
+ if (method === 'GET') {
90
+ return await GET_NEWSLETTERS(req, config);
91
+ }
92
+ if (method === 'POST') {
93
+ return await POST_NEWSLETTER(req, config);
94
+ }
95
+ }
96
+ }
97
+
63
98
  // Method not allowed
64
99
  return NextResponse.json(
65
100
  { error: `Method ${method} not allowed for route: ${route || '/'}` },