feat: Replace TipTap with Visual Email Builder 🎨
## 🚀 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] <h1>New Order Received</h1> [/card] [card type="success"] <h2>✅ Order Confirmed!</h2> [/card] [card] <p style="text-align: center;"> <a href="{order_url}" class="button">View Order Details</a> </p> [/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! 🎉
This commit is contained in:
336
admin-spa/src/components/EmailBuilder/EmailBuilder.tsx
Normal file
336
admin-spa/src/components/EmailBuilder/EmailBuilder.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [editingContent, setEditingContent] = useState('');
|
||||
const [editingCardType, setEditingCardType] = useState<CardType>('default');
|
||||
const [editingButtonText, setEditingButtonText] = useState('');
|
||||
const [editingButtonLink, setEditingButtonLink] = useState('');
|
||||
const [editingButtonStyle, setEditingButtonStyle] = useState<ButtonStyle>('solid');
|
||||
|
||||
const addBlock = (type: EmailBlock['type']) => {
|
||||
const newBlock: EmailBlock = (() => {
|
||||
const id = `block-${Date.now()}`;
|
||||
switch (type) {
|
||||
case 'header':
|
||||
return { id, type, content: '<h1>Header Title</h1>' };
|
||||
case 'text':
|
||||
return { id, type, content: '<p>Your text content here...</p>' };
|
||||
case 'card':
|
||||
return { id, type, cardType: 'default', content: '<h2>Card Title</h2><p>Card content...</p>' };
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* Add Block Toolbar */}
|
||||
<div className="flex flex-wrap gap-2 p-3 bg-muted/50 rounded-md border">
|
||||
<span className="text-xs font-medium text-muted-foreground flex items-center">
|
||||
{__('Add Block:')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addBlock('header')}
|
||||
className="h-7 text-xs gap-1"
|
||||
>
|
||||
<Type className="h-3 w-3" />
|
||||
{__('Header')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addBlock('text')}
|
||||
className="h-7 text-xs gap-1"
|
||||
>
|
||||
<Type className="h-3 w-3" />
|
||||
{__('Text')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addBlock('card')}
|
||||
className="h-7 text-xs gap-1"
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
{__('Card')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addBlock('button')}
|
||||
className="h-7 text-xs gap-1"
|
||||
>
|
||||
<MousePointer className="h-3 w-3" />
|
||||
{__('Button')}
|
||||
</Button>
|
||||
<div className="border-l mx-1"></div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addBlock('divider')}
|
||||
className="h-7 text-xs gap-1"
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
{__('Divider')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addBlock('spacer')}
|
||||
className="h-7 text-xs gap-1"
|
||||
>
|
||||
<Space className="h-3 w-3" />
|
||||
{__('Spacer')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Email Canvas */}
|
||||
<div className="bg-gray-50 rounded-lg p-6 min-h-[400px]">
|
||||
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-sm p-8 space-y-6">
|
||||
{blocks.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>{__('No blocks yet. Add blocks using the toolbar above.')}</p>
|
||||
</div>
|
||||
) : (
|
||||
blocks.map((block, index) => (
|
||||
<BlockRenderer
|
||||
key={block.id}
|
||||
block={block}
|
||||
isEditing={editingBlockId === block.id}
|
||||
onEdit={() => openEditDialog(block)}
|
||||
onDelete={() => deleteBlock(block.id)}
|
||||
onMoveUp={() => moveBlock(block.id, 'up')}
|
||||
onMoveDown={() => moveBlock(block.id, 'down')}
|
||||
isFirst={index === 0}
|
||||
isLast={index === blocks.length - 1}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingBlock?.type === 'header' && __('Edit Header')}
|
||||
{editingBlock?.type === 'text' && __('Edit Text')}
|
||||
{editingBlock?.type === 'card' && __('Edit Card')}
|
||||
{editingBlock?.type === 'button' && __('Edit Button')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__('Make changes to your block. You can use variables like {customer_name} or {order_number}.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{(editingBlock?.type === 'header' || editingBlock?.type === 'text') && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="content">{__('Content (HTML)')}</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={editingContent}
|
||||
onChange={(e) => setEditingContent(e.target.value)}
|
||||
rows={6}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingBlock?.type === 'card' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="card-type">{__('Card Type')}</Label>
|
||||
<Select value={editingCardType} onValueChange={(value: CardType) => setEditingCardType(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{__('Default')}</SelectItem>
|
||||
<SelectItem value="success">{__('Success')}</SelectItem>
|
||||
<SelectItem value="info">{__('Info')}</SelectItem>
|
||||
<SelectItem value="warning">{__('Warning')}</SelectItem>
|
||||
<SelectItem value="hero">{__('Hero')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="card-content">{__('Content (HTML)')}</Label>
|
||||
<Textarea
|
||||
id="card-content"
|
||||
value={editingContent}
|
||||
onChange={(e) => setEditingContent(e.target.value)}
|
||||
rows={8}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editingBlock?.type === 'button' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="button-text">{__('Button Text')}</Label>
|
||||
<Input
|
||||
id="button-text"
|
||||
value={editingButtonText}
|
||||
onChange={(e) => setEditingButtonText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="button-link">{__('Button Link')}</Label>
|
||||
<Input
|
||||
id="button-link"
|
||||
value={editingButtonLink}
|
||||
onChange={(e) => setEditingButtonLink(e.target.value)}
|
||||
placeholder="{order_url}"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="button-style">{__('Button Style')}</Label>
|
||||
<Select value={editingButtonStyle} onValueChange={(value: ButtonStyle) => setEditingButtonStyle(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
|
||||
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Variable Helper */}
|
||||
{variables.length > 0 && (
|
||||
<div className="pt-2 border-t">
|
||||
<Label className="text-xs text-muted-foreground">{__('Available Variables:')}</Label>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{variables.map(variable => (
|
||||
<code
|
||||
key={variable}
|
||||
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
|
||||
onClick={() => {
|
||||
if (editingBlock?.type === 'button') {
|
||||
setEditingButtonLink(editingButtonLink + `{${variable}}`);
|
||||
} else {
|
||||
setEditingContent(editingContent + `{${variable}}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{`{${variable}}`}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={saveEdit}>
|
||||
{__('Save Changes')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user