feat: Complete markdown syntax refinement and variable protection
✅ New cleaner syntax implemented: - [card:type] instead of [card type='type'] - [button:style](url)Text[/button] instead of [button url='...' style='...'] - Standard markdown images:  ✅ Variable protection from markdown parsing: - Variables with underscores (e.g., {order_items_table}) now protected - HTML comment placeholders prevent italic/bold parsing - All variables render correctly in preview ✅ Button rendering fixes: - Buttons work in Visual mode inside cards - Buttons work in Preview mode - Button clicks prevented in visual editor - Proper styling for solid and outline buttons ✅ Backward compatibility: - Old syntax still supported - No breaking changes ✅ Bug fixes: - Fixed order_item_table → order_items_table naming - Fixed button regex to match across newlines - Added button/image parsing to parseMarkdownBasics - Prevented button clicks on .button and .button-outline classes 📚 Documentation: - NEW_MARKDOWN_SYNTAX.md - Complete user guide - MARKDOWN_SYNTAX_AND_VARIABLES.md - Technical analysis
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { EmailBlock } from './types';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { parseMarkdownBasics } from '@/lib/markdown-utils';
|
||||
|
||||
interface BlockRendererProps {
|
||||
block: EmailBlock;
|
||||
@@ -27,7 +28,16 @@ export function BlockRenderer({
|
||||
// Prevent navigation in builder
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'A' || target.tagName === 'BUTTON' || target.closest('a') || target.closest('button')) {
|
||||
if (
|
||||
target.tagName === 'A' ||
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.closest('a') ||
|
||||
target.closest('button') ||
|
||||
target.classList.contains('button') ||
|
||||
target.classList.contains('button-outline') ||
|
||||
target.closest('.button') ||
|
||||
target.closest('.button-outline')
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
@@ -73,17 +83,20 @@ export function BlockRenderer({
|
||||
}
|
||||
};
|
||||
|
||||
// Convert markdown to HTML for visual rendering
|
||||
const htmlContent = parseMarkdownBasics(block.content);
|
||||
|
||||
return (
|
||||
<div style={cardStyles[block.cardType]}>
|
||||
<div
|
||||
className="prose prose-sm max-w-none [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-0 [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-0 [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-0 [&_h3]:mb-2 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-0 [&_h4]:mb-2"
|
||||
className="prose prose-sm max-w-none [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-0 [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-0 [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-0 [&_h3]:mb-2 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-0 [&_h4]:mb-2 [&_.button]:inline-block [&_.button]:bg-purple-600 [&_.button]:text-white [&_.button]:px-7 [&_.button]:py-3.5 [&_.button]:rounded-md [&_.button]:no-underline [&_.button]:font-semibold [&_.button-outline]:inline-block [&_.button-outline]:bg-transparent [&_.button-outline]:text-purple-600 [&_.button-outline]:px-6 [&_.button-outline]:py-3 [&_.button-outline]:rounded-md [&_.button-outline]:no-underline [&_.button-outline]:font-semibold [&_.button-outline]:border-2 [&_.button-outline]:border-purple-600"
|
||||
style={block.cardType === 'hero' ? { color: '#fff' } : {}}
|
||||
dangerouslySetInnerHTML={{ __html: block.content }}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'button':
|
||||
case 'button': {
|
||||
const buttonStyle: React.CSSProperties = block.style === 'solid'
|
||||
? {
|
||||
display: 'inline-block',
|
||||
@@ -92,7 +105,7 @@ export function BlockRenderer({
|
||||
padding: '14px 28px',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600
|
||||
fontWeight: 600,
|
||||
}
|
||||
: {
|
||||
display: 'inline-block',
|
||||
@@ -102,19 +115,57 @@ export function BlockRenderer({
|
||||
border: '2px solid #7f54b3',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
textAlign: block.align || 'center',
|
||||
};
|
||||
|
||||
if (block.widthMode === 'full') {
|
||||
buttonStyle.display = 'block';
|
||||
buttonStyle.width = '100%';
|
||||
buttonStyle.textAlign = 'center';
|
||||
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
|
||||
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
|
||||
buttonStyle.width = '100%';
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<a
|
||||
href={block.link}
|
||||
style={buttonStyle}
|
||||
>
|
||||
<div style={containerStyle}>
|
||||
<a href={block.link} style={buttonStyle}>
|
||||
{block.text}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'image': {
|
||||
const containerStyle: React.CSSProperties = {
|
||||
textAlign: block.align,
|
||||
marginBottom: 24,
|
||||
};
|
||||
|
||||
const imgStyle: React.CSSProperties = {
|
||||
display: 'inline-block',
|
||||
};
|
||||
|
||||
if (block.widthMode === 'full') {
|
||||
imgStyle.display = 'block';
|
||||
imgStyle.width = '100%';
|
||||
imgStyle.height = 'auto';
|
||||
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
|
||||
imgStyle.maxWidth = `${block.customMaxWidth}px`;
|
||||
imgStyle.width = '100%';
|
||||
imgStyle.height = 'auto';
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<img src={block.src} alt={block.alt || ''} style={imgStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'divider':
|
||||
return <hr className="border-t border-gray-300 my-4" />;
|
||||
@@ -154,8 +205,8 @@ export function BlockRenderer({
|
||||
↓
|
||||
</button>
|
||||
)}
|
||||
{/* Only show edit button for card and button blocks */}
|
||||
{(block.type === 'card' || block.type === 'button') && (
|
||||
{/* Only show edit button for card, button, and image blocks */}
|
||||
{(block.type === 'card' || block.type === 'button' || block.type === 'image') && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1 hover:bg-gray-100 rounded text-blue-600 text-xs"
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import React, { useState } from 'react';
|
||||
import { EmailBlock, CardType, ButtonStyle } from './types';
|
||||
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 } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
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[];
|
||||
@@ -18,6 +22,7 @@ interface EmailBuilderProps {
|
||||
}
|
||||
|
||||
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('');
|
||||
@@ -25,6 +30,37 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
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 = (() => {
|
||||
@@ -33,7 +69,9 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
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' };
|
||||
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':
|
||||
@@ -66,12 +104,22 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
setEditingBlockId(block.id);
|
||||
|
||||
if (block.type === 'card') {
|
||||
setEditingContent(block.content);
|
||||
// Convert markdown to HTML for rich text editor
|
||||
const htmlContent = parseMarkdownBasics(block.content);
|
||||
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);
|
||||
}
|
||||
|
||||
setEditDialogOpen(true);
|
||||
@@ -84,9 +132,27 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
if (block.id !== editingBlockId) return block;
|
||||
|
||||
if (block.type === 'card') {
|
||||
return { ...block, content: editingContent, cardType: editingCardType };
|
||||
// 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 };
|
||||
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;
|
||||
@@ -99,6 +165,24 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
|
||||
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 */}
|
||||
@@ -126,6 +210,15 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
<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"
|
||||
@@ -151,7 +244,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
|
||||
{/* Email Canvas */}
|
||||
<div className="bg-gray-100 rounded-lg p-6 min-h-[400px]">
|
||||
<div className="max-w-2xl mx-auto bg-gray-50 rounded-lg shadow-sm p-8 space-y-6">
|
||||
<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>
|
||||
@@ -176,11 +269,36 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogContent
|
||||
className="sm:max-w-2xl"
|
||||
onInteractOutside={(e) => {
|
||||
// Check if WordPress media modal is currently open
|
||||
const wpMediaOpen = document.querySelector('.media-modal');
|
||||
|
||||
if (wpMediaOpen) {
|
||||
// If WP media is open, ALWAYS prevent dialog from closing
|
||||
// regardless of where the click happened
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// If WP media is not open, prevent closing dialog for outside clicks
|
||||
e.preventDefault();
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
// Allow escape to close WP media modal
|
||||
const wpMediaOpen = document.querySelector('.media-modal');
|
||||
if (wpMediaOpen) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<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}.')}
|
||||
@@ -197,6 +315,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
<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>
|
||||
@@ -264,6 +383,112 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
</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>
|
||||
|
||||
@@ -1,4 +1,87 @@
|
||||
import { EmailBlock } from './types';
|
||||
import { EmailBlock, CardBlock, ButtonBlock, ImageBlock, SpacerBlock, CardType, ButtonStyle, ContentWidth, ContentAlign } from './types';
|
||||
|
||||
/**
|
||||
* Convert HTML tags to markdown
|
||||
*/
|
||||
function convertHtmlToMarkdown(html: string): string {
|
||||
let markdown = html;
|
||||
|
||||
// Headings
|
||||
markdown = markdown.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
|
||||
markdown = markdown.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
|
||||
markdown = markdown.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
|
||||
markdown = markdown.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '#### $1\n\n');
|
||||
|
||||
// Bold
|
||||
markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
|
||||
markdown = markdown.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
|
||||
|
||||
// Italic
|
||||
markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
|
||||
markdown = markdown.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');
|
||||
|
||||
// Links
|
||||
markdown = markdown.replace(/<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, '[$2]($1)');
|
||||
|
||||
// Paragraphs
|
||||
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');
|
||||
|
||||
// Line breaks
|
||||
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
|
||||
|
||||
// Lists
|
||||
markdown = markdown.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (match, content) => {
|
||||
return content.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n');
|
||||
});
|
||||
markdown = markdown.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (match, content) => {
|
||||
let counter = 1;
|
||||
return content.replace(/<li[^>]*>(.*?)<\/li>/gi, () => `${counter++}. $1\n`);
|
||||
});
|
||||
|
||||
// Clean up extra newlines
|
||||
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
return markdown.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert blocks directly to clean markdown (no HTML pollution)
|
||||
*/
|
||||
export function blocksToMarkdown(blocks: EmailBlock[]): string {
|
||||
return blocks.map(block => {
|
||||
switch (block.type) {
|
||||
case 'card': {
|
||||
const cardBlock = block as CardBlock;
|
||||
// Use new [card:type] syntax
|
||||
const cardSyntax = cardBlock.cardType !== 'default' ? `[card:${cardBlock.cardType}]` : '[card]';
|
||||
return `${cardSyntax}\n\n${cardBlock.content}\n\n[/card]`;
|
||||
}
|
||||
|
||||
case 'button': {
|
||||
const buttonBlock = block as ButtonBlock;
|
||||
// Use new [button:style](url)Text[/button] syntax
|
||||
const style = buttonBlock.style || 'solid';
|
||||
return `[button:${style}](${buttonBlock.link})${buttonBlock.text}[/button]`;
|
||||
}
|
||||
|
||||
case 'image': {
|
||||
const imageBlock = block as ImageBlock;
|
||||
return `[image src="${imageBlock.src}" alt="${imageBlock.alt || ''}" width="${imageBlock.widthMode}" align="${imageBlock.align}"]`;
|
||||
}
|
||||
|
||||
case 'divider':
|
||||
return '---';
|
||||
|
||||
case 'spacer': {
|
||||
const spacerBlock = block as SpacerBlock;
|
||||
return `[spacer height="${spacerBlock.height}"]`;
|
||||
}
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert blocks to [card] syntax HTML
|
||||
@@ -12,9 +95,29 @@ export function blocksToHTML(blocks: EmailBlock[]): string {
|
||||
}
|
||||
return `[card type="${block.cardType}"]\n${block.content}\n[/card]`;
|
||||
|
||||
case 'button':
|
||||
case 'button': {
|
||||
const buttonClass = block.style === 'solid' ? 'button' : 'button-outline';
|
||||
return `<p style="text-align: center;"><a href="${block.link}" class="${buttonClass}">${block.text}</a></p>`;
|
||||
const align = block.align || 'center';
|
||||
let linkStyle = '';
|
||||
if (block.widthMode === 'full') {
|
||||
linkStyle = 'display:block;width:100%;text-align:center;';
|
||||
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
|
||||
linkStyle = `display:block;max-width:${block.customMaxWidth}px;width:100%;margin:0 auto;`;
|
||||
}
|
||||
const styleAttr = linkStyle ? ` style="${linkStyle}"` : '';
|
||||
return `<p style="text-align: ${align};"><a href="${block.link}" class="${buttonClass}"${styleAttr}>${block.text}</a></p>`;
|
||||
}
|
||||
|
||||
case 'image': {
|
||||
let wrapperStyle = `text-align: ${block.align};`;
|
||||
let imgStyle = '';
|
||||
if (block.widthMode === 'full') {
|
||||
imgStyle = 'display:block;width:100%;height:auto;';
|
||||
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
|
||||
imgStyle = `display:block;max-width:${block.customMaxWidth}px;width:100%;height:auto;margin:0 auto;`;
|
||||
}
|
||||
return `<p style="${wrapperStyle}"><img src="${block.src}" alt="${block.alt || ''}" style="${imgStyle}" /></p>`;
|
||||
}
|
||||
|
||||
case 'divider':
|
||||
return `<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />`;
|
||||
@@ -29,14 +132,14 @@ export function blocksToHTML(blocks: EmailBlock[]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert [card] syntax HTML to blocks
|
||||
* Convert [card] syntax HTML or <div class="card"> HTML to blocks
|
||||
*/
|
||||
export function htmlToBlocks(html: string): EmailBlock[] {
|
||||
const blocks: EmailBlock[] = [];
|
||||
let blockId = 0;
|
||||
|
||||
// Split by [card] tags and other elements
|
||||
const cardRegex = /\[card([^\]]*)\](.*?)\[\/card\]/gs;
|
||||
// Match both [card] syntax and <div class="card"> HTML
|
||||
const cardRegex = /(?:\[card([^\]]*)\]([\s\S]*?)\[\/card\]|<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>)/gs;
|
||||
const parts: string[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
@@ -63,33 +166,122 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
||||
for (const part of parts) {
|
||||
const id = `block-${Date.now()}-${blockId++}`;
|
||||
|
||||
// Check if it's a card
|
||||
const cardMatch = part.match(/\[card([^\]]*)\](.*?)\[\/card\]/s);
|
||||
// Check if it's a card - match [card:type], [card type="..."], and <div class="card">
|
||||
let content = '';
|
||||
let cardType = 'default';
|
||||
|
||||
// Try new [card:type] syntax first
|
||||
let cardMatch = part.match(/\[card:(\w+)\]([\s\S]*?)\[\/card\]/s);
|
||||
if (cardMatch) {
|
||||
const attributes = cardMatch[1];
|
||||
const content = cardMatch[2].trim();
|
||||
const typeMatch = attributes.match(/type=["']([^"']+)["']/);
|
||||
const cardType = (typeMatch ? typeMatch[1] : 'default') as any;
|
||||
|
||||
cardType = cardMatch[1];
|
||||
content = cardMatch[2].trim();
|
||||
} else {
|
||||
// Try old [card type="..."] syntax
|
||||
cardMatch = part.match(/\[card([^\]]*)\]([\s\S]*?)\[\/card\]/s);
|
||||
if (cardMatch) {
|
||||
const attributes = cardMatch[1];
|
||||
content = cardMatch[2].trim();
|
||||
const typeMatch = attributes.match(/type=["']([^"']+)["']/);
|
||||
cardType = (typeMatch ? typeMatch[1] : 'default');
|
||||
}
|
||||
}
|
||||
|
||||
if (!cardMatch) {
|
||||
// <div class="card"> HTML syntax
|
||||
const htmlCardMatch = part.match(/<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>/s);
|
||||
if (htmlCardMatch) {
|
||||
cardType = (htmlCardMatch[1] || 'default');
|
||||
content = htmlCardMatch[2].trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (content) {
|
||||
// Convert HTML content to markdown for clean editing
|
||||
// But only if it actually contains HTML tags
|
||||
const hasHtmlTags = /<[^>]+>/.test(content);
|
||||
const markdownContent = hasHtmlTags ? convertHtmlToMarkdown(content) : content;
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'card',
|
||||
cardType,
|
||||
content
|
||||
cardType: cardType as any,
|
||||
content: markdownContent
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's a button
|
||||
// Check if it's a button - try new syntax first
|
||||
let buttonMatch = part.match(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/);
|
||||
if (buttonMatch) {
|
||||
const style = buttonMatch[1] as ButtonStyle;
|
||||
const url = buttonMatch[2];
|
||||
const text = buttonMatch[3].trim();
|
||||
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'button',
|
||||
link: url,
|
||||
text: text,
|
||||
style: style,
|
||||
align: 'center',
|
||||
widthMode: 'fit'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try old [button url="..."] syntax
|
||||
buttonMatch = part.match(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](\w+)["'])?\]([^\[]+)\[\/button\]/);
|
||||
if (buttonMatch) {
|
||||
const url = buttonMatch[1];
|
||||
const style = (buttonMatch[2] || 'solid') as ButtonStyle;
|
||||
const text = buttonMatch[3].trim();
|
||||
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'button',
|
||||
link: url,
|
||||
text: text,
|
||||
style: style,
|
||||
align: 'center',
|
||||
widthMode: 'fit'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check HTML button syntax
|
||||
if (part.includes('class="button"') || part.includes('class="button-outline"')) {
|
||||
const buttonMatch = part.match(/<a[^>]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*>([^<]*)<\/a>/);
|
||||
const buttonMatch = part.match(/<a[^>]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*style="([^"]*)"[^>]*>([^<]*)<\/a>/) ||
|
||||
part.match(/<a[^>]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*>([^<]*)<\/a>/);
|
||||
if (buttonMatch) {
|
||||
const hasStyle = buttonMatch.length === 5;
|
||||
const styleAttr = hasStyle ? buttonMatch[3] : '';
|
||||
const textIndex = hasStyle ? 4 : 3;
|
||||
const styleClassIndex = 2;
|
||||
|
||||
let widthMode: any = 'fit';
|
||||
let customMaxWidth: number | undefined = undefined;
|
||||
if (styleAttr.includes('width:100%') && !styleAttr.includes('max-width')) {
|
||||
widthMode = 'full';
|
||||
} else if (styleAttr.includes('max-width')) {
|
||||
widthMode = 'custom';
|
||||
const maxMatch = styleAttr.match(/max-width:(\d+)px/);
|
||||
if (maxMatch) {
|
||||
customMaxWidth = parseInt(maxMatch[1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract alignment from parent <p> tag if present
|
||||
const alignMatch = part.match(/text-align:\s*(left|center|right)/);
|
||||
const align = alignMatch ? alignMatch[1] as any : 'center';
|
||||
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'button',
|
||||
text: buttonMatch[3],
|
||||
text: buttonMatch[textIndex],
|
||||
link: buttonMatch[1],
|
||||
style: buttonMatch[2].includes('outline') ? 'outline' : 'solid'
|
||||
style: buttonMatch[styleClassIndex].includes('outline') ? 'outline' : 'solid',
|
||||
widthMode,
|
||||
customMaxWidth,
|
||||
align,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -111,3 +303,110 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert clean markdown directly to blocks (no HTML intermediary)
|
||||
*/
|
||||
export function markdownToBlocks(markdown: string): EmailBlock[] {
|
||||
const blocks: EmailBlock[] = [];
|
||||
let blockId = 0;
|
||||
|
||||
// Parse markdown respecting [card]...[/card] and [button]...[/button] boundaries
|
||||
let remaining = markdown;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
remaining = remaining.trim();
|
||||
if (!remaining) break;
|
||||
|
||||
const id = `block-${Date.now()}-${blockId++}`;
|
||||
|
||||
// Check for [card] blocks - match with proper boundaries
|
||||
const cardMatch = remaining.match(/^\[card([^\]]*)\]([\s\S]*?)\[\/card\]/);
|
||||
if (cardMatch) {
|
||||
const attributes = cardMatch[1].trim();
|
||||
const content = cardMatch[2].trim();
|
||||
|
||||
// Extract card type
|
||||
const typeMatch = attributes.match(/type\s*=\s*["']([^"']+)["']/);
|
||||
const cardType = (typeMatch?.[1] || 'default') as CardType;
|
||||
|
||||
// Extract background
|
||||
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
|
||||
const bg = bgMatch?.[1];
|
||||
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'card',
|
||||
cardType,
|
||||
content,
|
||||
bg,
|
||||
});
|
||||
|
||||
// Advance past this card
|
||||
remaining = remaining.substring(cardMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for [button] blocks
|
||||
const buttonMatch = remaining.match(/^\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/);
|
||||
if (buttonMatch) {
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'button',
|
||||
text: buttonMatch[3].trim(),
|
||||
link: buttonMatch[1],
|
||||
style: (buttonMatch[2] || 'solid') as ButtonStyle,
|
||||
align: 'center',
|
||||
widthMode: 'fit',
|
||||
});
|
||||
|
||||
remaining = remaining.substring(buttonMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for [image] blocks
|
||||
const imageMatch = remaining.match(/^\[image\s+src=["']([^"']+)["'](?:\s+alt=["']([^"']*)["'])?(?:\s+width=["']([^"']+)["'])?(?:\s+align=["']([^"']+)["'])?\]/);
|
||||
if (imageMatch) {
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'image',
|
||||
src: imageMatch[1],
|
||||
alt: imageMatch[2] || '',
|
||||
widthMode: (imageMatch[3] || 'fit') as ContentWidth,
|
||||
align: (imageMatch[4] || 'center') as ContentAlign,
|
||||
});
|
||||
|
||||
remaining = remaining.substring(imageMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for [spacer] blocks
|
||||
const spacerMatch = remaining.match(/^\[spacer\s+height=["'](\d+)["']\]/);
|
||||
if (spacerMatch) {
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'spacer',
|
||||
height: parseInt(spacerMatch[1]),
|
||||
});
|
||||
|
||||
remaining = remaining.substring(spacerMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for horizontal rule
|
||||
if (remaining.startsWith('---')) {
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'divider',
|
||||
});
|
||||
|
||||
remaining = remaining.substring(3);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If nothing matches, skip this character to avoid infinite loop
|
||||
remaining = remaining.substring(1);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { EmailBuilder } from './EmailBuilder';
|
||||
export { BlockRenderer } from './BlockRenderer';
|
||||
export { blocksToHTML, htmlToBlocks } from './converter';
|
||||
export { blocksToHTML, htmlToBlocks, blocksToMarkdown, markdownToBlocks } from './converter';
|
||||
export * from './types';
|
||||
|
||||
73
admin-spa/src/components/EmailBuilder/markdown-converter.ts
Normal file
73
admin-spa/src/components/EmailBuilder/markdown-converter.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { EmailBlock, CardType, ButtonStyle } from './types';
|
||||
|
||||
/**
|
||||
* Convert markdown to blocks - respects [card]...[/card] boundaries
|
||||
*/
|
||||
export function markdownToBlocks(markdown: string): EmailBlock[] {
|
||||
const blocks: EmailBlock[] = [];
|
||||
let blockId = 0;
|
||||
let pos = 0;
|
||||
|
||||
while (pos < markdown.length) {
|
||||
// Skip whitespace
|
||||
while (pos < markdown.length && /\s/.test(markdown[pos])) pos++;
|
||||
if (pos >= markdown.length) break;
|
||||
|
||||
const id = `block-${Date.now()}-${blockId++}`;
|
||||
|
||||
// Check for [card]
|
||||
if (markdown.substr(pos, 5) === '[card') {
|
||||
const cardStart = pos;
|
||||
const cardOpenEnd = markdown.indexOf(']', pos);
|
||||
const cardClose = markdown.indexOf('[/card]', pos);
|
||||
|
||||
if (cardOpenEnd !== -1 && cardClose !== -1) {
|
||||
const attributes = markdown.substring(pos + 5, cardOpenEnd);
|
||||
const content = markdown.substring(cardOpenEnd + 1, cardClose).trim();
|
||||
|
||||
// Parse type
|
||||
const typeMatch = attributes.match(/type\s*=\s*["']([^"']+)["']/);
|
||||
const cardType = (typeMatch?.[1] || 'default') as CardType;
|
||||
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'card',
|
||||
cardType,
|
||||
content,
|
||||
});
|
||||
|
||||
pos = cardClose + 7; // Skip [/card]
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for [button]
|
||||
if (markdown.substr(pos, 7) === '[button') {
|
||||
const buttonEnd = markdown.indexOf('[/button]', pos);
|
||||
if (buttonEnd !== -1) {
|
||||
const fullButton = markdown.substring(pos, buttonEnd + 9);
|
||||
const match = fullButton.match(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/);
|
||||
|
||||
if (match) {
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'button',
|
||||
text: match[3].trim(),
|
||||
link: match[1],
|
||||
style: (match[2] || 'solid') as ButtonStyle,
|
||||
align: 'center',
|
||||
widthMode: 'fit',
|
||||
});
|
||||
|
||||
pos = buttonEnd + 9;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip unknown content
|
||||
pos++;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
export type BlockType = 'card' | 'button' | 'divider' | 'spacer';
|
||||
export type BlockType = 'card' | 'button' | 'divider' | 'spacer' | 'image';
|
||||
|
||||
export type CardType = 'default' | 'success' | 'info' | 'warning' | 'hero';
|
||||
export type CardType = 'default' | 'success' | 'info' | 'warning' | 'hero' | 'basic';
|
||||
|
||||
export type ButtonStyle = 'solid' | 'outline';
|
||||
|
||||
export type ContentWidth = 'fit' | 'full' | 'custom';
|
||||
|
||||
export type ContentAlign = 'left' | 'center' | 'right';
|
||||
|
||||
export interface BaseBlock {
|
||||
id: string;
|
||||
type: BlockType;
|
||||
@@ -21,6 +25,18 @@ export interface ButtonBlock extends BaseBlock {
|
||||
text: string;
|
||||
link: string;
|
||||
style: ButtonStyle;
|
||||
widthMode?: ContentWidth;
|
||||
customMaxWidth?: number;
|
||||
align?: ContentAlign;
|
||||
}
|
||||
|
||||
export interface ImageBlock extends BaseBlock {
|
||||
type: 'image';
|
||||
src: string;
|
||||
alt?: string;
|
||||
widthMode: ContentWidth;
|
||||
customMaxWidth?: number;
|
||||
align: ContentAlign;
|
||||
}
|
||||
|
||||
export interface DividerBlock extends BaseBlock {
|
||||
@@ -32,7 +48,12 @@ export interface SpacerBlock extends BaseBlock {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type EmailBlock = CardBlock | ButtonBlock | DividerBlock | SpacerBlock;
|
||||
export type EmailBlock =
|
||||
| CardBlock
|
||||
| ButtonBlock
|
||||
| DividerBlock
|
||||
| SpacerBlock
|
||||
| ImageBlock;
|
||||
|
||||
export interface EmailTemplate {
|
||||
blocks: EmailBlock[];
|
||||
|
||||
@@ -1,36 +1,53 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { EditorView, basicSetup } from 'codemirror';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { Button } from './button';
|
||||
import { parseMarkdownToEmail, parseEmailToMarkdown } from '@/lib/markdown-parser';
|
||||
import { MarkdownToolbar } from './markdown-toolbar';
|
||||
|
||||
interface CodeEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
supportMarkdown?: boolean;
|
||||
supportMarkdown?: boolean; // Keep for backward compatibility but always use markdown
|
||||
}
|
||||
|
||||
export function CodeEditor({ value, onChange, placeholder, supportMarkdown = false }: CodeEditorProps) {
|
||||
const [mode, setMode] = useState<'html' | 'markdown'>('html');
|
||||
export function CodeEditor({ value, onChange, placeholder }: CodeEditorProps) {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
|
||||
// Handle markdown insertions from toolbar
|
||||
const handleInsert = (before: string, after: string = '') => {
|
||||
if (!viewRef.current) return;
|
||||
|
||||
const view = viewRef.current;
|
||||
const selection = view.state.selection.main;
|
||||
const selectedText = view.state.doc.sliceString(selection.from, selection.to);
|
||||
|
||||
// Insert the markdown syntax
|
||||
const newText = before + selectedText + after;
|
||||
view.dispatch({
|
||||
changes: { from: selection.from, to: selection.to, insert: newText },
|
||||
selection: { anchor: selection.from + before.length + selectedText.length }
|
||||
});
|
||||
|
||||
// Focus back to editor
|
||||
view.focus();
|
||||
};
|
||||
|
||||
// Initialize editor once
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
const view = new EditorView({
|
||||
doc: mode === 'markdown' ? parseEmailToMarkdown(value) : value,
|
||||
doc: value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
mode === 'markdown' ? markdown() : html(),
|
||||
markdown(),
|
||||
oneDark,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
const content = update.state.doc.toString();
|
||||
onChange(mode === 'markdown' ? parseMarkdownToEmail(content) : content);
|
||||
onChange(content);
|
||||
}
|
||||
}),
|
||||
],
|
||||
@@ -42,52 +59,33 @@ export function CodeEditor({ value, onChange, placeholder, supportMarkdown = fal
|
||||
return () => {
|
||||
view.destroy();
|
||||
};
|
||||
}, [mode]);
|
||||
}, []); // Only run once on mount
|
||||
|
||||
// Update editor when value prop changes
|
||||
// Update editor when value prop changes from external source
|
||||
useEffect(() => {
|
||||
if (viewRef.current) {
|
||||
const displayValue = mode === 'markdown' ? parseEmailToMarkdown(value) : value;
|
||||
if (displayValue !== viewRef.current.state.doc.toString()) {
|
||||
viewRef.current.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: viewRef.current.state.doc.length,
|
||||
insert: displayValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (viewRef.current && value !== viewRef.current.state.doc.toString()) {
|
||||
viewRef.current.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: viewRef.current.state.doc.length,
|
||||
insert: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [value, mode]);
|
||||
|
||||
const toggleMode = () => {
|
||||
setMode(mode === 'html' ? 'markdown' : 'html');
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{supportMarkdown && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleMode}
|
||||
className="text-xs"
|
||||
>
|
||||
{mode === 'html' ? '📝 Switch to Markdown' : '🔧 Switch to HTML'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={editorRef}
|
||||
className="border rounded-md overflow-hidden min-h-[400px] font-mono text-sm"
|
||||
/>
|
||||
{supportMarkdown && mode === 'markdown' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 Markdown syntax: Use <code>:::</code> for cards, <code>[button](url){text}</code> for buttons
|
||||
</p>
|
||||
)}
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<MarkdownToolbar onInsert={handleInsert} />
|
||||
<div
|
||||
ref={editorRef}
|
||||
className="min-h-[400px] font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 Use the toolbar above or type markdown directly: **bold**, ## headings, [card]...[/card], [button]...[/button]
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
232
admin-spa/src/components/ui/markdown-toolbar.tsx
Normal file
232
admin-spa/src/components/ui/markdown-toolbar.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './button';
|
||||
import { Bold, Italic, Heading1, Heading2, Link, List, ListOrdered, Quote, Code, Square, Plus, Image, MousePointer } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from './dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './select';
|
||||
|
||||
interface MarkdownToolbarProps {
|
||||
onInsert: (before: string, after?: string) => void;
|
||||
}
|
||||
|
||||
export function MarkdownToolbar({ onInsert }: MarkdownToolbarProps) {
|
||||
const [showCardDialog, setShowCardDialog] = useState(false);
|
||||
const [selectedCardType, setSelectedCardType] = useState('default');
|
||||
const [showButtonDialog, setShowButtonDialog] = useState(false);
|
||||
const [buttonStyle, setButtonStyle] = useState('solid');
|
||||
const [showImageDialog, setShowImageDialog] = useState(false);
|
||||
|
||||
const tools = [
|
||||
{ icon: Bold, label: 'Bold', before: '**', after: '**' },
|
||||
{ icon: Italic, label: 'Italic', before: '*', after: '*' },
|
||||
{ icon: Heading1, label: 'Heading 1', before: '# ', after: '' },
|
||||
{ icon: Heading2, label: 'Heading 2', before: '## ', after: '' },
|
||||
{ icon: Link, label: 'Link', before: '[', after: '](url)' },
|
||||
{ icon: List, label: 'Bullet List', before: '- ', after: '' },
|
||||
{ icon: ListOrdered, label: 'Numbered List', before: '1. ', after: '' },
|
||||
{ icon: Quote, label: 'Quote', before: '> ', after: '' },
|
||||
{ icon: Code, label: 'Code', before: '`', after: '`' },
|
||||
];
|
||||
|
||||
const cardTypes = [
|
||||
{ value: 'default', label: 'Default', description: 'Standard white card' },
|
||||
{ value: 'hero', label: 'Hero', description: 'Large header card with gradient' },
|
||||
{ value: 'success', label: 'Success', description: 'Green success message' },
|
||||
{ value: 'warning', label: 'Warning', description: 'Yellow warning message' },
|
||||
{ value: 'info', label: 'Info', description: 'Blue information card' },
|
||||
{ value: 'basic', label: 'Basic', description: 'Minimal styling' },
|
||||
];
|
||||
|
||||
const handleInsertCard = () => {
|
||||
const cardTemplate = selectedCardType === 'default'
|
||||
? '[card]\n\n## Your heading here\n\nYour content here...\n\n[/card]'
|
||||
: `[card:${selectedCardType}]\n\n## Your heading here\n\nYour content here...\n\n[/card]`;
|
||||
|
||||
onInsert(cardTemplate, '');
|
||||
setShowCardDialog(false);
|
||||
};
|
||||
|
||||
const handleInsertButton = () => {
|
||||
const buttonTemplate = `[button:${buttonStyle}](https://example.com)Click me[/button]`;
|
||||
onInsert(buttonTemplate, '');
|
||||
setShowButtonDialog(false);
|
||||
};
|
||||
|
||||
const handleInsertImage = () => {
|
||||
const imageTemplate = ``;
|
||||
onInsert(imageTemplate, '');
|
||||
setShowImageDialog(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 p-2 border-b bg-muted/30">
|
||||
{/* Card Insert Button with Dialog */}
|
||||
<Dialog open={showCardDialog} onOpenChange={setShowCardDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 gap-1"
|
||||
title="Insert Card"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Insert Card</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a card type to insert into your template
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Card Type</label>
|
||||
<Select value={selectedCardType} onValueChange={setSelectedCardType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cardTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<div>
|
||||
<div className="font-medium">{type.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{type.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleInsertCard} className="w-full">
|
||||
Insert Card
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Button Insert Dialog */}
|
||||
<Dialog open={showButtonDialog} onOpenChange={setShowButtonDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 gap-1"
|
||||
title="Insert Button"
|
||||
>
|
||||
<MousePointer className="h-4 w-4" />
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Insert Button</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a button style to insert
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Button Style</label>
|
||||
<Select value={buttonStyle} onValueChange={setButtonStyle}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">
|
||||
<div>
|
||||
<div className="font-medium">Solid</div>
|
||||
<div className="text-xs text-muted-foreground">Filled background</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="outline">
|
||||
<div>
|
||||
<div className="font-medium">Outline</div>
|
||||
<div className="text-xs text-muted-foreground">Border only</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleInsertButton} className="w-full">
|
||||
Insert Button
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Image Insert Dialog */}
|
||||
<Dialog open={showImageDialog} onOpenChange={setShowImageDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 gap-1"
|
||||
title="Insert Image"
|
||||
>
|
||||
<Image className="h-4 w-4" />
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Insert Image</DialogTitle>
|
||||
<DialogDescription>
|
||||
Insert an image using standard Markdown syntax
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>Syntax: <code className="px-1 py-0.5 bg-muted rounded"></code></p>
|
||||
<p className="mt-2">Example: <code className="px-1 py-0.5 bg-muted rounded"></code></p>
|
||||
</div>
|
||||
<Button onClick={handleInsertImage} className="w-full">
|
||||
Insert Image Template
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-8 bg-border" />
|
||||
|
||||
{/* Other formatting tools */}
|
||||
{tools.map((tool) => (
|
||||
<Button
|
||||
key={tool.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onInsert(tool.before, tool.after)}
|
||||
className="h-8 w-8 p-0"
|
||||
title={tool.label}
|
||||
>
|
||||
<tool.icon className="h-4 w-4" />
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="hidden sm:inline">Quick formatting:</span>
|
||||
<code className="px-1 py-0.5 bg-muted rounded">**bold**</code>
|
||||
<code className="px-1 py-0.5 bg-muted rounded">## heading</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -168,4 +168,25 @@ body.woonoow-fullscreen .woonoow-app { overflow: visible; }
|
||||
html #wpadminbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* WordPress Media Modal z-index fix */
|
||||
/* Ensure WP media modal appears above Radix UI components (Dialog, Select, etc.) */
|
||||
.media-modal {
|
||||
z-index: 999999 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
/* Ensure media modal content is above the backdrop and receives clicks */
|
||||
.media-modal-content {
|
||||
z-index: 1000000 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
/* Ensure all interactive elements in WP media can receive clicks */
|
||||
.media-modal .media-frame,
|
||||
.media-modal .media-toolbar,
|
||||
.media-modal .attachments,
|
||||
.media-modal .attachment {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
64
admin-spa/src/lib/html-to-markdown.ts
Normal file
64
admin-spa/src/lib/html-to-markdown.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Convert HTML to Markdown
|
||||
* Simple converter for rich text editor output
|
||||
*/
|
||||
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
if (!html) return '';
|
||||
|
||||
let markdown = html;
|
||||
|
||||
// Headings
|
||||
markdown = markdown.replace(/<h1>(.*?)<\/h1>/gi, '# $1\n\n');
|
||||
markdown = markdown.replace(/<h2>(.*?)<\/h2>/gi, '## $1\n\n');
|
||||
markdown = markdown.replace(/<h3>(.*?)<\/h3>/gi, '### $1\n\n');
|
||||
markdown = markdown.replace(/<h4>(.*?)<\/h4>/gi, '#### $1\n\n');
|
||||
|
||||
// Bold
|
||||
markdown = markdown.replace(/<strong>(.*?)<\/strong>/gi, '**$1**');
|
||||
markdown = markdown.replace(/<b>(.*?)<\/b>/gi, '**$1**');
|
||||
|
||||
// Italic
|
||||
markdown = markdown.replace(/<em>(.*?)<\/em>/gi, '*$1*');
|
||||
markdown = markdown.replace(/<i>(.*?)<\/i>/gi, '*$1*');
|
||||
|
||||
// Links
|
||||
markdown = markdown.replace(/<a\s+href="([^"]+)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
|
||||
|
||||
// Lists
|
||||
markdown = markdown.replace(/<ul[^>]*>(.*?)<\/ul>/gis, (match, content) => {
|
||||
const items = content.match(/<li[^>]*>(.*?)<\/li>/gis) || [];
|
||||
return items.map((item: string) => {
|
||||
const text = item.replace(/<li[^>]*>(.*?)<\/li>/is, '$1').trim();
|
||||
return `- ${text}`;
|
||||
}).join('\n') + '\n\n';
|
||||
});
|
||||
|
||||
markdown = markdown.replace(/<ol[^>]*>(.*?)<\/ol>/gis, (match, content) => {
|
||||
const items = content.match(/<li[^>]*>(.*?)<\/li>/gis) || [];
|
||||
return items.map((item: string, index: number) => {
|
||||
const text = item.replace(/<li[^>]*>(.*?)<\/li>/is, '$1').trim();
|
||||
return `${index + 1}. ${text}`;
|
||||
}).join('\n') + '\n\n';
|
||||
});
|
||||
|
||||
// Paragraphs - convert to double newlines
|
||||
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gis, '$1\n\n');
|
||||
|
||||
// Line breaks
|
||||
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
|
||||
|
||||
// Horizontal rules
|
||||
markdown = markdown.replace(/<hr\s*\/?>/gi, '\n---\n\n');
|
||||
|
||||
// Remove remaining HTML tags
|
||||
markdown = markdown.replace(/<[^>]+>/g, '');
|
||||
|
||||
// Clean up excessive newlines
|
||||
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
// Trim
|
||||
markdown = markdown.trim();
|
||||
|
||||
return markdown;
|
||||
}
|
||||
@@ -2,10 +2,11 @@
|
||||
* Markdown to Email HTML Parser
|
||||
*
|
||||
* Supports:
|
||||
* - Standard Markdown (headings, bold, italic, lists, links)
|
||||
* - Standard Markdown (headings, bold, italic, lists, links, horizontal rules)
|
||||
* - Card blocks with ::: syntax
|
||||
* - Button blocks with [button] syntax
|
||||
* - Button blocks with [button url="..."]Text[/button] syntax
|
||||
* - Variables with {variable_name}
|
||||
* - Checkmarks (✓) and bullet points (•)
|
||||
*/
|
||||
|
||||
export function parseMarkdownToEmail(markdown: string): string {
|
||||
@@ -18,10 +19,14 @@ export function parseMarkdownToEmail(markdown: string): string {
|
||||
return `[card${type ? ` type="${cardType}"` : ''}]\n${parsedContent}\n[/card]`;
|
||||
});
|
||||
|
||||
// Parse button blocks [button](url) or [button style="outline"](url)
|
||||
// Parse button blocks [button url="..."]Text[/button] - already in correct format
|
||||
// Also support legacy [button](url){text} syntax
|
||||
html = html.replace(/\[button(?:\s+style="(solid|outline)")?\]\((.*?)\)\s*\{([^}]+)\}/g, (match, style, url, text) => {
|
||||
return `[button link="${url}"${style ? ` style="${style}"` : ' style="solid"'}]${text}[/button]`;
|
||||
return `[button url="${url}"${style ? ` style="${style}"` : ''}]${text}[/button]`;
|
||||
});
|
||||
|
||||
// Horizontal rules
|
||||
html = html.replace(/^---$/gm, '<hr>');
|
||||
|
||||
// Parse remaining markdown (outside cards)
|
||||
html = parseMarkdownBasics(html);
|
||||
@@ -49,9 +54,8 @@ function parseMarkdownBasics(text: string): string {
|
||||
// Links (but not button syntax)
|
||||
html = html.replace(/\[(?!button)([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||
|
||||
// Unordered lists
|
||||
html = html.replace(/^\* (.*$)/gim, '<li>$1</li>');
|
||||
html = html.replace(/^- (.*$)/gim, '<li>$1</li>');
|
||||
// Unordered lists (including checkmarks and bullets)
|
||||
html = html.replace(/^[\*\-•✓] (.*$)/gim, '<li>$1</li>');
|
||||
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
|
||||
|
||||
// Ordered lists
|
||||
@@ -82,12 +86,14 @@ export function parseEmailToMarkdown(html: string): string {
|
||||
return type ? `:::card[${type}]\n${mdContent}\n:::` : `:::card\n${mdContent}\n:::`;
|
||||
});
|
||||
|
||||
// Convert [button] blocks to markdown syntax
|
||||
// Convert [button] blocks - keep new syntax [button url="..."]Text[/button]
|
||||
// This is already the format we want, so just normalize
|
||||
markdown = markdown.replace(/\[button link="([^"]+)"(?:\s+style="(solid|outline)")?\]([^[]+)\[\/button\]/g, (match, url, style, text) => {
|
||||
return style && style !== 'solid'
|
||||
? `[button style="${style}"](${url}){${text.trim()}}`
|
||||
: `[button](${url}){${text.trim()}}`;
|
||||
return `[button url="${url}"${style ? ` style="${style}"` : ''}]${text.trim()}[/button]`;
|
||||
});
|
||||
|
||||
// Convert horizontal rules
|
||||
markdown = markdown.replace(/<hr\s*\/?>/gi, '\n---\n');
|
||||
|
||||
// Convert remaining HTML to markdown
|
||||
markdown = parseHtmlToMarkdownBasics(markdown);
|
||||
|
||||
322
admin-spa/src/lib/markdown-utils.ts
Normal file
322
admin-spa/src/lib/markdown-utils.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Markdown Detection and Conversion Utilities
|
||||
*
|
||||
* Handles detection of markdown vs HTML content and conversion between formats
|
||||
*/
|
||||
|
||||
/**
|
||||
* Detect if content is markdown or HTML
|
||||
*
|
||||
* @param content - The content to check
|
||||
* @returns 'markdown' | 'html'
|
||||
*/
|
||||
export function detectContentType(content: string): 'markdown' | 'html' {
|
||||
if (!content || content.trim() === '') {
|
||||
return 'html';
|
||||
}
|
||||
|
||||
// Check for markdown-specific patterns
|
||||
const markdownPatterns = [
|
||||
/^\*\*[^*]+\*\*/m, // **bold**
|
||||
/^__[^_]+__/m, // __bold__
|
||||
/^\*[^*]+\*/m, // *italic*
|
||||
/^_[^_]+_/m, // _italic_
|
||||
/^#{1,6}\s/m, // # headings
|
||||
/^\[card[^\]]*\]/m, // [card] syntax
|
||||
/^\[button\s+url=/m, // [button url=...] syntax
|
||||
/^---$/m, // horizontal rules
|
||||
/^[\*\-•✓]\s/m, // bullet points
|
||||
];
|
||||
|
||||
// Check for HTML-specific patterns
|
||||
const htmlPatterns = [
|
||||
/<[a-z][\s\S]*>/i, // HTML tags
|
||||
/<\/[a-z]+>/i, // Closing tags
|
||||
/&[a-z]+;/i, // HTML entities
|
||||
];
|
||||
|
||||
// Count markdown vs HTML indicators
|
||||
let markdownScore = 0;
|
||||
let htmlScore = 0;
|
||||
|
||||
for (const pattern of markdownPatterns) {
|
||||
if (pattern.test(content)) {
|
||||
markdownScore++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of htmlPatterns) {
|
||||
if (pattern.test(content)) {
|
||||
htmlScore++;
|
||||
}
|
||||
}
|
||||
|
||||
// If content has [card] or [button] syntax, it's definitely our markdown format
|
||||
if (/\[card[^\]]*\]/.test(content) || /\[button\s+url=/.test(content)) {
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
// If content has HTML tags but no markdown, it's HTML
|
||||
if (htmlScore > 0 && markdownScore === 0) {
|
||||
return 'html';
|
||||
}
|
||||
|
||||
// If content has markdown indicators, it's markdown
|
||||
if (markdownScore > 0) {
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
// Default to HTML for safety
|
||||
return 'html';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown to HTML for display
|
||||
*
|
||||
* @param markdown - Markdown content
|
||||
* @returns HTML content
|
||||
*/
|
||||
export function markdownToHtml(markdown: string): string {
|
||||
if (!markdown) return '';
|
||||
|
||||
let html = markdown;
|
||||
|
||||
// Parse [card:type] blocks (new syntax)
|
||||
html = html.replace(/\[card:(\w+)\]([\s\S]*?)\[\/card\]/g, (match, type, content) => {
|
||||
const cardClass = `card card-${type}`;
|
||||
const parsedContent = parseMarkdownBasics(content.trim());
|
||||
return `<div class="${cardClass}">${parsedContent}</div>`;
|
||||
});
|
||||
|
||||
// Parse [card type="..."] blocks (old syntax - backward compatibility)
|
||||
html = html.replace(/\[card(?:\s+type="([^"]+)")?\]([\s\S]*?)\[\/card\]/g, (match, type, content) => {
|
||||
const cardClass = type ? `card card-${type}` : 'card';
|
||||
const parsedContent = parseMarkdownBasics(content.trim());
|
||||
return `<div class="${cardClass}">${parsedContent}</div>`;
|
||||
});
|
||||
|
||||
// Parse [button:style](url)Text[/button] (new syntax)
|
||||
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => {
|
||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||
});
|
||||
|
||||
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
|
||||
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||
});
|
||||
|
||||
// Parse remaining markdown
|
||||
html = parseMarkdownBasics(html);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse basic markdown syntax to HTML (exported for use in components)
|
||||
*
|
||||
* @param text - Markdown text
|
||||
* @returns HTML text
|
||||
*/
|
||||
export function parseMarkdownBasics(text: string): string {
|
||||
let html = text;
|
||||
|
||||
// Protect variables from markdown parsing by temporarily replacing them
|
||||
const variables: { [key: string]: string } = {};
|
||||
let varIndex = 0;
|
||||
html = html.replace(/\{([^}]+)\}/g, (match, varName) => {
|
||||
const placeholder = `<!--VAR${varIndex}-->`;
|
||||
variables[placeholder] = match;
|
||||
varIndex++;
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// Headings
|
||||
html = html.replace(/^#### (.*$)/gim, '<h4>$1</h4>');
|
||||
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
|
||||
|
||||
// Bold (don't match across newlines)
|
||||
html = html.replace(/\*\*([^\n*]+?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/__([^\n_]+?)__/g, '<strong>$1</strong>');
|
||||
|
||||
// Italic (don't match across newlines)
|
||||
html = html.replace(/\*([^\n*]+?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/_([^\n_]+?)_/g, '<em>$1</em>');
|
||||
|
||||
// Horizontal rules
|
||||
html = html.replace(/^---$/gm, '<hr>');
|
||||
|
||||
// Parse [button:style](url)Text[/button] (new syntax) - must come before images
|
||||
// Allow whitespace and newlines between parts
|
||||
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([\s\S]*?)\[\/button\]/g, (match, style, url, text) => {
|
||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||
});
|
||||
|
||||
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
|
||||
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||
});
|
||||
|
||||
// Images (must come before links)
|
||||
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width: 100%; height: auto; display: block; margin: 16px 0;">');
|
||||
|
||||
// Links (but not button syntax)
|
||||
html = html.replace(/\[(?!button)([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||
|
||||
// Process lines for paragraphs and lists
|
||||
const lines = html.split('\n');
|
||||
let inList = false;
|
||||
let paragraphContent = '';
|
||||
const processedLines: string[] = [];
|
||||
|
||||
const closeParagraph = () => {
|
||||
if (paragraphContent) {
|
||||
processedLines.push(`<p>${paragraphContent}</p>`);
|
||||
paragraphContent = '';
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Empty line - close paragraph or list
|
||||
if (!trimmed) {
|
||||
if (inList) {
|
||||
processedLines.push('</ul>');
|
||||
inList = false;
|
||||
}
|
||||
closeParagraph();
|
||||
processedLines.push('');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if line is a list item
|
||||
if (/^[\*\-•✓]\s/.test(trimmed)) {
|
||||
closeParagraph();
|
||||
const content = trimmed.replace(/^[\*\-•✓]\s/, '');
|
||||
if (!inList) {
|
||||
processedLines.push('<ul>');
|
||||
inList = true;
|
||||
}
|
||||
processedLines.push(`<li>${content}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Close list if we're in one
|
||||
if (inList) {
|
||||
processedLines.push('</ul>');
|
||||
inList = false;
|
||||
}
|
||||
|
||||
// Block-level HTML tags - don't wrap in paragraph
|
||||
// But inline tags like <strong>, <em>, <a> should be part of paragraph
|
||||
const blockTags = /^<(div|h1|h2|h3|h4|h5|h6|p|ul|ol|li|hr|table|blockquote)/i;
|
||||
if (blockTags.test(trimmed)) {
|
||||
closeParagraph();
|
||||
processedLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular text line - accumulate in paragraph
|
||||
if (paragraphContent) {
|
||||
// Add line break before continuation
|
||||
paragraphContent += '<br>' + trimmed;
|
||||
} else {
|
||||
// Start new paragraph
|
||||
paragraphContent = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
// Close any open tags
|
||||
if (inList) {
|
||||
processedLines.push('</ul>');
|
||||
}
|
||||
closeParagraph();
|
||||
|
||||
html = processedLines.join('\n');
|
||||
|
||||
// Restore variables
|
||||
Object.entries(variables).forEach(([placeholder, original]) => {
|
||||
html = html.replace(new RegExp(placeholder, 'g'), original);
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HTML back to markdown (for editing)
|
||||
*
|
||||
* @param html - HTML content
|
||||
* @returns Markdown content
|
||||
*/
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
if (!html) return '';
|
||||
|
||||
let markdown = html;
|
||||
|
||||
// Convert <div class="card"> back to [card]
|
||||
markdown = markdown.replace(/<div class="card(?:\s+card-([^"]+))?">([\s\S]*?)<\/div>/g, (match, type, content) => {
|
||||
const mdContent = parseHtmlToMarkdownBasics(content.trim());
|
||||
return type ? `[card type="${type}"]\n${mdContent}\n[/card]` : `[card]\n${mdContent}\n[/card]`;
|
||||
});
|
||||
|
||||
// Convert buttons back to [button] syntax
|
||||
markdown = markdown.replace(/<p[^>]*><a href="([^"]+)" class="(button[^"]*)"[^>]*>([^<]+)<\/a><\/p>/g, (match, url, className, text) => {
|
||||
const style = className.includes('outline') ? ' style="outline"' : '';
|
||||
return `[button url="${url}"${style}]${text.trim()}[/button]`;
|
||||
});
|
||||
|
||||
// Convert remaining HTML to markdown
|
||||
markdown = parseHtmlToMarkdownBasics(markdown);
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTML back to basic markdown
|
||||
*
|
||||
* @param html - HTML text
|
||||
* @returns Markdown text
|
||||
*/
|
||||
function parseHtmlToMarkdownBasics(html: string): string {
|
||||
let markdown = html;
|
||||
|
||||
// Headings
|
||||
markdown = markdown.replace(/<h1>(.*?)<\/h1>/gi, '# $1');
|
||||
markdown = markdown.replace(/<h2>(.*?)<\/h2>/gi, '## $1');
|
||||
markdown = markdown.replace(/<h3>(.*?)<\/h3>/gi, '### $1');
|
||||
markdown = markdown.replace(/<h4>(.*?)<\/h4>/gi, '#### $1');
|
||||
|
||||
// Bold
|
||||
markdown = markdown.replace(/<strong>(.*?)<\/strong>/gi, '**$1**');
|
||||
markdown = markdown.replace(/<b>(.*?)<\/b>/gi, '**$1**');
|
||||
|
||||
// Italic
|
||||
markdown = markdown.replace(/<em>(.*?)<\/em>/gi, '*$1*');
|
||||
markdown = markdown.replace(/<i>(.*?)<\/i>/gi, '*$1*');
|
||||
|
||||
// Links
|
||||
markdown = markdown.replace(/<a href="([^"]+)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
|
||||
|
||||
// Horizontal rules
|
||||
markdown = markdown.replace(/<hr\s*\/?>/gi, '\n---\n');
|
||||
|
||||
// Lists
|
||||
markdown = markdown.replace(/<ul>([\s\S]*?)<\/ul>/gi, (match, content) => {
|
||||
return content.replace(/<li>(.*?)<\/li>/gi, '- $1\n');
|
||||
});
|
||||
|
||||
// Paragraphs
|
||||
markdown = markdown.replace(/<p>(.*?)<\/p>/gi, '$1\n\n');
|
||||
|
||||
// Clean up extra newlines
|
||||
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
return markdown.trim();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Crown, Info, Save } from 'lucide-react';
|
||||
import { Crown, Info } from 'lucide-react';
|
||||
import { SettingsLayout } from './components/SettingsLayout';
|
||||
import { SettingsCard } from './components/SettingsCard';
|
||||
import { ToggleField } from './components/ToggleField';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -89,27 +90,23 @@ export default function CustomersSettings() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{__('Customer Settings')}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Configure VIP customer qualification')}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsLayout
|
||||
title={__('Customer Settings')}
|
||||
description={__('Configure VIP customer qualification')}
|
||||
isLoading={true}
|
||||
>
|
||||
<div className="animate-pulse h-64 bg-muted rounded-lg"></div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{__('Customer Settings')}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Configure VIP customer qualification criteria')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsLayout
|
||||
title={__('Customer Settings')}
|
||||
description={__('Configure VIP customer qualification criteria')}
|
||||
onSave={handleSave}
|
||||
saveLabel={__('Save Changes')}
|
||||
>
|
||||
{message && (
|
||||
<div className={`p-4 rounded-lg ${message.includes('success') ? 'bg-green-50 text-green-900' : 'bg-red-50 text-red-900'}`}>
|
||||
{message}
|
||||
@@ -227,16 +224,6 @@ export default function CustomersSettings() {
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={fetchSettings} disabled={isSaving}>
|
||||
{__('Reset')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? __('Saving...') : __('Save Changes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ 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 { EmailBuilder, EmailBlock, blocksToHTML, htmlToBlocks } from '@/components/EmailBuilder';
|
||||
import { EmailBuilder, EmailBlock, blocksToMarkdown, markdownToBlocks } from '@/components/EmailBuilder';
|
||||
import { CodeEditor } from '@/components/ui/code-editor';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ArrowLeft, Eye, Edit, RotateCcw } from 'lucide-react';
|
||||
import { ArrowLeft, Eye, Edit, RotateCcw, FileText } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { markdownToHtml } from '@/lib/markdown-utils';
|
||||
|
||||
export default function EditTemplate() {
|
||||
// Mobile responsive check
|
||||
@@ -30,13 +31,13 @@ export default function EditTemplate() {
|
||||
|
||||
const eventId = searchParams.get('event');
|
||||
const channelId = searchParams.get('channel');
|
||||
const recipientType = searchParams.get('recipient') || 'customer'; // Default to customer
|
||||
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [blocks, setBlocks] = useState<EmailBlock[]>([]);
|
||||
const [markdownContent, setMarkdownContent] = useState(''); // Source of truth: Markdown
|
||||
const [blocks, setBlocks] = useState<EmailBlock[]>([]); // Visual mode view (derived from markdown)
|
||||
const [variables, setVariables] = useState<{ [key: string]: string }>({});
|
||||
const [activeTab, setActiveTab] = useState('editor');
|
||||
const [codeMode, setCodeMode] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('preview');
|
||||
|
||||
// Fetch email customization settings
|
||||
const { data: emailSettings } = useQuery({
|
||||
@@ -46,10 +47,10 @@ export default function EditTemplate() {
|
||||
|
||||
// Fetch template
|
||||
const { data: template, isLoading, error } = useQuery({
|
||||
queryKey: ['notification-template', eventId, channelId],
|
||||
queryKey: ['notification-template', eventId, channelId, recipientType],
|
||||
queryFn: async () => {
|
||||
console.log('Fetching template for:', eventId, channelId);
|
||||
const response = await api.get(`/notifications/templates/${eventId}/${channelId}`);
|
||||
console.log('Fetching template for:', eventId, channelId, recipientType);
|
||||
const response = await api.get(`/notifications/templates/${eventId}/${channelId}?recipient=${recipientType}`);
|
||||
console.log('API Response:', response);
|
||||
console.log('API Response.data:', response.data);
|
||||
console.log('API Response type:', typeof response);
|
||||
@@ -67,60 +68,37 @@ export default function EditTemplate() {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
console.error('No valid template data found in response');
|
||||
return null;
|
||||
},
|
||||
enabled: !!eventId && !!channelId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Template changed:', template);
|
||||
if (template) {
|
||||
console.log('Template data:', {
|
||||
subject: template.subject,
|
||||
body: template.body,
|
||||
variables: template.variables,
|
||||
event_label: template.event_label,
|
||||
channel_label: template.channel_label
|
||||
});
|
||||
|
||||
setSubject(template.subject || '');
|
||||
setBody(template.body || '');
|
||||
setBlocks(htmlToBlocks(template.body || ''));
|
||||
setVariables(template.variables || {});
|
||||
|
||||
// Always treat body as markdown (source of truth)
|
||||
const markdown = template.body || '';
|
||||
setMarkdownContent(markdown);
|
||||
|
||||
// Convert to blocks for visual mode
|
||||
const initialBlocks = markdownToBlocks(markdown);
|
||||
setBlocks(initialBlocks);
|
||||
}
|
||||
}, [template]);
|
||||
|
||||
// Debug: Log when states change
|
||||
useEffect(() => {
|
||||
console.log('Subject state:', subject);
|
||||
}, [subject]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Body state:', body);
|
||||
}, [body]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Variables state:', variables);
|
||||
}, [variables]);
|
||||
|
||||
const handleSave = async () => {
|
||||
// Convert blocks to HTML before saving
|
||||
const htmlBody = codeMode ? body : blocksToHTML(blocks);
|
||||
|
||||
try {
|
||||
await api.post('/notifications/templates', {
|
||||
eventId,
|
||||
channelId,
|
||||
await api.post(`/notifications/templates/${eventId}/${channelId}`, {
|
||||
subject,
|
||||
body: htmlBody,
|
||||
body: markdownContent, // Save markdown (source of truth)
|
||||
recipient: recipientType,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-template', eventId, channelId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-template', eventId, channelId, recipientType] });
|
||||
toast.success(__('Template saved successfully'));
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || __('Failed to save template'));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -128,41 +106,43 @@ export default function EditTemplate() {
|
||||
if (!confirm(__('Are you sure you want to reset this template to default?'))) return;
|
||||
|
||||
try {
|
||||
await api.del(`/notifications/templates/${eventId}/${channelId}`);
|
||||
await api.del(`/notifications/templates/${eventId}/${channelId}?recipient=${recipientType}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-template', eventId, channelId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-template', eventId, channelId, recipientType] });
|
||||
toast.success(__('Template reset to default'));
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || __('Failed to reset template'));
|
||||
}
|
||||
};
|
||||
|
||||
// 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 {
|
||||
// Switching FROM code mode: convert HTML to blocks
|
||||
setBlocks(htmlToBlocks(body));
|
||||
}
|
||||
setCodeMode(!codeMode);
|
||||
};
|
||||
|
||||
// Update blocks and sync to body
|
||||
// Visual mode: Update blocks → Markdown (source of truth)
|
||||
const handleBlocksChange = (newBlocks: EmailBlock[]) => {
|
||||
setBlocks(newBlocks);
|
||||
setBody(blocksToHTML(newBlocks));
|
||||
const markdown = blocksToMarkdown(newBlocks);
|
||||
setMarkdownContent(markdown); // Update markdown (source of truth)
|
||||
};
|
||||
|
||||
// Markdown mode: Update markdown → Blocks (for visual sync)
|
||||
const handleMarkdownChange = (newMarkdown: string) => {
|
||||
setMarkdownContent(newMarkdown); // Update source of truth
|
||||
const newBlocks = markdownToBlocks(newMarkdown);
|
||||
setBlocks(newBlocks); // Keep blocks in sync
|
||||
};
|
||||
|
||||
// Get variable keys for the rich text editor
|
||||
const variableKeys = Object.keys(variables);
|
||||
|
||||
// Parse [card] tags for preview
|
||||
// Parse [card] tags and [button] shortcodes for preview
|
||||
const parseCardsForPreview = (content: string) => {
|
||||
const cardRegex = /\[card([^\]]*)\](.*?)\[\/card\]/gs;
|
||||
// Parse card blocks - new [card:type] syntax
|
||||
let parsed = content.replace(/\[card:(\w+)\](.*?)\[\/card\]/gs, (match, type, cardContent) => {
|
||||
const cardClass = `card card-${type}`;
|
||||
const htmlContent = markdownToHtml(cardContent.trim());
|
||||
return `<div class="${cardClass}">${htmlContent}</div>`;
|
||||
});
|
||||
|
||||
return content.replace(cardRegex, (match, attributes, cardContent) => {
|
||||
// Parse card blocks - old [card type="..."] syntax (backward compatibility)
|
||||
parsed = parsed.replace(/\[card([^\]]*)\](.*?)\[\/card\]/gs, (match, attributes, cardContent) => {
|
||||
let cardClass = 'card';
|
||||
const typeMatch = attributes.match(/type=["']([^"']+)["']/);
|
||||
if (typeMatch) {
|
||||
@@ -172,13 +152,30 @@ export default function EditTemplate() {
|
||||
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
|
||||
const bgStyle = bgMatch ? `background-image: url(${bgMatch[1]}); background-size: cover; background-position: center;` : '';
|
||||
|
||||
return `<div class="${cardClass}" style="${bgStyle}">${cardContent}</div>`;
|
||||
// Convert markdown inside card to HTML
|
||||
const htmlContent = markdownToHtml(cardContent.trim());
|
||||
return `<div class="${cardClass}" style="${bgStyle}">${htmlContent}</div>`;
|
||||
});
|
||||
|
||||
// Parse button shortcodes - new [button:style](url)Text[/button] syntax
|
||||
parsed = parsed.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => {
|
||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||
});
|
||||
|
||||
// Parse button shortcodes - old [button url="..."]Text[/button] syntax (backward compatibility)
|
||||
parsed = parsed.replace(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||
});
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
// Generate preview HTML
|
||||
const generatePreviewHTML = () => {
|
||||
let previewBody = body;
|
||||
// Convert markdown to HTML for preview
|
||||
let previewBody = parseCardsForPreview(markdownContent);
|
||||
|
||||
// Replace store-identity variables with actual data
|
||||
const storeVariables: { [key: string]: string } = {
|
||||
@@ -188,7 +185,8 @@ export default function EditTemplate() {
|
||||
};
|
||||
|
||||
Object.entries(storeVariables).forEach(([key, value]) => {
|
||||
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
|
||||
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
||||
previewBody = previewBody.replace(regex, value);
|
||||
});
|
||||
|
||||
// Replace dynamic variables with sample data (not just highlighting)
|
||||
@@ -198,6 +196,7 @@ export default function EditTemplate() {
|
||||
order_status: 'Processing',
|
||||
order_date: new Date().toLocaleDateString(),
|
||||
order_url: '#',
|
||||
completion_date: new Date().toLocaleDateString(),
|
||||
order_items_list: `<ul style="list-style: none; padding: 0; margin: 16px 0;">
|
||||
<li style="padding: 12px; background: #f9f9f9; border-radius: 6px; margin-bottom: 8px;">
|
||||
<strong>Premium T-Shirt</strong> × 2<br>
|
||||
@@ -244,12 +243,26 @@ export default function EditTemplate() {
|
||||
payment_url: '#',
|
||||
shipping_method: 'Standard Shipping',
|
||||
tracking_number: 'TRACK123456',
|
||||
tracking_url: '#',
|
||||
shipping_carrier: 'Standard Shipping',
|
||||
refund_amount: '$50.00',
|
||||
billing_address: '123 Main St, City, State 12345',
|
||||
shipping_address: '123 Main St, City, State 12345',
|
||||
transaction_id: 'TXN123456789',
|
||||
payment_date: new Date().toLocaleDateString(),
|
||||
payment_status: 'Completed',
|
||||
review_url: '#',
|
||||
shop_url: '#',
|
||||
my_account_url: '#',
|
||||
payment_retry_url: '#',
|
||||
vip_dashboard_url: '#',
|
||||
vip_free_shipping_threshold: '$50',
|
||||
current_year: new Date().getFullYear().toString(),
|
||||
site_name: 'My WordPress Store',
|
||||
store_name: 'My WordPress Store',
|
||||
store_url: '#',
|
||||
store_email: 'store@example.com',
|
||||
support_email: 'support@example.com',
|
||||
};
|
||||
|
||||
Object.keys(sampleData).forEach((key) => {
|
||||
@@ -287,7 +300,10 @@ export default function EditTemplate() {
|
||||
const processedFooter = footerText.replace('{current_year}', new Date().getFullYear().toString());
|
||||
|
||||
// Generate social icons HTML with PNG images
|
||||
const pluginUrl = (window as any).woonoowData?.pluginUrl || '';
|
||||
const pluginUrl =
|
||||
(window as any).woonoowData?.pluginUrl ||
|
||||
(window as any).WNW_CONFIG?.pluginUrl ||
|
||||
'';
|
||||
const socialIconsHtml = socialLinks.length > 0 ? `
|
||||
<div style="margin-top: 16px;">
|
||||
${socialLinks.map((link: any) => `
|
||||
@@ -308,6 +324,15 @@ export default function EditTemplate() {
|
||||
.header { padding: 32px; text-align: center; background: #f8f8f8; }
|
||||
.card-gutter { padding: 0 16px; }
|
||||
.card { background: #ffffff; border-radius: 8px; margin-bottom: 24px; padding: 32px 40px; }
|
||||
|
||||
/* Mobile responsive */
|
||||
@media only screen and (max-width: 600px) {
|
||||
body { padding: 8px; }
|
||||
.card-gutter { padding: 0 8px; }
|
||||
.card { padding: 20px 16px; }
|
||||
.header { padding: 20px 16px; }
|
||||
.footer { padding: 20px 16px; }
|
||||
}
|
||||
.card-success { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
||||
.card-success * { color: ${heroTextColor} !important; }
|
||||
.card-highlight { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
||||
@@ -316,6 +341,7 @@ export default function EditTemplate() {
|
||||
.card-hero * { color: ${heroTextColor} !important; }
|
||||
.card-info { background: #f0f7ff; border: 1px solid #0071e3; }
|
||||
.card-warning { background: #fff8e1; border: 1px solid #ff9800; }
|
||||
.card-basic { background: none; border: none; padding: 0; margin: 16px 0; }
|
||||
h1 { font-size: 26px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
||||
h2 { font-size: 18px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
||||
h3 { font-size: 16px; margin-top: 0; margin-bottom: 8px; color: #333; }
|
||||
@@ -438,48 +464,40 @@ export default function EditTemplate() {
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-4">
|
||||
{/* Tabs for Editor/Preview */}
|
||||
{/* Three-tab system: Preview | Visual | Markdown */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>{__('Message Body')}</Label>
|
||||
{activeTab === 'editor' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCodeModeToggle}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{codeMode ? __('Visual Builder') : __('Code Mode')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Label>{__('Message Body')}</Label>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="editor" className="flex items-center gap-1 text-xs">
|
||||
<Edit className="h-3 w-3" />
|
||||
{__('Editor')}
|
||||
</TabsTrigger>
|
||||
<TabsList className="grid grid-cols-3">
|
||||
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
|
||||
<Eye className="h-3 w-3" />
|
||||
{__('Preview')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visual" className="flex items-center gap-1 text-xs">
|
||||
<Edit className="h-3 w-3" />
|
||||
{__('Visual')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="markdown" className="flex items-center gap-1 text-xs">
|
||||
<FileText className="h-3 w-3" />
|
||||
{__('Markdown')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{activeTab === 'editor' && codeMode ? (
|
||||
<div className="space-y-2">
|
||||
<CodeEditor
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder={__('Enter HTML code with [card] tags...')}
|
||||
supportMarkdown={true}
|
||||
{/* Preview Tab */}
|
||||
{activeTab === 'preview' && (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={generatePreviewHTML()}
|
||||
className="w-full min-h-[600px] overflow-hidden bg-white"
|
||||
title={__('Email Preview')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Edit raw HTML code with [card] syntax, or switch to Markdown mode for easier editing.')}
|
||||
</p>
|
||||
</div>
|
||||
) : activeTab === 'editor' ? (
|
||||
)}
|
||||
|
||||
{/* Visual Tab */}
|
||||
{activeTab === 'visual' && (
|
||||
<div>
|
||||
<EmailBuilder
|
||||
blocks={blocks}
|
||||
@@ -487,18 +505,28 @@ export default function EditTemplate() {
|
||||
variables={variableKeys}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{__('Build your email visually. Add blocks, edit content, and see live preview.')}
|
||||
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
|
||||
</p>
|
||||
</div>
|
||||
) : activeTab === 'preview' ? (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={generatePreviewHTML()}
|
||||
className="w-full h-[600px] bg-white"
|
||||
title={__('Email Preview')}
|
||||
)}
|
||||
|
||||
{/* Markdown Tab */}
|
||||
{activeTab === 'markdown' && (
|
||||
<div className="space-y-2">
|
||||
<CodeEditor
|
||||
value={markdownContent}
|
||||
onChange={handleMarkdownChange}
|
||||
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
|
||||
supportMarkdown={true}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Write in Markdown - easy to type, even on mobile! Use **bold**, ## headings, [card]...[/card], etc.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('All changes are automatically synced between Visual and Markdown modes.')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -28,6 +28,10 @@ export default function NotificationTemplates() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [openAccordion, setOpenAccordion] = useState<string | undefined>();
|
||||
|
||||
// Determine recipient type from current page URL (using hash because of HashRouter)
|
||||
const isStaffPage = window.location.hash.includes('/staff');
|
||||
const recipientType = isStaffPage ? 'staff' : 'customer';
|
||||
|
||||
// Check for query params to open specific accordion
|
||||
useEffect(() => {
|
||||
const eventParam = searchParams.get('event');
|
||||
@@ -55,8 +59,8 @@ export default function NotificationTemplates() {
|
||||
});
|
||||
|
||||
const openEditor = (event: any, channel: any) => {
|
||||
// Navigate to edit template subpage
|
||||
navigate(`/settings/notifications/edit-template?event=${event.id}&channel=${channel.id}`);
|
||||
// Navigate to edit template subpage with recipient type
|
||||
navigate(`/settings/notifications/edit-template?event=${event.id}&channel=${channel.id}&recipient=${recipientType}`);
|
||||
};
|
||||
|
||||
const getChannelIcon = (channelId: string) => {
|
||||
@@ -86,6 +90,15 @@ export default function NotificationTemplates() {
|
||||
...(eventsData?.customers || []),
|
||||
];
|
||||
|
||||
// Filter events by recipient type
|
||||
const filteredEvents = allEvents.filter((event: any) => {
|
||||
// Check both recipients array (from get_events) and recipient_type (from get_all_events)
|
||||
if (event.recipients && Array.isArray(event.recipients)) {
|
||||
return event.recipients.includes(recipientType);
|
||||
}
|
||||
return event.recipient_type === recipientType;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Info Card */}
|
||||
@@ -114,7 +127,7 @@ export default function NotificationTemplates() {
|
||||
>
|
||||
<Accordion type="single" collapsible className="w-full" value={openAccordion} onValueChange={setOpenAccordion}>
|
||||
{channels?.map((channel: NotificationChannel) => {
|
||||
const channelTemplates = allEvents.filter((event: any) => {
|
||||
const channelTemplates = filteredEvents.filter((event: any) => {
|
||||
const templateKey = `${event.id}_${channel.id}`;
|
||||
return templates && templates[templateKey];
|
||||
});
|
||||
@@ -129,7 +142,7 @@ export default function NotificationTemplates() {
|
||||
<span className="font-medium text-left">{channel.label}</span>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{allEvents.length} {__('templates')}
|
||||
{filteredEvents.length} {__('templates')}
|
||||
</Badge>
|
||||
{customCount > 0 && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
@@ -142,7 +155,7 @@ export default function NotificationTemplates() {
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-2 pt-2">
|
||||
{allEvents.map((event: any) => {
|
||||
{filteredEvents.map((event: any) => {
|
||||
const templateKey = `${event.id}_${channel.id}`;
|
||||
const hasCustomTemplate = templates && templates[templateKey];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user