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:
dwindown
2025-11-13 06:40:23 +07:00
parent 74e084caa6
commit 4ec0f3f890
7 changed files with 718 additions and 207 deletions

View File

@@ -6,12 +6,10 @@ import { SettingsLayout } from '../components/SettingsLayout';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { RichTextEditor } from '@/components/ui/rich-text-editor';
import { EmailBuilder, EmailBlock, blocksToHTML, htmlToBlocks } from '@/components/EmailBuilder';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { ArrowLeft, Eye, Edit, RotateCcw, Plus, CheckCircle, Info, AlertCircle, Image } from 'lucide-react';
import { ArrowLeft, Eye, Edit, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import { __ } from '@/lib/i18n';
@@ -34,15 +32,10 @@ export default function EditTemplate() {
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [blocks, setBlocks] = useState<EmailBlock[]>([]);
const [variables, setVariables] = useState<{ [key: string]: string }>({});
const [activeTab, setActiveTab] = useState('editor');
const [codeMode, setCodeMode] = useState(false);
// Button dialog state
const [buttonDialogOpen, setButtonDialogOpen] = useState(false);
const [buttonText, setButtonText] = useState('Click Here');
const [buttonLink, setButtonLink] = useState('{order_url}');
const [buttonType, setButtonType] = useState<'solid' | 'outline'>('solid');
// Fetch template
const { data: template, isLoading, error } = useQuery({
@@ -86,6 +79,7 @@ export default function EditTemplate() {
setSubject(template.subject || '');
setBody(template.body || '');
setBlocks(htmlToBlocks(template.body || ''));
setVariables(template.variables || {});
}
}, [template]);
@@ -104,12 +98,15 @@ export default function EditTemplate() {
}, [variables]);
const handleSave = async () => {
// Convert blocks to HTML before saving
const htmlBody = codeMode ? body : blocksToHTML(blocks);
try {
await api.post('/notifications/templates', {
eventId,
channelId,
subject,
body,
body: htmlBody,
});
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
queryClient.invalidateQueries({ queryKey: ['notification-template', eventId, channelId] });
@@ -133,37 +130,22 @@ export default function EditTemplate() {
}
};
// Insert card helpers
const insertCard = (type?: string, content?: string) => {
let cardText = '';
if (type) {
cardText = `[card type="${type}"]
${content || '<h2>Card Title</h2>\n<p>Card content goes here...</p>'}
[/card]\n\n`;
// Sync blocks to body when switching to code mode
const handleCodeModeToggle = () => {
if (!codeMode) {
// Switching TO code mode: convert blocks to HTML
setBody(blocksToHTML(blocks));
} else {
cardText = `[card]
${content || '<h2>Card Title</h2>\n<p>Card content goes here...</p>'}
[/card]\n\n`;
// Switching FROM code mode: convert HTML to blocks
setBlocks(htmlToBlocks(body));
}
setBody(body + cardText);
toast.success(__('Card inserted'));
};
const openButtonDialog = () => {
setButtonText('Click Here');
setButtonLink('{order_url}');
setButtonType('solid');
setButtonDialogOpen(true);
setCodeMode(!codeMode);
};
const insertButton = () => {
const buttonClass = buttonType === 'solid' ? 'button' : 'button-outline';
const buttonHtml = `<p style="text-align: center;"><a href="${buttonLink}" class="${buttonClass}">${buttonText}</a></p>`;
setBody(body + buttonHtml + '\n');
setButtonDialogOpen(false);
toast.success(__('Button inserted'));
// Update blocks and sync to body
const handleBlocksChange = (newBlocks: EmailBlock[]) => {
setBlocks(newBlocks);
setBody(blocksToHTML(newBlocks));
};
// Get variable keys for the rich text editor
@@ -378,187 +360,42 @@ ${content || '<h2>Card Title</h2>\n<p>Card content goes here...</p>'}
<Button
variant="ghost"
size="sm"
onClick={() => setCodeMode(!codeMode)}
onClick={handleCodeModeToggle}
className="h-8 text-xs"
>
{codeMode ? __('Visual Editor') : __('Code Mode')}
{codeMode ? __('Visual Builder') : __('Code Mode')}
</Button>
</div>
{/* Card Insert Buttons */}
{!codeMode && (
<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">
{__('Insert Card:')}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => insertCard()}
className="h-7 text-xs gap-1"
>
<Plus className="h-3 w-3" />
{__('Basic')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => insertCard('success', '<h2>✅ Success!</h2>\n<p>Your action was successful.</p>')}
className="h-7 text-xs gap-1"
>
<CheckCircle className="h-3 w-3 text-green-600" />
{__('Success')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => insertCard('info', '<h2> Information</h2>\n<p>Important information here.</p>')}
className="h-7 text-xs gap-1"
>
<Info className="h-3 w-3 text-blue-600" />
{__('Info')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => insertCard('warning', '<h2>⚠️ Warning</h2>\n<p>Please pay attention to this.</p>')}
className="h-7 text-xs gap-1"
>
<AlertCircle className="h-3 w-3 text-orange-600" />
{__('Warning')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => insertCard('hero', '<h1>Big Announcement</h1>\n<p>Hero card with large text.</p>', 'https://example.com/bg.jpg')}
className="h-7 text-xs gap-1"
>
<Image className="h-3 w-3 text-purple-600" />
{__('Hero')}
</Button>
<div className="border-l mx-1"></div>
<Button
type="button"
variant="outline"
size="sm"
onClick={openButtonDialog}
className="h-7 text-xs gap-1"
>
<Plus className="h-3 w-3" />
{__('Button')}
</Button>
{codeMode ? (
<div className="space-y-2">
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
className="w-full min-h-[400px] p-4 font-mono text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
placeholder={__('Enter HTML code with [card] tags...')}
/>
<p className="text-xs text-muted-foreground">
{__('Edit raw HTML code with [card] syntax. Switch to Visual Builder for drag-and-drop editing.')}
</p>
</div>
) : (
<div>
<EmailBuilder
blocks={blocks}
onChange={handleBlocksChange}
variables={variableKeys}
/>
<p className="text-xs text-muted-foreground mt-2">
{__('Build your email visually. Add blocks, edit content, and see live preview.')}
</p>
</div>
)}
{codeMode ? (
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
className="w-full min-h-[400px] p-4 font-mono text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
placeholder={__('Enter HTML code...')}
/>
) : (
<RichTextEditor
key={`editor-${eventId}-${channelId}`}
content={body}
onChange={setBody}
placeholder={__('Enter notification message')}
variables={variableKeys}
/>
)}
<p className="text-xs text-muted-foreground">
{codeMode
? __('Edit raw HTML code. Switch to Visual Editor for WYSIWYG editing.')
: __('Use the toolbar to format text and insert variables.')}
</p>
</div>
</CardContent>
</Card>
)}
{/* Button Insert Dialog */}
<Dialog open={buttonDialogOpen} onOpenChange={setButtonDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{__('Insert Button')}</DialogTitle>
<DialogDescription>
{__('Configure your call-to-action button. Use variables like {order_url} for dynamic links.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Button Text */}
<div className="space-y-2">
<Label htmlFor="button-text">{__('Button Text')}</Label>
<Input
id="button-text"
value={buttonText}
onChange={(e) => setButtonText(e.target.value)}
placeholder={__('e.g., View Order, Track Shipment')}
/>
</div>
{/* Button Link */}
<div className="space-y-2">
<Label htmlFor="button-link">{__('Button Link')}</Label>
<Input
id="button-link"
value={buttonLink}
onChange={(e) => setButtonLink(e.target.value)}
placeholder={__('e.g., {order_url}, {product_url}')}
/>
<p className="text-xs text-muted-foreground">
{__('Use variables like {order_url} or enter a full URL')}
</p>
</div>
{/* Button Type */}
<div className="space-y-2">
<Label>{__('Button Style')}</Label>
<RadioGroup value={buttonType} onValueChange={(value: 'solid' | 'outline') => setButtonType(value)}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="solid" id="solid" />
<Label htmlFor="solid" className="font-normal cursor-pointer">
<div className="flex items-center gap-2">
<div className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm font-medium">
{__('Solid')}
</div>
<span className="text-xs text-muted-foreground">{__('High priority, urgent action')}</span>
</div>
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="outline" id="outline" />
<Label htmlFor="outline" className="font-normal cursor-pointer">
<div className="flex items-center gap-2">
<div className="px-4 py-2 border-2 border-primary text-primary rounded text-sm font-medium">
{__('Outline')}
</div>
<span className="text-xs text-muted-foreground">{__('Secondary action, less urgent')}</span>
</div>
</Label>
</div>
</RadioGroup>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setButtonDialogOpen(false)}>
{__('Cancel')}
</Button>
<Button onClick={insertButton}>
{__('Insert Button')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Preview Tab */}
{activeTab === 'preview' && (
<Card>