Initial commit

This commit is contained in:
gustavooth
2026-07-23 22:56:30 -03:00
commit 22a1006598
1816 changed files with 410742 additions and 0 deletions
@@ -0,0 +1,359 @@
<?php
namespace FluentCrm\App\Services\BlockRender;
use FluentCrm\Framework\Support\Arr;
class BlockEditorHelper
{
public static function getStyleDefauls()
{
return [
'spacing' => [
[
'name' => '2X-Small',
'slug' => 'fc-xx-small',
'size' => '5px',
],
[
'name' => 'X-Small',
'slug' => 'fc-x-small',
'size' => '10px',
],
[
'name' => 'Small',
'slug' => 'fc-small',
'size' => '14px',
],
[
'name' => 'Medium',
'slug' => 'fc-medium',
'size' => '20px',
],
[
'name' => 'Large',
'slug' => 'fc-large',
'size' => '30px',
],
[
'name' => 'X-Large',
'slug' => 'fc-x-large',
'size' => '45px',
],
[
'name' => '2X-Large',
'slug' => 'fc-xx-large',
'size' => '60px',
],
],
'font-family' => [
[
'name' => 'System UI',
'slug' => 'system-ui',
'fontFamily' => "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'",
],
[
'name' => 'Arial',
'slug' => 'arial',
'fontFamily' => "Arial, 'Helvetica Neue', Helvetica, sans-serif",
],
[
'name' => 'Georgia',
'slug' => 'georgia',
'fontFamily' => "Georgia, Times, 'Times New Roman', serif",
],
[
'name' => 'Helvetica',
'slug' => 'helvetica',
'fontFamily' => "Helvetica, Arial, Verdana, sans-serif",
],
[
'name' => 'Courier New',
'slug' => 'courier-new',
'fontFamily' => "'Courier New', Courier, 'Lucida Sans Typewriter', monospace",
],
[
'name' => 'Times New Roman',
'slug' => 'times-new-roman',
'fontFamily' => "'Times New Roman', Times, Baskerville, Georgia, serif",
],
[
'name' => 'Trebuchet MS',
'slug' => 'trebuchet-ms',
'fontFamily' => "'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', Tahoma, sans-serif",
],
[
'name' => 'Verdana',
'slug' => 'verdana',
'fontFamily' => "Verdana, Geneva, sans-serif",
],
],
'font-size' => [
[
'name' => 'Small',
'slug' => 'fc-small',
'size' => '13px',
],
[
'name' => 'Regular',
'slug' => 'fc-regular',
'size' => '16px',
],
[
'name' => 'Medium',
'slug' => 'fc-medium',
'size' => '18px',
],
[
'name' => 'Large',
'slug' => 'fc-large',
'size' => '26px',
],
[
'name' => 'Extra Large',
'slug' => 'fc-x-large',
'size' => '32px',
],
],
'color' => [
[
'name' => 'Black',
'slug' => 'black',
'color' => '#000000'
],
[
'name' => 'Cyan bluish gray',
'slug' => 'cyan-bluish-gray',
'color' => '#abb8c3'
],
[
'name' => 'White',
'slug' => 'white',
'color' => '#ffffff'
],
[
'name' => 'Pale pink',
'slug' => 'pale-pink',
'color' => '#f78da7'
],
[
'name' => 'Vivid red',
'slug' => 'vivid-red',
'color' => '#cf2e2e'
],
[
'name' => 'Luminous vivid orange',
'slug' => 'luminous-vivid-orange',
'color' => '#ff6900'
],
[
'name' => 'Luminous vivid amber',
'slug' => 'luminous-vivid-amber',
'color' => '#fcb900'
],
[
'name' => 'Light green cyan',
'slug' => 'light-green-cyan',
'color' => '#7bdcb5'
],
[
'name' => 'Vivid green cyan',
'slug' => 'vivid-green-cyan',
'color' => '#00d084'
],
[
'name' => 'Pale cyan blue',
'slug' => 'pale-cyan-blue',
'color' => '#8ed1fc'
],
[
'name' => 'Vivid cyan blue',
'slug' => 'vivid-cyan-blue',
'color' => '#0693e3'
],
[
'name' => 'Vivid purple',
'slug' => 'vivid-purple',
'color' => '#9b51e0'
]
]
];
}
public static function getDefaultPreset($key = '')
{
$defaults = self::getStyleDefauls();
if ($key && isset($defaults[$key])) {
return $defaults[$key];
}
return [];
}
public static function getStyleDefaultPresets()
{
$defaults = self::getStyleDefauls();
$cssProps = '';
foreach ($defaults['spacing'] as $item) {
$cssProps .= '--wp--preset--spacing--' . $item['slug'] . ': ' . $item['size'] . ';';
}
// Keep core color preset variables available inside the editor canvas.
// Without these, Gutenberg's link color styles can resolve to empty values.
foreach ($defaults['color'] as $item) {
$cssProps .= '--wp--preset--color--' . $item['slug'] . ': ' . $item['color'] . ';';
}
return $cssProps;
}
public static function replaceStyleSlugsWithValues($css = '')
{
if (!$css) {
return '';
}
$defaults = self::getStyleDefauls();
$replaces = [
'var(--wp--preset--color--white)' => 'white',
];
foreach ($defaults as $key => $value) {
if (is_array($value)) {
foreach ($value as $item) {
if ($key === 'spacing') {
$replaces['var(--wp--preset--spacing--' . $item['slug'] . ')'] = $item['size'];
} else if ($key === 'font-size') {
$replaces['var(--fcom--font--size--' . $item['slug'] . ')'] = $item['size'];
$replaces['var(--wp--preset--font-size--' . $item['slug'] . ')'] = $item['size'];
} else if ($key === 'color') {
$replaces['var(--fcom--color--' . $item['slug'] . ')'] = $item['color'];
$replaces['var(--wp--preset--color--' . $item['slug'] . ')'] = $item['color'];
} else if ($key === 'font-family') {
$replaces['var(--wp--preset--font-family--' . $item['slug'] . ')'] = $item['fontFamily'];
}
}
}
}
// Include active theme font size presets too (small, medium, large, etc).
$themeFontSizes = \FluentCrm\App\Services\Helper::getThemeFontSizes();
foreach ((array)$themeFontSizes as $themeFontSize) {
if (!is_array($themeFontSize) || empty($themeFontSize['slug']) || !isset($themeFontSize['size'])) {
continue;
}
$size = $themeFontSize['size'];
if (is_numeric($size)) {
$size = $size . 'px';
}
$replaces['var(--wp--preset--font-size--' . $themeFontSize['slug'] . ')'] = $size;
}
return str_replace(array_keys($replaces), array_values($replaces), $css);
}
public static function renderAtts($atts = [], $echo = false)
{
$rendered = '';
foreach ($atts as $key => $value) {
if (is_array($value)) {
$value = implode(' ', $value);
}
$value = esc_attr($value);
$key = esc_attr($key);
$rendered .= $key . '="' . $value . '" ';
}
if ($echo) {
echo $rendered;
}
return $rendered;
}
public static function renderStyles($styles = [], $echo = false)
{
$rendered = '';
foreach ($styles as $key => $value) {
if (!$value) {
continue;
}
$rendered .= $key . ':' . $value . ';';
}
if ($echo) {
echo esc_attr($rendered);
}
return esc_attr($rendered);
}
public static function getBorderColor($attrs = [], $default = '')
{
if ($borderColor = Arr::get($attrs, 'borderColor', '')) {
$color = 'var(--fcom--color--' . $borderColor . ')';
return self::replaceStyleSlugsWithValues($color);
}
$color = $default;
if ($borderConfig = Arr::get($attrs, 'style.border', [])) {
$color = Arr::get($borderConfig, 'color', '');
}
return $color;
}
public static function getDynamicCssForEditor()
{
$fonts = self::getDefaultPreset('font-family');
$css = '';
foreach ($fonts as $font) {
$css .= '.editor-styles-wrapper .has-' . $font['slug'] . '-font-family { font-family: ' . $font['fontFamily'] . ' !important; }';
}
return $css;
}
public static function getDefaultPrefConfig()
{
return [
'content_width' => 700,
'content_border_radius' => '0px',
'content_padding_top' => '20px', // matched
'content_padding_right' => '20px', // matched
'content_padding_bottom' => '20px', // matched
'content_padding_left' => '20px', // matched
'content_margin_top' => '20px', // new
'content_margin_bottom' => '20px', // new
'headings_font_family' => "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", // matched
'content_font_family' => "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", // matched
'body_bg_color' => '#FAFAFA', // matched
'content_bg_color' => '#FFFFFF', // matched
'headings_color' => '#202020', // matched
'text_color' => '#202020', // matched
'link_color' => '#0693e3',
'link_color_hover' => '', // not used now
'paragraph_font_size' => '16px', // matched
'paragraph_line_height' => '1.5', // matched
'footer_text_color' => '#202020', // matched,
'design_template' => 'simple'
];
}
}
@@ -0,0 +1,157 @@
<?php
namespace FluentCrm\App\Services\BlockRender;
use FluentCrm\Framework\Support\Arr;
class CartProduct
{
private static $productsCache = [];
public static function renderProduct($buttonHtml, $data)
{
if (!defined('FLUENTCART_VERSION')) {
return '';
}
$defaultAtts = [
'productId' => null,
'showImage' => true,
'showDescription' => true,
'showPrice' => true,
'showButton' => true,
'buttonText' => __('Buy Now', 'fluent-crm'),
'backgroundColor' => '#fffeeb',
'contentColor' => '',
'pricingColor' => '',
'template' => 'left',
];
$attrs = isset($data['attrs']) && is_array($data['attrs']) ? $data['attrs'] : [];
$atts = wp_parse_args($attrs, $defaultAtts);
$productId = absint($atts['productId']);
if (!$productId) {
return '';
}
$product = self::getProductData($productId);
if (!$product) {
return '';
}
$template = self::normalizeTemplate(Arr::get($atts, 'template', 'left'));
$showImage = RenderValueHelper::normalizeBool(Arr::get($atts, 'showImage'), true);
$showDescription = RenderValueHelper::normalizeBool(Arr::get($atts, 'showDescription'), true);
$showPrice = RenderValueHelper::normalizeBool(Arr::get($atts, 'showPrice'), true);
$showButton = RenderValueHelper::normalizeBool(Arr::get($atts, 'showButton'), true);
$tableStyle = 'border-radius: 5px;';
$contentColorStyle = '';
if ($bgColor = Arr::get($atts, 'backgroundColor')) {
$tableStyle .= 'background-color:' . sanitize_text_field($bgColor) . ';';
}
if ($contentColor = Arr::get($atts, 'contentColor')) {
$safeContentColor = sanitize_text_field($contentColor);
$tableStyle .= 'color:' . $safeContentColor . ';';
$contentColorStyle = 'color:' . $safeContentColor . ';';
}
$priceStyle = '';
if ($pricingColor = Arr::get($atts, 'pricingColor')) {
$priceStyle = 'color:' . sanitize_text_field($pricingColor) . ';';
}
$contentHtml = sprintf(
'<h2 style="%1$smargin:0;"><a href="%2$s" target="_blank" style="%1$stext-decoration:none;">%3$s</a></h2>',
$contentColorStyle,
esc_url($product['permalink']),
wp_kses_post($product['name'])
);
if ($showDescription && !empty($product['short_description'])) {
$contentHtml .= sprintf(
'<div style="%1$smargin-bottom:5px;">%2$s</div>',
$contentColorStyle,
wp_kses_post($product['short_description'])
);
}
if ($showPrice) {
$contentHtml .= sprintf(
'<div style="%1$smargin:5px 0;">%2$s</div>',
$priceStyle,
ProductListRenderer::preparePriceHtml($product['price_html'], __('Free', 'fluent-crm'))
);
}
$image = '';
if ($showImage && $template !== 'none') {
$customImage = trim((string)Arr::get($atts, 'customImage'));
$image = $customImage ? esc_url_raw($customImage) : $product['image'];
}
if (!$image) {
$template = 'none';
}
if ($showButton) {
$fallbackButton = '<a href="' . esc_url($product['permalink']) . '" style="text-decoration:none;display:inline-block;padding:8px 18px;border-radius:4px;background:#2a363d;color:#ffffff;">' . esc_html(Arr::get($atts, 'buttonText', __('Buy Now', 'fluent-crm'))) . '</a>';
$contentHtml .= '<div style="margin-top:10px;margin-bottom:10px;">' . ($buttonHtml ?: $fallbackButton) . '</div>';
}
$contentStyle = '';
if ($template === 'none' || $template === 'top') {
$contentStyle = 'text-align:center;';
}
$imageTd = '';
if ($image) {
if ($template === 'top') {
$contentHtml = '<img src="' . esc_url($image) . '" width="auto" alt="' . esc_attr(wp_strip_all_tags($product['name'])) . '" style="border:0;height:auto;outline:none;text-decoration:none;max-width:100%;-ms-interpolation-mode:bicubic;display:block;margin:0 auto 15px;">' . $contentHtml;
} elseif ($template === 'left') {
$imageContent = '<img src="' . esc_url($image) . '" width="auto" alt="' . esc_attr(wp_strip_all_tags($product['name'])) . '" style="border:0;height:auto;outline:none;text-decoration:none;max-width:100%;-ms-interpolation-mode:bicubic;display:block;margin:0 auto;">';
$imageTd = self::getContentTd($imageContent, $template);
}
}
ob_start();
?>
<table class="fce_row" border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout:fixed;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;margin-bottom:20px;margin-top:20px;<?php echo $tableStyle; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>"><tbody><tr>
<?php
if ($imageTd) {
echo $imageTd; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo self::getContentTd($contentHtml, $template, $contentStyle); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
?>
</tr></tbody></table>
<?php
return ob_get_clean();
}
private static function getProductData($productId)
{
if (isset(self::$productsCache[$productId])) {
return self::$productsCache[$productId];
}
self::$productsCache[$productId] = CartProductData::getProductById($productId, 'large');
return self::$productsCache[$productId];
}
private static function getContentTd($contentHtml, $template, $extraStyle = '')
{
$width = ($template === 'left') ? '50' : '100';
return '<td align="center" valign="middle" width="' . $width . '%" class="fce_column"><table border="0" cellpadding="10" cellspacing="0" width="100%"><tr><td class="fc_column_content" style="padding:10px;' . $extraStyle . '">' . $contentHtml . '</td></tr></table></td>';
}
private static function normalizeTemplate($template)
{
$template = is_string($template) ? trim($template) : 'left';
return in_array($template, ['left', 'top', 'none'], true) ? $template : 'left';
}
}
@@ -0,0 +1,182 @@
<?php
namespace FluentCrm\App\Services\BlockRender;
class CartProductData
{
/**
* Query FluentCart products and return normalized product payloads.
*
* @param array $options
* @param string $imageSize
* @return array
*/
public static function getProducts($options = [], $imageSize = 'large')
{
if (!defined('FLUENTCART_VERSION')) {
return [];
}
$queryArgs = self::buildQueryArgs($options);
$query = new \WP_Query($queryArgs);
$posts = $query->posts;
wp_reset_postdata();
if (!$posts) {
return [];
}
$postIds = wp_list_pluck($posts, 'ID');
$detailMap = self::buildDetailMap($postIds);
$products = [];
foreach ($posts as $post) {
$products[] = self::mapPostToProduct($post, $detailMap, $imageSize);
}
return $products;
}
/**
* Get a single FluentCart product by ID.
*
* @param int $productId
* @param string $imageSize
* @return array|null
*/
public static function getProductById($productId, $imageSize = 'large')
{
$productId = absint($productId);
if (!$productId) {
return null;
}
$products = self::getProducts([
'productId' => $productId,
'perPage' => 1,
], $imageSize);
return isset($products[0]) ? $products[0] : null;
}
/**
* Build query args for FluentCart products.
*
* @param array $options
* @return array
*/
public static function buildQueryArgs($options = [])
{
$perPage = max(1, min(20, absint(isset($options['perPage']) ? $options['perPage'] : 3)));
$order = strtolower((string)(isset($options['order']) ? $options['order'] : 'desc')) === 'asc' ? 'ASC' : 'DESC';
$orderByRaw = sanitize_key((string)(isset($options['orderBy']) ? $options['orderBy'] : 'date'));
$productId = absint(isset($options['productId']) ? $options['productId'] : 0);
$search = sanitize_text_field((string)(isset($options['search']) ? $options['search'] : ''));
$taxType = sanitize_text_field((string)(isset($options['taxType']) ? $options['taxType'] : 'all'));
$orderByMap = [
'date' => 'date',
'modified' => 'modified',
'title' => 'title',
'menu_order' => 'menu_order',
'rand' => 'rand',
];
$orderBy = isset($orderByMap[$orderByRaw]) ? $orderByMap[$orderByRaw] : 'date';
$queryArgs = [
'post_type' => 'fluent-products',
'post_status' => 'publish',
'posts_per_page' => $perPage,
'orderby' => $orderBy,
'order' => $order,
'no_found_rows' => true,
];
if ($productId) {
$queryArgs['post__in'] = [$productId];
$queryArgs['posts_per_page'] = 1;
$queryArgs['orderby'] = 'post__in';
} elseif ($search !== '') {
$queryArgs['s'] = $search;
}
if ($taxType && $taxType !== 'all' && is_numeric($taxType)) {
$queryArgs['tax_query'] = [[
'taxonomy' => 'product-categories',
'field' => 'term_id',
'terms' => [(int)$taxType],
]];
}
return $queryArgs;
}
private static function buildDetailMap($postIds)
{
$detailMap = [];
if (!$postIds || !class_exists('\FluentCart\App\Models\ProductDetail')) {
return $detailMap;
}
try {
$details = \FluentCart\App\Models\ProductDetail::query()
->whereIn('post_id', $postIds)
->get();
foreach ($details as $detail) {
$detailMap[(int)$detail->post_id] = $detail;
}
} catch (\Throwable $e) {
$detailMap = [];
}
return $detailMap;
}
private static function mapPostToProduct($post, $detailMap, $imageSize)
{
$postId = (int)$post->ID;
$detail = isset($detailMap[$postId]) ? $detailMap[$postId] : null;
$priceHtml = ($detail && isset($detail->formatted_min_price) && $detail->formatted_min_price)
? (string)$detail->formatted_min_price
: __('Free', 'fluent-crm');
$priceHtml = ProductListRenderer::preparePriceHtml($priceHtml, __('Free', 'fluent-crm'));
$priceText = html_entity_decode(
wp_strip_all_tags((string)$priceHtml),
ENT_QUOTES,
get_bloginfo('charset') ?: 'UTF-8'
);
$priceText = preg_replace('/\s+/', ' ', trim($priceText));
if ($priceText === '') {
$priceText = __('Free', 'fluent-crm');
}
$categories = wp_get_post_terms($postId, 'product-categories', ['fields' => 'names']);
if (is_wp_error($categories) || !is_array($categories)) {
$categories = [];
}
$title = get_the_title($postId);
$label = '#' . $postId . ' - ' . $title;
if ($priceText) {
$label .= ' | ' . $priceText;
}
if ($categories) {
$label .= ' | ' . implode(', ', $categories);
}
return [
'id' => $postId,
'name' => $title,
'label' => $label,
'short_description' => wp_kses_post(get_the_excerpt($postId)),
'price_html' => $priceHtml,
'price_text' => $priceText,
'categories' => $categories,
'image' => get_the_post_thumbnail_url($postId, $imageSize) ?: '',
'permalink' => get_permalink($postId) ?: '#',
];
}
}
@@ -0,0 +1,52 @@
<?php
namespace FluentCrm\App\Services\BlockRender;
class CartProducts
{
/**
* Render FluentCart products list block for email body.
*
* @param string $content
* @param array $data
* @return string
*/
public static function renderProducts($content, $data)
{
$customRenderer = apply_filters('fluent_crm/cart_products_renderer', null, $content, $data);
if (is_callable($customRenderer)) {
try {
$rendered = call_user_func($customRenderer, $content, $data);
return is_string($rendered) ? ProductListRenderer::postProcessRendered($rendered, $data) : '';
} catch (\Throwable $e) {
// Fall through to built-in renderer.
}
}
if (!defined('FLUENTCART_VERSION')) {
return '';
}
$attrs = isset($data['attrs']) && is_array($data['attrs']) ? $data['attrs'] : [];
$atts = ProductListRenderer::mergeDefaultAttributes($attrs);
$products = self::getFluentCartProducts($atts);
if (!$products) {
return '';
}
$html = ProductListRenderer::renderProducts($products, $atts, 'fc_woo_products fc_cart_products');
return ProductListRenderer::postProcessRendered($html, $data);
}
private static function getFluentCartProducts($atts)
{
return CartProductData::getProducts([
'perPage' => isset($atts['selectedPostsPerPage']) ? $atts['selectedPostsPerPage'] : 3,
'order' => isset($atts['order']) ? $atts['order'] : 'desc',
'orderBy' => isset($atts['orderBy']) ? $atts['orderBy'] : 'date',
'taxType' => isset($atts['taxType']) ? $atts['taxType'] : 'all',
], 'large');
}
}
@@ -0,0 +1,442 @@
<?php
namespace FluentCrm\App\Services\BlockRender;
class ProductListRenderer
{
/**
* Default block attributes for list-style product blocks.
*
* @param array $attrs
* @return array
*/
public static function mergeDefaultAttributes($attrs = [])
{
if (!is_array($attrs)) {
$attrs = [];
}
return wp_parse_args($attrs, [
'selectedLayout' => 'default',
'selectedPostsPerPage' => '3',
'showDescription' => true,
'showImage' => true,
'showPrice' => true,
'showButton' => true,
'buttonText' => 'Buy Now',
'titleColor' => '#2a363d',
'descriptionColor' => '',
'priceColor' => '#37454e',
'buttonColor' => '',
'buttonBG' => '#2a363d',
'taxType' => 'all',
'order' => 'desc',
'orderBy' => 'date',
]);
}
/**
* Convert normalized product arrays into email-safe HTML.
*
* @param array $products
* @param array $atts
* @param string $tableClass
* @return string
*/
public static function renderProducts($products, $atts, $tableClass = 'fc_woo_products')
{
if (!is_array($products) || !$products) {
return '';
}
$selectedLayout = self::normalizeLayout(isset($atts['selectedLayout']) ? $atts['selectedLayout'] : 'default');
$productsHtml = '';
foreach ($products as $product) {
$productsHtml .= self::renderProduct($product, $atts, $selectedLayout);
}
if (!$productsHtml) {
return '';
}
if ($selectedLayout === 'layout-2') {
return $productsHtml;
}
return '<table class="' . esc_attr($tableClass) . '" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td class="template-' . esc_attr($selectedLayout) . '">' . $productsHtml . '</td></tr></tbody></table>';
}
public static function postProcessRendered($html, $data)
{
if (!is_string($html) || $html === '') {
return '';
}
$attrs = isset($data['attrs']) && is_array($data['attrs']) ? $data['attrs'] : [];
if (!self::isShowImageEnabled($attrs)) {
$html = self::removeProductImagePlaceholders($html);
}
return $html;
}
private static function renderProduct($product, $atts, $layout)
{
$showImage = RenderValueHelper::normalizeBool(isset($atts['showImage']) ? $atts['showImage'] : true, true);
$showDescription = RenderValueHelper::normalizeBool(isset($atts['showDescription']) ? $atts['showDescription'] : true, true);
$showPrice = RenderValueHelper::normalizeBool(isset($atts['showPrice']) ? $atts['showPrice'] : true, true);
$showButton = RenderValueHelper::normalizeBool(isset($atts['showButton']) ? $atts['showButton'] : true, true);
$titleColor = trim((string)(isset($atts['titleColor']) ? $atts['titleColor'] : ''));
$descriptionColor = trim((string)(isset($atts['descriptionColor']) ? $atts['descriptionColor'] : ''));
$priceColor = trim((string)(isset($atts['priceColor']) ? $atts['priceColor'] : ''));
$buttonColor = trim((string)(isset($atts['buttonColor']) ? $atts['buttonColor'] : ''));
$buttonBg = trim((string)(isset($atts['buttonBG']) ? $atts['buttonBG'] : ''));
$title = wp_kses_post(isset($product['name']) ? $product['name'] : '');
$description = wp_kses_post(isset($product['short_description']) ? $product['short_description'] : '');
$priceHtml = self::preparePriceHtml(
isset($product['price_html']) ? $product['price_html'] : '',
__('Free', 'fluent-crm')
);
$image = esc_url(isset($product['image']) ? $product['image'] : '');
$permalink = esc_url(isset($product['permalink']) ? $product['permalink'] : '#');
$buttonText = esc_html(isset($atts['buttonText']) ? $atts['buttonText'] : __('Buy Now', 'fluent-crm'));
$titleStyle = $titleColor ? 'color:' . esc_attr($titleColor) . ';' : '';
$descriptionStyle = $descriptionColor ? 'color:' . esc_attr($descriptionColor) . ';' : '';
$priceStyle = $priceColor ? 'color:' . esc_attr($priceColor) . ';' : '';
$buttonStyle = $buttonColor ? 'color:' . esc_attr($buttonColor) . ';' : '';
if ($layout === 'layout-2' && $buttonBg) {
$buttonStyle .= 'background:' . esc_attr($buttonBg) . ';';
}
if ($layout === 'layout-2') {
return self::renderLayoutTwoProduct(
$title,
$description,
$priceHtml,
$image,
$permalink,
$buttonText,
$titleStyle,
$descriptionStyle,
$priceStyle,
$buttonStyle,
$showImage,
$showDescription,
$showPrice,
$showButton
);
}
$contentHtml = '<div class="fc_woo_product_info"><div>';
$contentHtml .= '<h2 class="title"><a style="' . esc_attr($titleStyle) . '" target="_blank" href="' . $permalink . '">' . $title . '</a></h2>';
if ($showPrice) {
$contentHtml .= '<span class="price" style="' . esc_attr($priceStyle) . '">' . $priceHtml . '</span>';
}
$contentHtml .= '</div>';
if ($layout !== 'layout-3' && $showButton) {
$contentHtml .= '<a href="' . $permalink . '" target="_blank" style="' . esc_attr($buttonStyle) . '" class="add-to-cart-btn">' . $buttonText . '</a>';
}
$contentHtml .= '</div>';
$imageHtml = '';
if ($showImage && $image) {
$imageHtml = '<div class="fc_woo_product_img"><img src="' . $image . '" alt="' . esc_attr(wp_strip_all_tags($title)) . '"></div>';
}
return '<table class="fce_row fc_woo_product ' . esc_attr($layout) . '" border="0" cellpadding="0" cellspacing="0" width="40%" style="margin-bottom:35px;"><tbody><tr><td width="100%">' . $imageHtml . $contentHtml . '</td></tr></tbody></table>';
}
private static function renderLayoutTwoProduct($title, $description, $priceHtml, $image, $permalink, $buttonText, $titleStyle, $descriptionStyle, $priceStyle, $buttonStyle, $showImage, $showDescription, $showPrice, $showButton)
{
$hasImage = $showImage && $image;
$imageCell = '';
if ($hasImage) {
$imageCell = '<td width="45%" class="fc_cart_product_img" style="background:url(\'' . $image . '\') center no-repeat;background-size:cover;"></td>';
}
$contentWidth = $hasImage ? '55%' : '100%';
$contentPadding = $hasImage ? 'padding:10px 10px 10px 20px;' : 'padding:10px;';
$contentHtml = '<div class="fc_woo_product_info"><div>';
$contentHtml .= '<h2 class="title"><a style="' . esc_attr($titleStyle) . '" target="_blank" href="' . $permalink . '">' . $title . '</a></h2>';
if ($showDescription) {
$contentHtml .= '<div class="description" style="' . esc_attr($descriptionStyle) . '">' . $description . '</div>';
}
if ($showPrice) {
$contentHtml .= '<span class="price" style="' . esc_attr($priceStyle) . '">' . $priceHtml . '</span>';
}
$contentHtml .= '</div>';
if ($showButton) {
$contentHtml .= '<a href="' . $permalink . '" target="_blank" style="' . esc_attr($buttonStyle) . '" class="add-to-cart-btn">' . $buttonText . '</a>';
}
$contentHtml .= '</div>';
return '<table class="fce_row fc_woo_product layout-2" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom:35px;"><tbody><tr>' . $imageCell . '<td width="' . esc_attr($contentWidth) . '" style="' . esc_attr($contentPadding) . '">' . $contentHtml . '</td></tr></tbody></table>';
}
private static function normalizeLayout($layout)
{
$layout = is_string($layout) ? trim($layout) : 'default';
return in_array($layout, ['default', 'layout-2', 'layout-3'], true) ? $layout : 'default';
}
/**
* Prepare product price markup for email/block-editor visual output.
*
* WooCommerce sale price HTML includes screen-reader helper spans. Remove
* those snippet-visible labels while keeping visible sale prices readable.
*
* @param string $priceHtml Raw product price HTML.
* @param string $fallback Fallback text when no price is available.
* @return string
*/
public static function preparePriceHtml($priceHtml, $fallback = '')
{
$priceHtml = wp_kses_post((string)$priceHtml);
if ($priceHtml === '') {
return esc_html($fallback);
}
$priceHtml = self::removeScreenReaderText($priceHtml);
$priceHtml = self::removeAttributeFromTag($priceHtml, 'del', 'aria-hidden');
$priceHtml = self::removeAttributeFromTag($priceHtml, 'ins', 'aria-hidden');
$priceHtml = self::addInlineStyleToTag(
$priceHtml,
'del',
'color:#9aa3aa;text-decoration:line-through;margin-right:8px;'
);
$priceHtml = self::addInlineStyleToTag(
$priceHtml,
'ins',
'text-decoration:none;font-weight:600;'
);
return trim(wp_kses_post($priceHtml));
}
private static function removeScreenReaderText($html)
{
return preg_replace('/<span\b(?=[^>]*\bclass=(["\'])[^"\']*\bscreen-reader-text\b[^"\']*\1)[^>]*>.*?<\/span>/is', '', $html);
}
private static function addInlineStyleToTag($html, $tag, $style)
{
return preg_replace_callback('/<' . preg_quote($tag, '/') . '\b([^>]*)>/i', function ($matches) use ($tag, $style) {
$attributes = $matches[1];
if (preg_match('/\sstyle=(["\'])(.*?)\1/i', $attributes)) {
$attributes = preg_replace_callback('/\sstyle=(["\'])(.*?)\1/i', function ($styleMatches) use ($style) {
if (strpos($styleMatches[2], rtrim($style, ';')) !== false) {
return $styleMatches[0];
}
return ' style=' . $styleMatches[1] . rtrim($styleMatches[2], ';') . ';' . $style . $styleMatches[1];
}, $attributes, 1);
return '<' . $tag . $attributes . '>';
}
return '<' . $tag . ' style="' . $style . '"' . $attributes . '>';
}, $html);
}
private static function removeAttributeFromTag($html, $tag, $attribute)
{
return preg_replace_callback('/<' . preg_quote($tag, '/') . '\b([^>]*)>/i', function ($matches) use ($tag, $attribute) {
$attributes = preg_replace('/\s' . preg_quote($attribute, '/') . '\s*=\s*(["\']).*?\1/i', '', $matches[1]);
$attributes = preg_replace('/\s' . preg_quote($attribute, '/') . '\s*=\s*[^\s>]*/i', '', $attributes);
return '<' . $tag . $attributes . '>';
}, $html);
}
private static function removeProductImagePlaceholders($html)
{
// Fast path first; also helps as fallback when DOM extension is unavailable.
$html = preg_replace('/<img\b[^>]*>/i', '', $html);
$html = preg_replace('/<figure\b[^>]*>\s*<\/figure>/i', '', $html);
$html = self::stripImageColumnsWithRegex($html);
if (!class_exists('\DOMDocument') || trim($html) === '') {
return $html;
}
$internalErrors = libxml_use_internal_errors(true);
$dom = new \DOMDocument('1.0', 'UTF-8');
$loaded = $dom->loadHTML(
'<?xml encoding="utf-8" ?><div id="fc_products_root">' . $html . '</div>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
libxml_use_internal_errors($internalErrors);
if (!$loaded) {
return $html;
}
$xpath = new \DOMXPath($dom);
$imageClassQueries = [
"//*[contains(concat(' ', normalize-space(@class), ' '), ' fc_woo_product_img ')]",
"//*[contains(concat(' ', normalize-space(@class), ' '), ' fc_cart_product_img ')]"
];
foreach ($imageClassQueries as $query) {
$nodes = $xpath->query($query);
if (!$nodes) {
continue;
}
for ($i = $nodes->length - 1; $i >= 0; $i--) {
self::removeNode($nodes->item($i));
}
}
$imageCells = $xpath->query("//td[contains(translate(@style, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'url(')]");
if ($imageCells) {
for ($i = $imageCells->length - 1; $i >= 0; $i--) {
$cell = $imageCells->item($i);
if (self::isImageOnlyCell($cell)) {
self::removeNode($cell);
}
}
}
$rows = $xpath->query('//tr[count(td)=1]');
if ($rows) {
for ($i = 0; $i < $rows->length; $i++) {
$row = $rows->item($i);
$cells = $row->getElementsByTagName('td');
if (!$cells || !$cells->item(0)) {
continue;
}
$cell = $cells->item(0);
$cell->setAttribute('width', '100%');
$style = self::normalizeCellStyle($cell->getAttribute('style'));
if ($style) {
$cell->setAttribute('style', $style);
} else {
$cell->removeAttribute('style');
}
}
}
$root = $dom->getElementById('fc_products_root');
if (!$root) {
return $html;
}
$output = '';
foreach ($root->childNodes as $child) {
$output .= $dom->saveHTML($child);
}
return self::stripImageColumnsWithRegex($output ?: $html);
}
private static function stripImageColumnsWithRegex($html)
{
$html = preg_replace(
'/<div\b[^>]*class=(["\'])[^"\']*(?:fc_woo_product_img|fc_cart_product_img)[^"\']*\1[^>]*>.*?<\/div>/is',
'',
$html
);
$html = preg_replace(
'/<td\b[^>]*style=(["\'])[^"\']*url\([^)]+\)[^"\']*\1[^>]*>\s*<\/td>/is',
'',
$html
);
$html = preg_replace('/\bwidth=(["\'])55%\1/i', 'width="100%"', $html);
$html = preg_replace('/\bpadding-left\s*:\s*[^;]+;?/i', '', $html);
$html = preg_replace('/\s{2,}/', ' ', $html);
return $html;
}
private static function normalizeCellStyle($style)
{
if (!$style) {
return '';
}
$style = preg_replace('/\bbackground(?:-image)?\s*:\s*[^;]+;?/i', '', $style);
$style = preg_replace('/\bbackground-size\s*:\s*[^;]+;?/i', '', $style);
$style = preg_replace('/\bbackground-position\s*:\s*[^;]+;?/i', '', $style);
$style = preg_replace('/\bbackground-repeat\s*:\s*[^;]+;?/i', '', $style);
$style = preg_replace('/\bpadding-left\s*:\s*[^;]+;?/i', '', $style);
$style = preg_replace('/\bwidth\s*:\s*(?:40%|45%|50%);?/i', '', $style);
$style = preg_replace_callback(
'/\bpadding\s*:\s*([0-9.]+[a-z%]+)\s+([0-9.]+[a-z%]+)\s+([0-9.]+[a-z%]+)\s+([0-9.]+[a-z%]+)\s*;?/i',
function ($matches) {
return 'padding:' . $matches[1] . ' ' . $matches[2] . ' ' . $matches[3] . ';';
},
$style
);
$style = preg_replace('/;\s*;/', ';', $style);
return trim($style, " \t\n\r\0\x0B;");
}
private static function removeNode($node)
{
if ($node && $node->parentNode) {
$node->parentNode->removeChild($node);
}
}
private static function isImageOnlyCell($cell)
{
if (!$cell) {
return false;
}
$text = trim(str_replace("\xc2\xa0", '', (string)$cell->textContent));
if ($text !== '') {
return false;
}
foreach ($cell->childNodes as $child) {
if ($child->nodeType !== XML_ELEMENT_NODE) {
continue;
}
$tag = strtolower($child->nodeName);
if (!in_array($tag, ['img', 'figure', 'picture', 'source', 'br'], true)) {
return false;
}
}
return true;
}
private static function isShowImageEnabled($attrs)
{
if (!array_key_exists('showImage', $attrs)) {
return true;
}
return RenderValueHelper::normalizeBool($attrs['showImage'], true);
}
}
@@ -0,0 +1,35 @@
<?php
namespace FluentCrm\App\Services\BlockRender;
class RenderValueHelper
{
/**
* Normalize mixed values coming from block attrs into bool.
*
* @param mixed $value
* @param bool $default
* @return bool
*/
public static function normalizeBool($value, $default = true)
{
if ($value === null) {
return $default;
}
if (is_bool($value)) {
return $value;
}
if (is_numeric($value)) {
return ((int)$value) !== 0;
}
if (is_string($value)) {
$normalized = strtolower(trim($value));
return !in_array($normalized, ['0', 'false', 'no', 'off'], true);
}
return (bool)$value;
}
}
@@ -0,0 +1,181 @@
<?php
namespace FluentCrm\App\Services\BlockRender;
use FluentCrm\Framework\Support\Arr;
class WooProduct
{
private static $productsCache = [];
public static function renderProduct($buttonHtml, $data)
{
if(!defined('WC_PLUGIN_FILE')) {
return '';
}
$defaultAtts = [
'productId' => null,
'showDescription' => true,
'showPrice' => true,
'customImage' => '',
'backgroundColor' => '#fffeeb',
'contentColor' => '',
'pricingColor' => '',
'template' => 'left'
];
$atts = wp_parse_args($data['attrs'], $defaultAtts);
$productId = (int) $atts['productId'];
if (!$productId) {
return '';
}
if(isset(self::$productsCache[$productId])) {
$product = self::$productsCache[$productId];
} else {
self::$productsCache[$productId] = wc_get_product($productId);
$product = self::$productsCache[$productId];
}
if (!$product) {
return '';
}
$tableStyle = 'border-radius: 5px;';
if ($bgColor = Arr::get($atts, 'backgroundColor')) {
$tableStyle .= 'background-color: ' . $bgColor . ';';
}
$contentColorStyle = '';
if ($color = Arr::get($atts, 'contentColor')) {
$tableStyle .= 'color: ' . $color . ';';
$contentColorStyle = 'color: ' . $color . ';';
}
$pricingStyle = 'display:block;margin:8px 0 10px;font-size:18px;line-height:1.25;';
if ($color = Arr::get($atts, 'pricingColor')) {
$pricingStyle .= 'color: ' . $color . ';';
}
$contentHtml = sprintf(
'<h2 style="%1$s">%2$s</h2>',
$contentColorStyle.'margin: 0;',
wp_kses_post($product->get_title())
);
if (Arr::get($atts, 'showDescription')) {
$contentHtml .= sprintf(
'<div style="%1$s">%2$s</div>',
$contentColorStyle.'margin-bottom: 5px;',
wc_format_content(wp_kses_post($product->get_short_description() ? $product->get_short_description() : wc_trim_string($product->get_description(), 400)))
);
}
if (Arr::get($atts, 'showPrice')) {
$contentHtml .= sprintf(
'<div class="fcw_p_price" style="%1$s">%2$s</div>',
$pricingStyle,
ProductListRenderer::preparePriceHtml($product->get_price_html(), __('Free', 'fluent-crm'))
);
}
$image = '';
$template = Arr::get($atts, 'template', 'left');
if($template != 'none') {
$image = self::getImage($product);
}
if (!$image) {
$template = 'none';
}
$atts['template'] = $template;
$contentStyle = '';
if ($template == 'none' || $template == 'top') {
$contentStyle = 'text-align: center;';
}
$imageTd = '';
if($image) {
if($template == 'top') {
$contentHtml = '<img src="'.$image.'" width="auto" alt="'.$product->get_title().'" style="border: 0; height: auto; outline: none; text-decoration: none; max-width: 100%; -ms-interpolation-mode: bicubic; display: block;margin: 0 auto 15px;">'.$contentHtml;
} else if($template == 'left') {
$imageContent = '<img src="'.$image.'" width="auto" alt="'.$product->get_title().'" style="border: 0; height: auto; outline: none; text-decoration: none; max-width: 100%; -ms-interpolation-mode: bicubic; display: block;margin: 0 auto;">';
$imageTd = self::getContentTd($imageContent, $template);
}
}
$buttonHtml = '<div style="margin-top: 10px; margin-bottom: 10px;">'.$buttonHtml.'</div>';
$contentHtml .= $buttonHtml;
$tableClass = 'fce_row';
if ($template === 'left' || $template === 'right') {
$tableClass .= ' fc_woo_product_stack_mobile';
}
ob_start();
?>
<table class="<?php echo esc_attr($tableClass); ?>" border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout: fixed; border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;-ms-text-size-adjust: 100%;-webkit-text-size-adjust: 100%;margin-bottom: 20px; margin-top: 20px;<?php echo $tableStyle; //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>"><tbody><tr>
<?php
if($imageTd) {
echo $imageTd; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo self::getContentTd($contentHtml, $template, $contentStyle); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
?>
</tr></tbody></table>
<?php
return ob_get_clean();
}
private static function getContentTd($contentHtml, $template, $extraStyle = '')
{
if(!defined('WC_PLUGIN_FILE')) {
return '';
}
$width = '100';
if($template == 'left') {
$width = '50';
}
return '<td align="center" valign="middle" width="' . $width . '%" class="fce_column"><table border="0" cellpadding="10" cellspacing="0" width="100%"><tr><td class="fc_column_content" style="padding: 10px;'. $extraStyle.'">'.$contentHtml.'</td></tr></table></td>'; //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Returns the main product image URL.
*
* @param \WC_Product $product Product object.
* @param string $size Image size, defaults to 'full'.
* @return string
*/
public static function getImage($product, $size = 'full')
{
if(!defined('WC_PLUGIN_FILE')) {
return '';
}
static $imageCache = [];
if(isset($imageCache[$product->get_id()])) {
return $imageCache[$product->get_id()];
}
$image = '';
if ($product->get_image_id()) {
$image = wp_get_attachment_image_url($product->get_image_id(), $size);
} elseif ($product->get_parent_id()) {
$parent_product = wc_get_product($product->get_parent_id());
if ($parent_product) {
$image = wp_get_attachment_image_url($parent_product->get_image_id(), $size);
}
}
$imageCache[$product->get_id()] = $image;
return $imageCache[$product->get_id()] ;
}
}
@@ -0,0 +1,111 @@
<?php
namespace FluentCrm\App\Services\BlockRender;
class WooProducts
{
/**
* Render WooCommerce products list block for email body.
*
* @param string $content
* @param array $data
* @return string
*/
public static function renderProducts($content, $data)
{
$customRenderer = apply_filters('fluent_crm/products_renderer', null, $content, $data);
if (is_callable($customRenderer)) {
try {
$rendered = call_user_func($customRenderer, $content, $data);
return is_string($rendered) ? ProductListRenderer::postProcessRendered($rendered, $data) : '';
} catch (\Throwable $e) {
// Fall through to built-in renderer.
}
}
if (!defined('WC_PLUGIN_FILE') || !function_exists('wc_get_products')) {
return '';
}
$attrs = isset($data['attrs']) && is_array($data['attrs']) ? $data['attrs'] : [];
$atts = ProductListRenderer::mergeDefaultAttributes($attrs);
$products = self::getWooProducts($atts);
if (!$products) {
return '';
}
$html = ProductListRenderer::renderProducts($products, $atts, 'fc_woo_products');
return ProductListRenderer::postProcessRendered($html, $data);
}
private static function getWooProducts($atts)
{
if (!defined('WC_PLUGIN_FILE') || !function_exists('wc_get_products')) {
return [];
}
$perPage = max(1, min(20, absint(isset($atts['selectedPostsPerPage']) ? $atts['selectedPostsPerPage'] : 3)));
$order = strtolower((string)(isset($atts['order']) ? $atts['order'] : 'desc')) === 'asc' ? 'ASC' : 'DESC';
$orderByRaw = sanitize_key((string)(isset($atts['orderBy']) ? $atts['orderBy'] : 'date'));
$orderByMap = [
'date' => 'date',
'modified' => 'modified',
'title' => 'title',
'menu_order' => 'menu_order',
'rand' => 'rand',
];
$orderBy = isset($orderByMap[$orderByRaw]) ? $orderByMap[$orderByRaw] : 'date';
$queryArgs = [
'status' => 'publish',
'limit' => $perPage,
'orderby' => $orderBy,
'order' => $order,
'return' => 'objects',
];
$taxType = sanitize_text_field((string)(isset($atts['taxType']) ? $atts['taxType'] : 'all'));
if ($taxType && $taxType !== 'all' && is_numeric($taxType)) {
$term = get_term((int)$taxType, 'product_cat');
if ($term && !is_wp_error($term) && !empty($term->slug)) {
$queryArgs['category'] = [$term->slug];
}
}
try {
$wooProducts = wc_get_products($queryArgs);
} catch (\Throwable $e) {
return [];
}
if (!$wooProducts) {
return [];
}
$products = [];
foreach ($wooProducts as $product) {
if (!$product || !method_exists($product, 'get_id')) {
continue;
}
$description = $product->get_short_description();
if (!$description && method_exists($product, 'get_description')) {
$description = wc_trim_string($product->get_description(), 400);
}
$products[] = [
'id' => $product->get_id(),
'name' => $product->get_name(),
'short_description' => wp_kses_post($description),
'price_html' => ProductListRenderer::preparePriceHtml($product->get_price_html(), __('Free', 'fluent-crm')),
'image' => WooProduct::getImage($product),
'permalink' => $product->get_permalink() ?: '#',
];
}
return $products;
}
}