From 4ec0f3f8908b3cf58fd01bd3706647203bcb99a4 Mon Sep 17 00:00:00 2001 From: dwindown Date: Thu, 13 Nov 2025 06:40:23 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Replace=20TipTap=20with=20Visual=20Emai?= =?UTF-8?q?l=20Builder=20=F0=9F=8E=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🚀 MAJOR FEATURE: Visual Email Content Builder! ### What Changed: **Before:** - TipTap rich text editor - Manual [card] syntax typing - Hard to visualize final result - Not beginner-friendly **After:** - Visual drag-and-drop builder - Live preview as you build - No code needed - Professional UX ### New Components: **1. EmailBuilder** (`/components/EmailBuilder/`) - Main builder component - Block-based editing - Drag to reorder (via up/down buttons) - Click to edit - Live preview **2. Block Types:** - **Header** - Large title text - **Text** - Paragraph content - **Card** - Styled content box (5 types: default, success, info, warning, hero) - **Button** - CTA with solid/outline styles - **Divider** - Horizontal line - **Spacer** - Vertical spacing **3. Features:** - ✅ **Add Block Toolbar** - One-click block insertion - ✅ **Hover Controls** - Edit, Delete, Move Up/Down - ✅ **Edit Dialog** - Full editor for each block - ✅ **Variable Helper** - Click to insert variables - ✅ **Code Mode Toggle** - Switch between visual/code - ✅ **Auto-sync** - Converts blocks ↔ [card] syntax ### How It Works: **Visual Mode:** ``` [Add Block: Header | Text | Card | Button | Divider | Spacer] ┌─────────────────────────────┐ │ Header Block [↑ ↓ ✎ ×] │ │ New Order Received │ └─────────────────────────────┘ ┌─────────────────────────────┐ │ Card Block (Success) [↑ ↓ ✎ ×] │ │ ✅ Order Confirmed! │ └─────────────────────────────┘ ┌─────────────────────────────┐ │ Button Block [↑ ↓ ✎ ×] │ │ [View Order Details] │ └─────────────────────────────┘ ``` **Code Mode:** ```html [card]

New Order Received

[/card] [card type="success"]

✅ Order Confirmed!

[/card] [card]

View Order Details

[/card] ``` ### Benefits: 1. **No Learning Curve** - Visual interface, no syntax to learn - Click, edit, done! 2. **Live Preview** - See exactly how email will look - WYSIWYG editing 3. **Flexible** - Switch to code mode anytime - Full HTML control when needed 4. **Professional** - Pre-designed block types - Consistent styling - Best practices built-in 5. **Variable Support** - Click to insert variables - Works in all block types - Helpful dropdown ### Technical Details: **Converter Functions:** - `blocksToHTML()` - Converts blocks to [card] syntax - `htmlToBlocks()` - Parses [card] syntax to blocks - Seamless sync between visual/code modes **State Management:** - Blocks stored as structured data - Auto-converts to HTML on save - Preserves all [card] attributes ### Next Steps: - Install @radix-ui/react-radio-group for radio buttons - Test email rendering end-to-end - Polish and final review This is a GAME CHANGER for email template editing! 🎉 --- .../components/EmailBuilder/BlockRenderer.tsx | 130 +++++++ .../components/EmailBuilder/EmailBuilder.tsx | 336 ++++++++++++++++++ .../src/components/EmailBuilder/converter.ts | 113 ++++++ .../src/components/EmailBuilder/index.ts | 4 + .../src/components/EmailBuilder/types.ts | 49 +++ admin-spa/src/components/ui/radio-group.tsx | 42 +++ .../Settings/Notifications/EditTemplate.tsx | 251 +++---------- 7 files changed, 718 insertions(+), 207 deletions(-) create mode 100644 admin-spa/src/components/EmailBuilder/BlockRenderer.tsx create mode 100644 admin-spa/src/components/EmailBuilder/EmailBuilder.tsx create mode 100644 admin-spa/src/components/EmailBuilder/converter.ts create mode 100644 admin-spa/src/components/EmailBuilder/index.ts create mode 100644 admin-spa/src/components/EmailBuilder/types.ts create mode 100644 admin-spa/src/components/ui/radio-group.tsx diff --git a/admin-spa/src/components/EmailBuilder/BlockRenderer.tsx b/admin-spa/src/components/EmailBuilder/BlockRenderer.tsx new file mode 100644 index 0000000..6e74f32 --- /dev/null +++ b/admin-spa/src/components/EmailBuilder/BlockRenderer.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { EmailBlock } from './types'; +import { __ } from '@/lib/i18n'; + +interface BlockRendererProps { + block: EmailBlock; + isEditing: boolean; + onEdit: () => void; + onDelete: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + isFirst: boolean; + isLast: boolean; +} + +export function BlockRenderer({ + block, + isEditing, + onEdit, + onDelete, + onMoveUp, + onMoveDown, + isFirst, + isLast +}: BlockRendererProps) { + + const renderBlockContent = () => { + switch (block.type) { + case 'header': + return ( +
+

+

+ ); + + case 'text': + return ( +
+ ); + + case 'card': + const cardClasses = { + default: 'bg-white border border-gray-200', + success: 'bg-green-50 border border-green-200', + info: 'bg-blue-50 border border-blue-200', + warning: 'bg-orange-50 border border-orange-200', + hero: 'bg-gradient-to-r from-purple-500 to-indigo-600 text-white' + }; + + return ( +
+
+
+ ); + + case 'button': + const buttonClasses = block.style === 'solid' + ? 'bg-purple-600 text-white hover:bg-purple-700' + : 'border-2 border-purple-600 text-purple-600 hover:bg-purple-50'; + + return ( +
+ + {block.text} + +
+ ); + + case 'divider': + return
; + + case 'spacer': + return
; + + default: + return null; + } + }; + + return ( +
+ {/* Block Content */} +
+ {renderBlockContent()} +
+ + {/* Hover Controls */} +
+ {!isFirst && ( + + )} + {!isLast && ( + + )} + + +
+
+ ); +} diff --git a/admin-spa/src/components/EmailBuilder/EmailBuilder.tsx b/admin-spa/src/components/EmailBuilder/EmailBuilder.tsx new file mode 100644 index 0000000..770b37a --- /dev/null +++ b/admin-spa/src/components/EmailBuilder/EmailBuilder.tsx @@ -0,0 +1,336 @@ +import React, { useState } from 'react'; +import { EmailBlock, CardType, ButtonStyle } from './types'; +import { BlockRenderer } from './BlockRenderer'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Plus, Type, Square, MousePointer, Minus, Space } from 'lucide-react'; +import { __ } from '@/lib/i18n'; + +interface EmailBuilderProps { + blocks: EmailBlock[]; + onChange: (blocks: EmailBlock[]) => void; + variables?: string[]; +} + +export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderProps) { + const [editingBlockId, setEditingBlockId] = useState(null); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [editingContent, setEditingContent] = useState(''); + const [editingCardType, setEditingCardType] = useState('default'); + const [editingButtonText, setEditingButtonText] = useState(''); + const [editingButtonLink, setEditingButtonLink] = useState(''); + const [editingButtonStyle, setEditingButtonStyle] = useState('solid'); + + const addBlock = (type: EmailBlock['type']) => { + const newBlock: EmailBlock = (() => { + const id = `block-${Date.now()}`; + switch (type) { + case 'header': + return { id, type, content: '

Header Title

' }; + case 'text': + return { id, type, content: '

Your text content here...

' }; + case 'card': + return { id, type, cardType: 'default', content: '

Card Title

Card content...

' }; + case 'button': + return { id, type, text: 'Click Here', link: '{order_url}', style: 'solid' }; + case 'divider': + return { id, type }; + case 'spacer': + return { id, type, height: 32 }; + default: + throw new Error(`Unknown block type: ${type}`); + } + })(); + + onChange([...blocks, newBlock]); + }; + + const deleteBlock = (id: string) => { + onChange(blocks.filter(b => b.id !== id)); + }; + + const moveBlock = (id: string, direction: 'up' | 'down') => { + const index = blocks.findIndex(b => b.id === id); + if (index === -1) return; + + const newIndex = direction === 'up' ? index - 1 : index + 1; + if (newIndex < 0 || newIndex >= blocks.length) return; + + const newBlocks = [...blocks]; + [newBlocks[index], newBlocks[newIndex]] = [newBlocks[newIndex], newBlocks[index]]; + onChange(newBlocks); + }; + + const openEditDialog = (block: EmailBlock) => { + setEditingBlockId(block.id); + + if (block.type === 'header' || block.type === 'text') { + setEditingContent(block.content); + } else if (block.type === 'card') { + setEditingContent(block.content); + setEditingCardType(block.cardType); + } else if (block.type === 'button') { + setEditingButtonText(block.text); + setEditingButtonLink(block.link); + setEditingButtonStyle(block.style); + } + + setEditDialogOpen(true); + }; + + const saveEdit = () => { + if (!editingBlockId) return; + + const newBlocks = blocks.map(block => { + if (block.id !== editingBlockId) return block; + + if (block.type === 'header') { + return { ...block, content: editingContent }; + } else if (block.type === 'text') { + return { ...block, content: editingContent }; + } else if (block.type === 'card') { + return { ...block, content: editingContent, cardType: editingCardType }; + } else if (block.type === 'button') { + return { ...block, text: editingButtonText, link: editingButtonLink, style: editingButtonStyle }; + } + + return block; + }); + + onChange(newBlocks); + setEditDialogOpen(false); + setEditingBlockId(null); + }; + + const editingBlock = blocks.find(b => b.id === editingBlockId); + + return ( +
+ {/* Add Block Toolbar */} +
+ + {__('Add Block:')} + + + + + +
+ + +
+ + {/* Email Canvas */} +
+
+ {blocks.length === 0 ? ( +
+

{__('No blocks yet. Add blocks using the toolbar above.')}

+
+ ) : ( + blocks.map((block, index) => ( + openEditDialog(block)} + onDelete={() => deleteBlock(block.id)} + onMoveUp={() => moveBlock(block.id, 'up')} + onMoveDown={() => moveBlock(block.id, 'down')} + isFirst={index === 0} + isLast={index === blocks.length - 1} + /> + )) + )} +
+
+ + {/* Edit Dialog */} + + + + + {editingBlock?.type === 'header' && __('Edit Header')} + {editingBlock?.type === 'text' && __('Edit Text')} + {editingBlock?.type === 'card' && __('Edit Card')} + {editingBlock?.type === 'button' && __('Edit Button')} + + + {__('Make changes to your block. You can use variables like {customer_name} or {order_number}.')} + + + +
+ {(editingBlock?.type === 'header' || editingBlock?.type === 'text') && ( +
+ +