fix: template save API + contextual variables per event
1. API Route Fix (NotificationsController.php): - Changed PUT to POST for /templates/:eventId/:channelId - Frontend was using api.post() but backend only accepted PUT - Templates can now be saved 2. Contextual Variables (EventRegistry.php): - Added get_variables_for_event() method - Returns category-based variables (order, customer, product, etc.) - Merges event-specific variables from event definition - Sorted alphabetically for easy browsing 3. API Response (NotificationsController.php): - Template API now returns available_variables for the event - Frontend can show only relevant variables 4. Frontend (EditTemplate.tsx): - Removed hardcoded 50+ variable list - Now uses template.available_variables from API - Variables update based on selected event type
This commit is contained in:
@@ -18,7 +18,7 @@ import { markdownToHtml } from '@/lib/markdown-utils';
|
||||
export default function EditTemplate() {
|
||||
// Mobile responsive check
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||
checkMobile();
|
||||
@@ -28,63 +28,15 @@ export default function EditTemplate() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
const eventId = searchParams.get('event');
|
||||
const channelId = searchParams.get('channel');
|
||||
const recipientType = searchParams.get('recipient') || 'customer'; // Default to customer
|
||||
|
||||
|
||||
const [subject, setSubject] = useState('');
|
||||
const [markdownContent, setMarkdownContent] = useState(''); // Source of truth: Markdown
|
||||
const [blocks, setBlocks] = useState<EmailBlock[]>([]); // Visual mode view (derived from markdown)
|
||||
const [activeTab, setActiveTab] = useState('preview');
|
||||
|
||||
// All available template variables
|
||||
const availableVariables = [
|
||||
// Order variables
|
||||
'order_number',
|
||||
'order_id',
|
||||
'order_date',
|
||||
'order_total',
|
||||
'order_subtotal',
|
||||
'order_tax',
|
||||
'order_shipping',
|
||||
'order_discount',
|
||||
'order_status',
|
||||
'order_url',
|
||||
'order_items_table',
|
||||
'completion_date',
|
||||
'estimated_delivery',
|
||||
// Customer variables
|
||||
'customer_name',
|
||||
'customer_first_name',
|
||||
'customer_last_name',
|
||||
'customer_email',
|
||||
'customer_phone',
|
||||
'billing_address',
|
||||
'shipping_address',
|
||||
// Payment variables
|
||||
'payment_method',
|
||||
'payment_status',
|
||||
'payment_date',
|
||||
'transaction_id',
|
||||
'payment_retry_url',
|
||||
// Shipping/Tracking variables
|
||||
'tracking_number',
|
||||
'tracking_url',
|
||||
'shipping_carrier',
|
||||
'shipping_method',
|
||||
// URL variables
|
||||
'review_url',
|
||||
'shop_url',
|
||||
'my_account_url',
|
||||
// Store variables
|
||||
'site_name',
|
||||
'site_title',
|
||||
'store_name',
|
||||
'store_url',
|
||||
'support_email',
|
||||
'current_year',
|
||||
];
|
||||
|
||||
// Fetch email customization settings
|
||||
const { data: emailSettings } = useQuery({
|
||||
@@ -101,20 +53,20 @@ export default function EditTemplate() {
|
||||
console.log('API Response:', response);
|
||||
console.log('API Response.data:', response.data);
|
||||
console.log('API Response type:', typeof response);
|
||||
|
||||
|
||||
// The api.get might already unwrap response.data
|
||||
// Return the response directly if it has the template fields
|
||||
if (response && (response.subject !== undefined || response.body !== undefined)) {
|
||||
console.log('Returning response directly:', response);
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
// Otherwise return response.data
|
||||
if (response && response.data) {
|
||||
console.log('Returning response.data:', response.data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
},
|
||||
enabled: !!eventId && !!channelId,
|
||||
@@ -123,11 +75,11 @@ export default function EditTemplate() {
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
setSubject(template.subject || '');
|
||||
|
||||
|
||||
// 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);
|
||||
@@ -151,7 +103,7 @@ export default function EditTemplate() {
|
||||
|
||||
const handleReset = async () => {
|
||||
if (!confirm(__('Are you sure you want to reset this template to default?'))) return;
|
||||
|
||||
|
||||
try {
|
||||
await api.del(`/notifications/templates/${eventId}/${channelId}?recipient=${recipientType}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
|
||||
@@ -168,7 +120,7 @@ export default function EditTemplate() {
|
||||
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
|
||||
@@ -176,9 +128,11 @@ export default function EditTemplate() {
|
||||
setBlocks(newBlocks); // Keep blocks in sync
|
||||
};
|
||||
|
||||
// Variable keys for the rich text editor dropdown
|
||||
const variableKeys = availableVariables;
|
||||
|
||||
// Variable keys for the rich text editor dropdown - from API (contextual per event)
|
||||
const variableKeys = template?.available_variables
|
||||
? Object.keys(template.available_variables).map(k => k.replace(/^\{|}$/g, ''))
|
||||
: [];
|
||||
|
||||
// Parse [card] tags and [button] shortcodes for preview
|
||||
const parseCardsForPreview = (content: string) => {
|
||||
// Parse card blocks - new [card:type] syntax
|
||||
@@ -187,7 +141,7 @@ export default function EditTemplate() {
|
||||
const htmlContent = markdownToHtml(cardContent.trim());
|
||||
return `<div class="${cardClass}">${htmlContent}</div>`;
|
||||
});
|
||||
|
||||
|
||||
// Parse card blocks - old [card type="..."] syntax (backward compatibility)
|
||||
parsed = parsed.replace(/\[card([^\]]*)\](.*?)\[\/card\]/gs, (match, attributes, cardContent) => {
|
||||
let cardClass = 'card';
|
||||
@@ -195,27 +149,27 @@ export default function EditTemplate() {
|
||||
if (typeMatch) {
|
||||
cardClass += ` card-${typeMatch[1]}`;
|
||||
}
|
||||
|
||||
|
||||
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
|
||||
const bgStyle = bgMatch ? `background-image: url(${bgMatch[1]}); background-size: cover; background-position: center;` : '';
|
||||
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
@@ -223,19 +177,19 @@ export default function EditTemplate() {
|
||||
const generatePreviewHTML = () => {
|
||||
// Convert markdown to HTML for preview
|
||||
let previewBody = parseCardsForPreview(markdownContent);
|
||||
|
||||
|
||||
// Replace store-identity variables with actual data
|
||||
const storeVariables: { [key: string]: string } = {
|
||||
store_name: 'My WordPress Store',
|
||||
store_url: window.location.origin,
|
||||
store_email: 'store@example.com',
|
||||
};
|
||||
|
||||
|
||||
Object.entries(storeVariables).forEach(([key, value]) => {
|
||||
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
||||
previewBody = previewBody.replace(regex, value);
|
||||
});
|
||||
|
||||
|
||||
// Replace dynamic variables with sample data (not just highlighting)
|
||||
const sampleData: { [key: string]: string } = {
|
||||
order_number: '12345',
|
||||
@@ -311,23 +265,23 @@ export default function EditTemplate() {
|
||||
store_email: 'store@example.com',
|
||||
support_email: 'support@example.com',
|
||||
};
|
||||
|
||||
|
||||
Object.keys(sampleData).forEach((key) => {
|
||||
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
||||
previewBody = previewBody.replace(regex, sampleData[key]);
|
||||
});
|
||||
|
||||
// Highlight variables that don't have sample data
|
||||
availableVariables.forEach(key => {
|
||||
variableKeys.forEach((key: string) => {
|
||||
if (!storeVariables[key] && !sampleData[key]) {
|
||||
const sampleValue = `<span style="background: #fef3c7; padding: 2px 4px; border-radius: 2px;">[${key}]</span>`;
|
||||
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), sampleValue);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Parse [card] tags
|
||||
previewBody = parseCardsForPreview(previewBody);
|
||||
|
||||
|
||||
// Get email settings for preview
|
||||
const settings = emailSettings || {};
|
||||
const primaryColor = settings.primary_color || '#7f54b3';
|
||||
@@ -342,10 +296,10 @@ export default function EditTemplate() {
|
||||
const headerText = settings.header_text || 'My WordPress Store';
|
||||
const footerText = settings.footer_text || `© ${new Date().getFullYear()} My WordPress Store. All rights reserved.`;
|
||||
const socialLinks = settings.social_links || [];
|
||||
|
||||
|
||||
// Replace {current_year} in footer
|
||||
const processedFooter = footerText.replace('{current_year}', new Date().getFullYear().toString());
|
||||
|
||||
|
||||
// Generate social icons HTML with PNG images
|
||||
const pluginUrl =
|
||||
(window as any).woonoowData?.pluginUrl ||
|
||||
@@ -360,7 +314,7 @@ export default function EditTemplate() {
|
||||
`).join('')}
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -416,7 +370,7 @@ export default function EditTemplate() {
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
|
||||
// Helper function to get social icon emoji
|
||||
const getSocialIcon = (platform: string) => {
|
||||
const icons: Record<string, string> = {
|
||||
@@ -492,91 +446,91 @@ export default function EditTemplate() {
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
{/* Subject */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">{__('Subject / Title')}</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={__('Enter notification subject')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{channelId === 'email'
|
||||
? __('Email subject line')
|
||||
: __('Push notification title')}
|
||||
</p>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
{/* Subject */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">{__('Subject / Title')}</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={__('Enter notification subject')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{channelId === 'email'
|
||||
? __('Email subject line')
|
||||
: __('Push notification title')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-4">
|
||||
{/* Three-tab system: Preview | Visual | Markdown */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{__('Message Body')}</Label>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
||||
<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>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-4">
|
||||
{/* Three-tab system: Preview | Visual | Markdown */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{__('Message Body')}</Label>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
||||
<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>
|
||||
{/* 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')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visual Tab */}
|
||||
{activeTab === 'visual' && (
|
||||
<div>
|
||||
<EmailBuilder
|
||||
blocks={blocks}
|
||||
onChange={handleBlocksChange}
|
||||
variables={variableKeys}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Visual Tab */}
|
||||
{activeTab === 'visual' && (
|
||||
<div>
|
||||
<EmailBuilder
|
||||
blocks={blocks}
|
||||
onChange={handleBlocksChange}
|
||||
variables={variableKeys}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user