Polish email template system with UX improvements

- 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 <noreply@anthropic.com>
This commit is contained in:
dwindown
2025-12-22 20:35:50 +07:00
parent efc085e231
commit 37680bd25b
3 changed files with 697 additions and 41 deletions

View File

@@ -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 = `
<div style="text-align: center; font-size: 12px; color: #007acc; margin-bottom: 4px;">OTP Box: ${node.attrs.code}</div>
<div style="
background-color: #F4F4F5;
border: 2px dashed #000;
padding: 20px;
letter-spacing: 5px;
font-family: 'Courier New', Courier, monospace;
font-size: 32px;
font-weight: 700;
color: #000;
text-align: center;
">${node.attrs.code}</div>
`;
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 = '<table style="width: 100%; border: 2px solid #000; margin-bottom: 25px; border-collapse: collapse;">';
if (hasHeader) {
tableHTML += '<thead><tr>';
for (let i = 0; i < cols; i++) {
tableHTML += `<th style="background-color: #000; color: #FFF; padding: 12px; text-align: left; font-size: 14px; text-transform: uppercase; font-weight: 700; border: 1px solid #000;">Kolom ${i + 1}</th>`;
}
tableHTML += '</tr></thead>';
}
tableHTML += '<tbody>';
for (let i = 0; i < (hasHeader ? rows - 1 : rows); i++) {
tableHTML += '<tr>';
for (let j = 0; j < cols; j++) {
tableHTML += `<td style="padding: 12px; border: 1px solid #000; font-size: 15px; vertical-align: top;">Isi sel</td>`;
}
tableHTML += '</tr>';
}
tableHTML += '</tbody></table>';
editor.chain().focus().insertContent(tableHTML).run();
}, [editor]);
const uploadImageToStorage = async (file: File): Promise<string | null> => {
try {
const fileExt = file.name.split('.').pop();
@@ -299,6 +618,42 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
>
<LinkIcon className="w-4 h-4" />
</Button>
{/* Email Components Separator */}
<div className="w-px h-6 bg-border mx-1" />
{/* Email Component Buttons */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={addButton}
title="Tambah Email Button"
>
<MousePointer className="w-4 h-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addOTPBox}
title="Tambah OTP Box"
>
<Square className="w-4 h-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addTable}
title="Tambah Email Table"
>
<TableIcon className="w-4 h-4" />
</Button>
{/* Image Upload Separator */}
<div className="w-px h-6 bg-border mx-1" />
<label>
<Button type="button" variant="ghost" size="sm" asChild disabled={uploading}>
<span className={uploading ? 'opacity-50' : ''}>