Files
WooNooW/admin-spa/src/components/EmailBuilder/EmailBuilder.tsx
Dwindi Ramadhana 875ab7af34 fix: dialog portal scope + UX improvements
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
2026-01-01 01:53:22 +07:00

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>
);
}