1. Dialog Portal: Render inside #woonoow-admin-app container instead of document.body to fix Tailwind CSS scoping in WordPress admin 2. Variables Panel: Redesigned from flat list to collapsible accordion - Collapsed by default (less visual noise) - Categorized: Order (blue), Customer (green), Shipping (orange), Store (purple) - Color-coded pills for quick recognition - Shows count of available variables 3. StarterKit: Disable built-in Link to prevent duplicate extension warning
506 lines
20 KiB
TypeScript
506 lines
20 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { __ } from '@/lib/i18n';
|
|
import { BlockRenderer } from './BlockRenderer';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { RichTextEditor } from '@/components/ui/rich-text-editor';
|
|
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, Monitor, Image as ImageIcon } from 'lucide-react';
|
|
import { useMediaQuery } from '@/hooks/use-media-query';
|
|
import { parseMarkdownBasics } from '@/lib/markdown-utils';
|
|
import { htmlToMarkdown } from '@/lib/html-to-markdown';
|
|
import type { EmailBlock, CardBlock, ButtonBlock, ImageBlock, SpacerBlock, CardType, ButtonStyle, ContentWidth, ContentAlign } from './types';
|
|
|
|
interface EmailBuilderProps {
|
|
blocks: EmailBlock[];
|
|
onChange: (blocks: EmailBlock[]) => void;
|
|
variables?: string[];
|
|
}
|
|
|
|
export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderProps) {
|
|
const isDesktop = useMediaQuery('(min-width: 768px)');
|
|
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 [editingWidthMode, setEditingWidthMode] = useState<ContentWidth>('fit');
|
|
const [editingCustomMaxWidth, setEditingCustomMaxWidth] = useState<number | undefined>(undefined);
|
|
const [editingAlign, setEditingAlign] = useState<ContentAlign>('center');
|
|
const [editingImageSrc, setEditingImageSrc] = useState('');
|
|
|
|
// WordPress Media Library integration
|
|
const openMediaLibrary = (callback: (url: string) => void) => {
|
|
// Check if wp.media is available
|
|
if (typeof (window as any).wp === 'undefined' || typeof (window as any).wp.media === 'undefined') {
|
|
console.error('WordPress media library is not available');
|
|
return;
|
|
}
|
|
|
|
const frame = (window as any).wp.media({
|
|
title: __('Select or Upload Image'),
|
|
button: {
|
|
text: __('Use this image'),
|
|
},
|
|
multiple: false,
|
|
library: {
|
|
type: 'image',
|
|
},
|
|
});
|
|
|
|
frame.on('select', () => {
|
|
const attachment = frame.state().get('selection').first().toJSON();
|
|
callback(attachment.url);
|
|
});
|
|
|
|
frame.open();
|
|
};
|
|
|
|
const addBlock = (type: EmailBlock['type']) => {
|
|
const newBlock: EmailBlock = (() => {
|
|
const id = `block-${Date.now()}`;
|
|
switch (type) {
|
|
case 'card':
|
|
return { id, type, cardType: 'default', content: '<h2>Card Title</h2><p>Your content here...</p>' };
|
|
case 'button':
|
|
return { id, type, text: 'Click Here', link: '{order_url}', style: 'solid', widthMode: 'fit', align: 'center' };
|
|
case 'image':
|
|
return { id, type, src: 'https://via.placeholder.com/600x200', alt: 'Image', widthMode: 'fit', align: 'center' };
|
|
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) => {
|
|
console.log('[EmailBuilder] openEditDialog called', { blockId: block.id, blockType: block.type });
|
|
setEditingBlockId(block.id);
|
|
|
|
if (block.type === 'card') {
|
|
// Convert markdown to HTML for rich text editor
|
|
const htmlContent = parseMarkdownBasics(block.content);
|
|
console.log('[EmailBuilder] Card content parsed', { original: block.content, html: htmlContent });
|
|
setEditingContent(htmlContent);
|
|
setEditingCardType(block.cardType);
|
|
} else if (block.type === 'button') {
|
|
setEditingButtonText(block.text);
|
|
setEditingButtonLink(block.link);
|
|
setEditingButtonStyle(block.style);
|
|
setEditingWidthMode(block.widthMode || 'fit');
|
|
setEditingCustomMaxWidth(block.customMaxWidth);
|
|
setEditingAlign(block.align || 'center');
|
|
} else if (block.type === 'image') {
|
|
setEditingImageSrc(block.src);
|
|
setEditingWidthMode(block.widthMode);
|
|
setEditingCustomMaxWidth(block.customMaxWidth);
|
|
setEditingAlign(block.align);
|
|
}
|
|
|
|
console.log('[EmailBuilder] Setting editDialogOpen to true');
|
|
setEditDialogOpen(true);
|
|
};
|
|
|
|
const saveEdit = () => {
|
|
if (!editingBlockId) return;
|
|
|
|
const newBlocks = blocks.map(block => {
|
|
if (block.id !== editingBlockId) return block;
|
|
|
|
if (block.type === 'card') {
|
|
// Convert HTML from rich text editor back to markdown for storage
|
|
const markdownContent = htmlToMarkdown(editingContent);
|
|
return { ...block, content: markdownContent, cardType: editingCardType };
|
|
} else if (block.type === 'button') {
|
|
return {
|
|
...block,
|
|
text: editingButtonText,
|
|
link: editingButtonLink,
|
|
style: editingButtonStyle,
|
|
widthMode: editingWidthMode,
|
|
customMaxWidth: editingCustomMaxWidth,
|
|
align: editingAlign,
|
|
};
|
|
} else if (block.type === 'image') {
|
|
return {
|
|
...block,
|
|
src: editingImageSrc,
|
|
widthMode: editingWidthMode,
|
|
customMaxWidth: editingCustomMaxWidth,
|
|
align: editingAlign,
|
|
};
|
|
}
|
|
|
|
return block;
|
|
});
|
|
|
|
onChange(newBlocks);
|
|
setEditDialogOpen(false);
|
|
setEditingBlockId(null);
|
|
};
|
|
|
|
const editingBlock = blocks.find(b => b.id === editingBlockId);
|
|
|
|
// Mobile fallback
|
|
if (!isDesktop) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center p-8 bg-muted/30 rounded-lg border-2 border-dashed border-muted-foreground/20 min-h-[400px] text-center">
|
|
<Monitor className="w-16 h-16 text-muted-foreground/40 mb-4" />
|
|
<h3 className="text-lg font-semibold mb-2">
|
|
{__('Desktop Only Feature')}
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground max-w-md mb-4">
|
|
{__('The email builder requires a desktop screen for the best editing experience. Please switch to a desktop or tablet device to use this feature.')}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{__('Minimum screen width: 768px')}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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('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>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => addBlock('image')}
|
|
className="h-7 text-xs gap-1"
|
|
>
|
|
{__('Image')}
|
|
</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-100 rounded-lg p-6 min-h-[400px]">
|
|
<div className="max-w-2xl mx-auto 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 max-h-[90vh] overflow-y-auto"
|
|
onInteractOutside={(e) => {
|
|
// Only prevent closing if WordPress media modal is open
|
|
const wpMediaOpen = document.querySelector('.media-modal');
|
|
if (wpMediaOpen) {
|
|
e.preventDefault();
|
|
}
|
|
// Otherwise, allow the dialog to close normally via outside click
|
|
}}
|
|
onEscapeKeyDown={(e) => {
|
|
// Only prevent escape if WP media modal is open
|
|
const wpMediaOpen = document.querySelector('.media-modal');
|
|
if (wpMediaOpen) {
|
|
e.preventDefault();
|
|
}
|
|
// Otherwise, allow escape to close dialog
|
|
}}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{editingBlock?.type === 'card' && __('Edit Card')}
|
|
{editingBlock?.type === 'button' && __('Edit Button')}
|
|
{editingBlock?.type === 'image' && __('Edit Image')}
|
|
</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 === '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="basic">{__('Basic (Plain Text)')}</SelectItem>
|
|
<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')}</Label>
|
|
<RichTextEditor
|
|
content={editingContent}
|
|
onChange={setEditingContent}
|
|
placeholder={__('Enter card content...')}
|
|
variables={variables}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{__('Use the toolbar to format text. HTML will be generated automatically.')}
|
|
</p>
|
|
</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}"
|
|
/>
|
|
{variables.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{variables.filter(v => v.includes('_url')).map((variable) => (
|
|
<code
|
|
key={variable}
|
|
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
|
|
onClick={() => setEditingButtonLink(editingButtonLink + `{${variable}}`)}
|
|
>
|
|
{`{${variable}}`}
|
|
</code>
|
|
))}
|
|
</div>
|
|
)}
|
|
</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>
|
|
<div className="space-y-2">
|
|
<Label>{__('Button Width')}</Label>
|
|
<Select value={editingWidthMode} onValueChange={(value: ContentWidth) => setEditingWidthMode(value)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="fit">{__('Fit content')}</SelectItem>
|
|
<SelectItem value="full">{__('Full width')}</SelectItem>
|
|
<SelectItem value="custom">{__('Custom max width')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{editingWidthMode === 'custom' && (
|
|
<div className="space-y-1">
|
|
<Label htmlFor="button-max-width">{__('Max width (px)')}</Label>
|
|
<Input
|
|
id="button-max-width"
|
|
type="number"
|
|
value={editingCustomMaxWidth ?? ''}
|
|
onChange={(e) => setEditingCustomMaxWidth(e.target.value ? parseInt(e.target.value, 10) : undefined)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>{__('Alignment')}</Label>
|
|
<Select value={editingAlign} onValueChange={(value: ContentAlign) => setEditingAlign(value)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="left">{__('Left')}</SelectItem>
|
|
<SelectItem value="center">{__('Center')}</SelectItem>
|
|
<SelectItem value="right">{__('Right')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{editingBlock?.type === 'image' && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="image-src">{__('Image URL')}</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="image-src"
|
|
value={editingImageSrc}
|
|
onChange={(e) => setEditingImageSrc(e.target.value)}
|
|
placeholder="https://example.com/image.jpg"
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => openMediaLibrary(setEditingImageSrc)}
|
|
title={__('Select from Media Library')}
|
|
>
|
|
<ImageIcon className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{__('Enter image URL or click the icon to select from WordPress media library')}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>{__('Image Width')}</Label>
|
|
<Select
|
|
value={editingWidthMode}
|
|
onValueChange={(value: ContentWidth) => setEditingWidthMode(value)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="fit">{__('Fit content')}</SelectItem>
|
|
<SelectItem value="full">{__('Full width')}</SelectItem>
|
|
<SelectItem value="custom">{__('Custom max width')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{editingWidthMode === 'custom' && (
|
|
<div className="space-y-1">
|
|
<Label htmlFor="image-max-width">{__('Max width (px)')}</Label>
|
|
<Input
|
|
id="image-max-width"
|
|
type="number"
|
|
value={editingCustomMaxWidth ?? ''}
|
|
onChange={(e) => setEditingCustomMaxWidth(e.target.value ? parseInt(e.target.value, 10) : undefined)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>{__('Alignment')}</Label>
|
|
<Select value={editingAlign} onValueChange={(value: ContentAlign) => setEditingAlign(value)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="left">{__('Left')}</SelectItem>
|
|
<SelectItem value="center">{__('Center')}</SelectItem>
|
|
<SelectItem value="right">{__('Right')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
|
{__('Cancel')}
|
|
</Button>
|
|
<Button onClick={saveEdit}>
|
|
{__('Save Changes')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|