Files
WooNooW/includes/Core/Notifications/EmailRenderer.php
dwindown 1225d7b0ff fix: Email rendering - newlines, hero text color, and card borders
🐛 Three Critical Email Issues Fixed:

1. Newlines Not Working
    "Order Number: #359 Order Total: Rp129.000" on same line
    Fixed by adding <br> for line continuations in paragraphs

   Key change in MarkdownParser.php:
   - Accumulate paragraph content with <br> between lines
   - Match TypeScript behavior exactly
   - Protect variables from markdown parsing

   Before:
   $paragraph_content = $trimmed;

   After:
   if ($paragraph_content) {
       $paragraph_content .= '<br>' . $trimmed;
   } else {
       $paragraph_content = $trimmed;
   }

2. Hero Card Text Color
    Heading black instead of white in Gmail
    Add inline color styles to all headings/paragraphs

   Problem: Gmail doesn't inherit color from parent
   Solution: Add style="color: white;" to each element

   $content = preg_replace(
       '/<(h[1-6]|p)([^>]*)>/',
       '<$1$2 style="color: ' . $hero_text_color . ';">',
       $content
   );

3. Blue Border on Cards
    Unwanted blue border in Gmail (screenshot 2)
    Removed borders from .card-info, .card-warning, .card-success

   Problem: CSS template had borders
   Solution: Removed border declarations

   Before:
   .card-info { border: 1px solid #0071e3; }

   After:
   .card-info { background-color: #f0f7ff; }

�� Additional Improvements:
- Variable protection during markdown parsing
- Don't match bold/italic across newlines
- Proper list handling
- Block-level tag detection
- Paragraph accumulation with line breaks

🎯 Result:
-  Proper line breaks in paragraphs
-  White text in hero cards (Gmail compatible)
-  No unwanted borders
-  Variables preserved during parsing
-  Professional email appearance

Test: Create order, check email - should now show:
- Order Number: #359
- Order Total: Rp129.000
- Estimated Delivery: 3-5 business days
(Each on separate line with proper spacing)
2025-11-18 21:46:06 +07:00

507 lines
14 KiB
PHP
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.

<?php
/**
* Email Renderer
*
* Renders email templates with content
*
* @package WooNooW\Core\Notifications
*/
namespace WooNooW\Core\Notifications;
class EmailRenderer {
/**
* Instance
*/
private static $instance = null;
/**
* Get instance
*/
public static function instance() {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Render email
*
* @param string $event_id Event ID (order_placed, order_processing, etc.)
* @param string $recipient_type Recipient type (staff, customer)
* @param mixed $data Order, Product, or Customer object
* @param array $extra_data Additional data
* @return array|null ['to', 'subject', 'body']
*/
public function render($event_id, $recipient_type, $data, $extra_data = []) {
// Get template settings
$template_settings = $this->get_template_settings($event_id, $recipient_type);
if (!$template_settings) {
return null;
}
// Get recipient email
$to = $this->get_recipient_email($recipient_type, $data);
if (!$to) {
return null;
}
// Get variables
$variables = $this->get_variables($event_id, $data, $extra_data);
// Replace variables in subject and content
$subject = $this->replace_variables($template_settings['subject'], $variables);
$content = $this->replace_variables($template_settings['body'], $variables);
// Parse cards in content
$content = $this->parse_cards($content);
// Get HTML template
$template_path = $this->get_design_template();
// Render final HTML
$html = $this->render_html($template_path, $content, $subject, $variables);
return [
'to' => $to,
'subject' => $subject,
'body' => $html,
];
}
/**
* Get template settings
*
* @param string $event_id
* @param string $recipient_type
* @return array|null
*/
private function get_template_settings($event_id, $recipient_type) {
// Get saved template (with recipient_type for proper default template lookup)
$template = TemplateProvider::get_template($event_id, 'email', $recipient_type);
if (!$template) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[EmailRenderer] No template found for event: ' . $event_id . ', recipient: ' . $recipient_type);
}
return null;
}
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[EmailRenderer] Template found - Subject: ' . ($template['subject'] ?? 'no subject'));
}
// Get design template preference
$settings = get_option('woonoow_notification_settings', []);
$design = $settings['email_design_template'] ?? 'modern';
return [
'subject' => $template['subject'] ?? '',
'body' => $template['body'] ?? '',
'design' => $design,
];
}
/**
* Get recipient email
*
* @param string $recipient_type
* @param mixed $data
* @return string|null
*/
private function get_recipient_email($recipient_type, $data) {
if ($recipient_type === 'staff') {
return get_option('admin_email');
}
// Customer
if ($data instanceof \WC_Order) {
return $data->get_billing_email();
}
if ($data instanceof \WC_Customer) {
return $data->get_email();
}
return null;
}
/**
* Get variables for template
*
* @param string $event_id
* @param mixed $data
* @param array $extra_data
* @return array
*/
private function get_variables($event_id, $data, $extra_data = []) {
$variables = [
'store_name' => get_bloginfo('name'),
'store_url' => home_url(),
'site_title' => get_bloginfo('name'),
'support_email' => get_option('admin_email'),
'current_year' => date('Y'),
];
// Order variables
if ($data instanceof \WC_Order) {
// Calculate estimated delivery (3-5 business days from now)
$estimated_delivery = date('F j', strtotime('+3 days')) . '-' . date('j', strtotime('+5 days'));
$variables = array_merge($variables, [
'order_number' => $data->get_order_number(),
'order_id' => $data->get_id(),
'order_date' => $data->get_date_created()->date('F j, Y'),
'order_total' => $data->get_formatted_order_total(),
'order_subtotal' => wc_price($data->get_subtotal()),
'order_tax' => wc_price($data->get_total_tax()),
'order_shipping' => wc_price($data->get_shipping_total()),
'order_discount' => wc_price($data->get_discount_total()),
'order_status' => wc_get_order_status_name($data->get_status()),
'order_url' => $data->get_view_order_url(),
'payment_method' => $data->get_payment_method_title(),
'shipping_method' => $data->get_shipping_method(),
'estimated_delivery' => $estimated_delivery,
'customer_name' => $data->get_formatted_billing_full_name(),
'customer_first_name' => $data->get_billing_first_name(),
'customer_last_name' => $data->get_billing_last_name(),
'customer_email' => $data->get_billing_email(),
'customer_phone' => $data->get_billing_phone(),
'billing_address' => $data->get_formatted_billing_address(),
'shipping_address' => $data->get_formatted_shipping_address(),
]);
// Order items
$items_html = '';
foreach ($data->get_items() as $item) {
$product = $item->get_product();
$items_html .= sprintf(
'<tr><td>%s × %d</td><td>%s</td></tr>',
$item->get_name(),
$item->get_quantity(),
wc_price($item->get_total())
);
}
$variables['order_items'] = $items_html;
}
// Product variables
if ($data instanceof \WC_Product) {
$variables = array_merge($variables, [
'product_id' => $data->get_id(),
'product_name' => $data->get_name(),
'product_sku' => $data->get_sku(),
'product_price' => wc_price($data->get_price()),
'product_url' => get_permalink($data->get_id()),
'stock_quantity' => $data->get_stock_quantity(),
'stock_status' => $data->get_stock_status(),
]);
}
// Customer variables
if ($data instanceof \WC_Customer) {
$variables = array_merge($variables, [
'customer_id' => $data->get_id(),
'customer_name' => $data->get_display_name(),
'customer_first_name' => $data->get_first_name(),
'customer_last_name' => $data->get_last_name(),
'customer_email' => $data->get_email(),
'customer_username' => $data->get_username(),
]);
}
// Merge extra data
$variables = array_merge($variables, $extra_data);
return apply_filters('woonoow_email_variables', $variables, $event_id, $data);
}
/**
* Parse [card] tags and convert to HTML
*
* @param string $content
* @return string
*/
private function parse_cards($content) {
// Match [card ...] ... [/card] patterns
preg_match_all('/\[card([^\]]*)\](.*?)\[\/card\]/s', $content, $matches, PREG_SET_ORDER);
if (empty($matches)) {
// No cards found, wrap entire content in a single card
return $this->render_card($content, []);
}
$html = '';
foreach ($matches as $match) {
$attributes = $this->parse_card_attributes($match[1]);
$card_content = $match[2];
$html .= $this->render_card($card_content, $attributes);
$html .= $this->render_card_spacing();
}
// Remove last spacing
$html = preg_replace('/<table[^>]*class="card-spacing"[^>]*>.*?<\/table>\s*$/s', '', $html);
return $html;
}
/**
* Parse card attributes from [card ...] tag
*
* @param string $attr_string
* @return array
*/
private function parse_card_attributes($attr_string) {
$attributes = [
'type' => 'default',
'bg' => null,
];
// Parse type="highlight"
if (preg_match('/type=["\']([^"\']+)["\']/', $attr_string, $match)) {
$attributes['type'] = $match[1];
}
// Parse bg="url"
if (preg_match('/bg=["\']([^"\']+)["\']/', $attr_string, $match)) {
$attributes['bg'] = $match[1];
}
return $attributes;
}
/**
* Render a single card
*
* @param string $content
* @param array $attributes
* @return string
*/
private function render_card($content, $attributes) {
$type = $attributes['type'] ?? 'default';
$bg = $attributes['bg'] ?? null;
// Parse markdown in content
$content = MarkdownParser::parse($content);
// Get email customization settings for colors
$email_settings = get_option('woonoow_email_settings', []);
$hero_gradient_start = $email_settings['hero_gradient_start'] ?? '#667eea';
$hero_gradient_end = $email_settings['hero_gradient_end'] ?? '#764ba2';
$hero_text_color = $email_settings['hero_text_color'] ?? '#ffffff';
$class = 'card';
$style = 'width: 100%; background-color: #ffffff; border-radius: 8px;';
$content_style = 'padding: 32px 40px;';
// Add type class and styling
if ($type !== 'default') {
$class .= ' card-' . esc_attr($type);
// Apply gradient and text color for hero/success cards
if ($type === 'hero' || $type === 'success') {
$style .= sprintf(
' background: linear-gradient(135deg, %s 0%%, %s 100%%);',
esc_attr($hero_gradient_start),
esc_attr($hero_gradient_end)
);
$content_style .= sprintf(' color: %s;', esc_attr($hero_text_color));
// Add inline color to all headings and paragraphs for email client compatibility
$content = preg_replace(
'/<(h[1-6]|p)([^>]*)>/',
'<$1$2 style="color: ' . esc_attr($hero_text_color) . ';">',
$content
);
}
}
// Add background image
if ($bg) {
$class .= ' card-bg';
$style .= ' background-image: url(' . esc_url($bg) . ');';
}
return sprintf(
'<table role="presentation" class="%s" border="0" cellpadding="0" cellspacing="0" style="%s">
<tr>
<td class="content" style="%s">
%s
</td>
</tr>
</table>',
$class,
$style,
$content_style,
$content
);
}
/**
* Render card spacing
*
* @return string
*/
private function render_card_spacing() {
return '<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr><td class="card-spacing" style="height: 24px; font-size: 24px; line-height: 24px;">&nbsp;</td></tr>
</table>';
}
/**
* Replace variables in text
*
* @param string $text
* @param array $variables
* @return string
*/
private function replace_variables($text, $variables) {
foreach ($variables as $key => $value) {
$text = str_replace('{' . $key . '}', $value, $text);
}
return $text;
}
/**
* Get design template path
*
* @return string
*/
private function get_design_template() {
// Use single base template (theme-agnostic)
$template_path = WOONOOW_PATH . 'templates/emails/base.html';
// Allow filtering template path
$template_path = apply_filters('woonoow_email_template', $template_path);
// Fallback to base if custom template doesn't exist
if (!file_exists($template_path)) {
$template_path = WOONOOW_PATH . 'templates/emails/base.html';
}
return $template_path;
}
/**
* Render HTML email
*
* @param string $template_path Path to HTML template
* @param string $content Email content (HTML)
* @param string $subject Email subject
* @param array $variables All variables
* @return string
*/
private function render_html($template_path, $content, $subject, $variables) {
if (!file_exists($template_path)) {
// Fallback to plain HTML
return $content;
}
// Load template
$html = file_get_contents($template_path);
// Get email customization settings
$email_settings = get_option('woonoow_email_settings', []);
// Email body background
$body_bg = '#f8f8f8';
// Email header (logo or text)
$logo_url = $email_settings['logo_url'] ?? '';
// Fallback to site icon if no logo set
if (empty($logo_url) && has_site_icon()) {
$logo_url = get_site_icon_url(200);
}
if (!empty($logo_url)) {
$header = sprintf(
'<a href="%s"><img src="%s" alt="%s" style="max-width: 200px; max-height: 60px;"></a>',
esc_url($variables['store_url']),
esc_url($logo_url),
esc_attr($variables['store_name'])
);
} else {
// No logo, use text header
$header_text = !empty($email_settings['header_text']) ? $email_settings['header_text'] : $variables['store_name'];
$header = sprintf(
'<a href="%s" style="font-size: 24px; font-weight: 700; color: #333; text-decoration: none;">%s</a>',
esc_url($variables['store_url']),
esc_html($header_text)
);
}
// Email footer with {current_year} variable support
$footer_text = !empty($email_settings['footer_text']) ? $email_settings['footer_text'] : sprintf(
'© %s %s. All rights reserved.',
date('Y'),
$variables['store_name']
);
// Replace {current_year} with actual year
$footer_text = str_replace('{current_year}', date('Y'), $footer_text);
// Social icons with PNG images
$social_html = '';
if (!empty($email_settings['social_links']) && is_array($email_settings['social_links'])) {
$icon_color = !empty($email_settings['social_icon_color']) ? $email_settings['social_icon_color'] : 'white';
$social_html = '<div class="social-icons" style="margin-top: 16px; text-align: center;">';
foreach ($email_settings['social_links'] as $link) {
if (!empty($link['url']) && !empty($link['platform'])) {
$icon_url = $this->get_social_icon_url($link['platform'], $icon_color);
$social_html .= sprintf(
'<a href="%s" style="display: inline-block; margin: 0 8px;"><img src="%s" alt="%s" style="width: 24px; height: 24px;"></a>',
esc_url($link['url']),
esc_url($icon_url),
esc_attr(ucfirst($link['platform']))
);
}
}
$social_html .= '</div>';
}
$footer = sprintf(
'<p style="font-family: \'Inter\', Arial, sans-serif; font-size: 13px; line-height: 1.5; color: #888888; margin: 0 0 8px 0; text-align: center;">%s</p>%s',
nl2br(esc_html($footer_text)),
$social_html
);
// Replace placeholders
$html = str_replace('{{email_subject}}', esc_html($subject), $html);
$html = str_replace('{{email_body_bg}}', esc_attr($body_bg), $html);
$html = str_replace('{{email_header}}', $header, $html);
$html = str_replace('{{email_content}}', $content, $html);
$html = str_replace('{{email_footer}}', $footer, $html);
$html = str_replace('{{store_name}}', esc_html($variables['store_name']), $html);
$html = str_replace('{{store_url}}', esc_url($variables['store_url']), $html);
$html = str_replace('{{current_year}}', date('Y'), $html);
// Replace all other variables
foreach ($variables as $key => $value) {
$html = str_replace('{{' . $key . '}}', $value, $html);
}
return $html;
}
/**
* Get social icon URL
*
* @param string $platform
* @param string $color 'white' or 'black'
* @return string
*/
private function get_social_icon_url($platform, $color = 'white') {
// Use local PNG icons
$plugin_url = plugin_dir_url(dirname(dirname(dirname(__FILE__))));
$filename = sprintf('mage--%s-%s.png', $platform, $color);
return $plugin_url . 'assets/icons/' . $filename;
}
}