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

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