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: ![alt](url)

 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:
dwindown
2025-11-15 20:05:50 +07:00
parent 550b3b69ef
commit 4471cd600f
45 changed files with 9194 additions and 508 deletions

View File

@@ -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"

View File

@@ -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>

View File

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

View File

@@ -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';

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

View File

@@ -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[];

View File

@@ -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)&#123;text&#125;</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>
);
}

View 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 = `![Image description](https://example.com/image.jpg)`;
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">![Alt text](image-url)</code></p>
<p className="mt-2">Example: <code className="px-1 py-0.5 bg-muted rounded">![Logo](https://example.com/logo.png)</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>
);
}

View File

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

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

View File

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

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

View File

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

View File

@@ -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>

View File

@@ -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];