Files
WooNooW/admin-spa/src/components/EmailBuilder/BlockRenderer.tsx
dwindown 4471cd600f 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
2025-11-15 20:05:50 +07:00

229 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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