✅ 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
229 lines
7.2 KiB
TypeScript
229 lines
7.2 KiB
TypeScript
import React from 'react';
|
||
import { EmailBlock } from './types';
|
||
import { __ } from '@/lib/i18n';
|
||
import { parseMarkdownBasics } from '@/lib/markdown-utils';
|
||
|
||
interface BlockRendererProps {
|
||
block: EmailBlock;
|
||
isEditing: boolean;
|
||
onEdit: () => void;
|
||
onDelete: () => void;
|
||
onMoveUp: () => void;
|
||
onMoveDown: () => void;
|
||
isFirst: boolean;
|
||
isLast: boolean;
|
||
}
|
||
|
||
export function BlockRenderer({
|
||
block,
|
||
isEditing,
|
||
onEdit,
|
||
onDelete,
|
||
onMoveUp,
|
||
onMoveDown,
|
||
isFirst,
|
||
isLast
|
||
}: BlockRendererProps) {
|
||
|
||
// 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') ||
|
||
target.classList.contains('button') ||
|
||
target.classList.contains('button-outline') ||
|
||
target.closest('.button') ||
|
||
target.closest('.button-outline')
|
||
) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}
|
||
};
|
||
|
||
const renderBlockContent = () => {
|
||
switch (block.type) {
|
||
case 'card':
|
||
const cardStyles: { [key: string]: React.CSSProperties } = {
|
||
default: {
|
||
background: '#ffffff',
|
||
borderRadius: '8px',
|
||
padding: '32px 40px',
|
||
marginBottom: '24px'
|
||
},
|
||
success: {
|
||
background: '#e8f5e9',
|
||
border: '1px solid #4caf50',
|
||
borderRadius: '8px',
|
||
padding: '32px 40px',
|
||
marginBottom: '24px'
|
||
},
|
||
info: {
|
||
background: '#f0f7ff',
|
||
border: '1px solid #0071e3',
|
||
borderRadius: '8px',
|
||
padding: '32px 40px',
|
||
marginBottom: '24px'
|
||
},
|
||
warning: {
|
||
background: '#fff8e1',
|
||
border: '1px solid #ff9800',
|
||
borderRadius: '8px',
|
||
padding: '32px 40px',
|
||
marginBottom: '24px'
|
||
},
|
||
hero: {
|
||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||
color: '#fff',
|
||
borderRadius: '8px',
|
||
padding: '32px 40px',
|
||
marginBottom: '24px'
|
||
}
|
||
};
|
||
|
||
// 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 [&_.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: htmlContent }}
|
||
/>
|
||
</div>
|
||
);
|
||
|
||
case 'button': {
|
||
const buttonStyle: React.CSSProperties = block.style === 'solid'
|
||
? {
|
||
display: 'inline-block',
|
||
background: '#7f54b3',
|
||
color: '#fff',
|
||
padding: '14px 28px',
|
||
borderRadius: '6px',
|
||
textDecoration: 'none',
|
||
fontWeight: 600,
|
||
}
|
||
: {
|
||
display: 'inline-block',
|
||
background: 'transparent',
|
||
color: '#7f54b3',
|
||
padding: '12px 26px',
|
||
border: '2px solid #7f54b3',
|
||
borderRadius: '6px',
|
||
textDecoration: 'none',
|
||
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={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" />;
|
||
|
||
case 'spacer':
|
||
return <div style={{ height: `${block.height}px` }} />;
|
||
|
||
default:
|
||
return null;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="group relative" onClick={handleClick}>
|
||
{/* Block Content */}
|
||
<div className={`transition-all ${isEditing ? 'ring-2 ring-purple-500 ring-offset-2' : ''}`}>
|
||
{renderBlockContent()}
|
||
</div>
|
||
|
||
{/* Hover Controls */}
|
||
<div className="absolute -right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col gap-1 bg-white rounded-md shadow-lg border p-1">
|
||
{!isFirst && (
|
||
<button
|
||
onClick={onMoveUp}
|
||
className="p-1 hover:bg-gray-100 rounded text-gray-600 text-xs"
|
||
title={__('Move up')}
|
||
>
|
||
↑
|
||
</button>
|
||
)}
|
||
{!isLast && (
|
||
<button
|
||
onClick={onMoveDown}
|
||
className="p-1 hover:bg-gray-100 rounded text-gray-600 text-xs"
|
||
title={__('Move down')}
|
||
>
|
||
↓
|
||
</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"
|
||
title={__('Edit')}
|
||
>
|
||
✎
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={onDelete}
|
||
className="p-1 hover:bg-gray-100 rounded text-red-600 text-xs"
|
||
title={__('Delete')}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|