From 37680bd25ba77a1bc623b41573d59a769a8cd51a Mon Sep 17 00:00:00 2001 From: dwindown Date: Mon, 22 Dec 2025 20:35:50 +0700 Subject: [PATCH] Polish email template system with UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Consolidated multiple preview canvases into single shared preview with "Simpan & Preview" button - Fixed double scrollbar issue in preview box by using fixed height container and scrolling=no - Added modular email components to Tiptap editor: * EmailButton with URL, text, and full-width options * OTPBox with monospace font and dashed border styling * EmailTable with brutalist styling and proper header support - Generated contextual initial email content for all template types: * Payment success with professional details table * Access granted with celebration styling and prominent CTA * Order created with clear next steps and status information * Payment reminder with urgent styling and warning alerts * Consulting scheduled with session details and preparation tips * Event reminder with high-energy countdown and call-to-action * Bootcamp progress with motivational progress tracking - Enhanced RichTextEditor toolbar with email component buttons and visual separators - Improved NotifikasiTab with streamlined preview workflow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/components/RichTextEditor.tsx | 363 ++++++++++++++++- src/components/admin/EmailTemplatePreview.tsx | 10 +- .../admin/settings/NotifikasiTab.tsx | 365 ++++++++++++++++-- 3 files changed, 697 insertions(+), 41 deletions(-) diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 3449a6d..eb746f8 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -3,11 +3,16 @@ import StarterKit from '@tiptap/starter-kit'; import Link from '@tiptap/extension-link'; import Image from '@tiptap/extension-image'; import Placeholder from '@tiptap/extension-placeholder'; +import { Node } from '@tiptap/core'; +import Table from '@tiptap/extension-table'; +import TableRow from '@tiptap/extension-table-row'; +import TableCell from '@tiptap/extension-table-cell'; +import TableHeader from '@tiptap/extension-table-header'; import { Button } from '@/components/ui/button'; -import { - Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon, +import { + Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon, Image as ImageIcon, Heading1, Heading2, Undo, Redo, - Maximize2, Minimize2 + Maximize2, Minimize2, MousePointer, Square, Table as TableIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useCallback, useEffect, useState } from 'react'; @@ -49,6 +54,247 @@ const ResizableImage = Image.extend({ }, }); +// Custom Button extension for email templates +const EmailButton = Node.create({ + name: 'emailButton', + + group: 'block', + + addAttributes() { + return { + url: { + default: '#', + parseHTML: element => element.getAttribute('data-url') || '#', + renderHTML: attributes => ({ + 'data-url': attributes.url, + }), + }, + text: { + default: 'Button', + parseHTML: element => element.textContent || 'Button', + renderHTML: attributes => ({}), + }, + fullWidth: { + default: false, + parseHTML: element => element.classList.contains('btn-full'), + renderHTML: attributes => ({ + class: attributes.fullWidth ? 'btn btn-full' : 'btn', + }), + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'div[data-email-button]', + }, + ]; + }, + + renderHTML({ HTMLAttributes, node }) { + const { url, text, fullWidth } = node.attrs; + return [ + 'p', + { style: 'margin-top: 20px; text-align: center;' }, + [ + 'a', + { + href: url, + class: fullWidth ? 'btn btn-full' : 'btn', + 'data-email-button': '', + style: ` + display: inline-block; + background-color: #000; + color: #FFF !important; + padding: 14px 28px; + font-weight: 700; + text-transform: uppercase; + text-decoration: none !important; + font-size: 16px; + border: 2px solid #000; + box-shadow: 4px 4px 0px 0px #000000; + margin: 10px 0; + transition: all 0.1s; + text-align: center; + ${fullWidth ? 'width: 100%; box-sizing: border-box;' : ''} + `, + }, + text || 'Button', + ], + ]; + }, + + addNodeView() { + return ({ node, editor }) => { + const dom = document.createElement('div'); + dom.style.cssText = 'margin: 10px 0; border: 2px dashed #007acc; padding: 8px; border-radius: 4px; background: #f0f9ff;'; + + const button = document.createElement('a'); + button.href = node.attrs.url; + button.textContent = node.attrs.text; + button.style.cssText = ` + display: inline-block; + background-color: #000; + color: #FFF; + padding: 14px 28px; + font-weight: 700; + text-transform: uppercase; + text-decoration: none; + font-size: 16px; + border: 2px solid #000; + box-shadow: 4px 4px 0px 0px #000000; + cursor: pointer; + ${node.attrs.fullWidth ? 'width: 100%; text-align: center; box-sizing: border-box;' : ''} + `; + + dom.appendChild(button); + + return { + dom, + destroy: () => { + dom.remove(); + }, + }; + }; + }, +}); + +// Custom OTP Box extension +const OTPBox = Node.create({ + name: 'otpBox', + + group: 'block', + + addAttributes() { + return { + code: { + default: '123-456', + parseHTML: element => element.getAttribute('data-code') || '123-456', + renderHTML: attributes => ({ + 'data-code': attributes.code, + }), + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'div[data-otp-box]', + }, + ]; + }, + + renderHTML({ HTMLAttributes, node }) { + const { code } = node.attrs; + return [ + 'div', + { + 'data-otp-box': '', + style: ` + background-color: #F4F4F5; + border: 2px dashed #000; + padding: 20px; + text-align: center; + margin: 20px 0; + letter-spacing: 5px; + font-family: 'Courier New', Courier, monospace; + font-size: 32px; + font-weight: 700; + color: #000; + `, + }, + code, + ]; + }, + + addNodeView() { + return ({ node, editor }) => { + const dom = document.createElement('div'); + dom.style.cssText = 'margin: 10px 0; border: 2px dashed #007acc; padding: 8px; border-radius: 4px; background: #f0f9ff;'; + dom.innerHTML = ` +
OTP Box: ${node.attrs.code}
+
${node.attrs.code}
+ `; + + return { + dom, + destroy: () => { + dom.remove(); + }, + }; + }; + }, +}); + +// Custom Email Table extension +const EmailTable = Table.extend({ + addAttributes() { + return { + ...this.parent?.(), + style: { + default: 'width: 100%; border: 2px solid #000; margin-bottom: 25px; border-collapse: collapse;', + renderHTML: attributes => { + return { style: attributes.style }; + }, + }, + }; + }, +}); + +const EmailTableRow = TableRow.extend({ + addAttributes() { + return { + ...this.parent?.(), + style: { + default: '', + renderHTML: attributes => { + return { style: attributes.style }; + }, + }, + }; + }, +}); + +const EmailTableCell = TableCell.extend({ + addAttributes() { + return { + ...this.parent?.(), + style: { + default: 'padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;', + renderHTML: attributes => { + return { style: attributes.style }; + }, + }, + }; + }, +}); + +const EmailTableHeader = TableHeader.extend({ + addAttributes() { + return { + ...this.parent?.(), + style: { + default: 'background-color: #000; color: #FFF; padding: 12px; text-align: left; font-size: 14px; text-transform: uppercase; font-weight: 700; border: 1px solid #000;', + renderHTML: attributes => { + return { style: attributes.style }; + }, + }, + }; + }, +}); + export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten...', className }: RichTextEditorProps) { const [uploading, setUploading] = useState(false); const [selectedImage, setSelectedImage] = useState<{ src: string; width?: number; height?: number } | null>(null); @@ -57,7 +303,12 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten. const editor = useEditor({ extensions: [ - StarterKit, + StarterKit.configure({ + table: false, + tableRow: false, + tableCell: false, + tableHeader: false, + }), Link.configure({ openOnClick: false, HTMLAttributes: { @@ -69,6 +320,17 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten. class: 'max-w-full h-auto rounded-md cursor-pointer', }, }), + EmailButton, + OTPBox, + EmailTable.configure({ + resizable: true, + HTMLAttributes: { + class: 'email-table', + }, + }), + EmailTableRow, + EmailTableCell, + EmailTableHeader, Placeholder.configure({ placeholder, }), @@ -110,6 +372,63 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten. } }, [editor]); + const addButton = useCallback(() => { + if (!editor) return; + const text = window.prompt('Teks Button:') || 'Button'; + const url = window.prompt('URL Button:') || '#'; + const fullWidth = window.confirm('Gunakan lebar penuh?'); + + editor.chain().focus().insertContent({ + type: 'emailButton', + attrs: { + text, + url, + fullWidth, + }, + }).run(); + }, [editor]); + + const addOTPBox = useCallback(() => { + if (!editor) return; + const code = window.prompt('Kode OTP (contoh: 123-456):') || '123-456'; + + editor.chain().focus().insertContent({ + type: 'otpBox', + attrs: { + code, + }, + }).run(); + }, [editor]); + + const addTable = useCallback(() => { + if (!editor) return; + const rows = parseInt(window.prompt('Jumlah baris:') || '3'); + const cols = parseInt(window.prompt('Jumlah kolom:') || '2'); + const hasHeader = window.confirm('Apakah table memiliki header?'); + + let tableHTML = ''; + + if (hasHeader) { + tableHTML += ''; + for (let i = 0; i < cols; i++) { + tableHTML += ``; + } + tableHTML += ''; + } + + tableHTML += ''; + for (let i = 0; i < (hasHeader ? rows - 1 : rows); i++) { + tableHTML += ''; + for (let j = 0; j < cols; j++) { + tableHTML += ``; + } + tableHTML += ''; + } + tableHTML += '
Kolom ${i + 1}
Isi sel
'; + + editor.chain().focus().insertContent(tableHTML).run(); + }, [editor]); + const uploadImageToStorage = async (file: File): Promise => { try { const fileExt = file.name.split('.').pop(); @@ -299,6 +618,42 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten. > + + {/* Email Components Separator */} +
+ + {/* Email Component Buttons */} + + + + + {/* Image Upload Separator */} +
+
-
+