feat: Page Editor v1.0 - canonical schema, SSR parity, and migration

Major improvements to WooNooW Page Editor system:

Schema & Architecture:
- Canonical section schema with unified sectionSchema.ts
- Normalized feature-grid to use items (not features)
- Standardized default values across all section types
- Schema versioning with automatic migration on read

Backend (PHP):
- Enhanced PlaceholderRenderer with typed output contracts
- Added fallback behavior for empty/invalid dynamic sources
- Added caching support for post data resolution
- New SchemaMigration class for backward compatibility
- New Features class for feature flags
- Enhanced PageSSR with full style support
- Removed controller-level special-casing for related_posts

Frontend (Admin SPA):
- Updated CanvasRenderer with schema-aware transformation
- Enhanced InspectorPanel with canonical schema metadata
- Added new section renderers

Frontend (Customer SPA):
- New section components: BentoCategoryGrid, MarqueeBanner, ProductCarousel, ShoppableImage
- Updated FeatureGridSection for items prop contract

Testing:
- Add PHP tests: SchemaMigrationTest, PlaceholderRendererTest, PageSSRTest
- Add TypeScript tests: schema-integration, feature-grid-regression
- Add parity tests for React vs SSR content matching
- Add CI script: check-schema-drift.mjs
- Add VERIFICATION_CHECKLIST.md

Documentation:
- RELEASE_NOTES-v1.0.md with full release notes
- docs/PAGE_EDITOR_SECTION_SCHEMA_V1.md
- docs/PAGE_EDITOR_SSR_COVERAGE_AUDIT.md
This commit is contained in:
Dwindi Ramadhana
2026-05-30 13:02:08 +07:00
parent e70aa1f554
commit 396ca25be4
118 changed files with 10162 additions and 3726 deletions

View File

@@ -65,14 +65,19 @@ class PageSSR
/**
* Resolve props - replace dynamic placeholders with actual values
*
*
* @param array $props Section props
* @param array|null $post_data Post data
* @param array $options Resolution options
* @return array Resolved props with actual values
*/
public static function resolve_props($props, $post_data = null)
public static function resolve_props($props, $post_data = null, $options = [])
{
$resolved = [];
$options = wp_parse_args($options, [
'validate_types' => true,
'use_fallbacks' => true,
]);
foreach ($props as $key => $prop) {
if (!is_array($prop)) {
@@ -86,7 +91,12 @@ class PageSSR
$resolved[$key] = $prop['value'] ?? '';
} elseif ($type === 'dynamic' && $post_data) {
$source = $prop['source'] ?? '';
$resolved[$key] = PlaceholderRenderer::get_value($source, $post_data);
$resolved[$key] = PlaceholderRenderer::get_value($source, $post_data, [
'validate_type' => $options['validate_types'],
'use_fallback' => $options['use_fallbacks'],
]);
} elseif ($options['use_fallbacks']) {
$resolved[$key] = $prop['value'] ?? PlaceholderRenderer::get_fallback($key);
} else {
$resolved[$key] = $prop['value'] ?? '';
}
@@ -281,73 +291,26 @@ class PageSSR
$props['content']['value'] = $content;
}
// Section Styles (Background)
// Section Styles (Background & Spacing)
$bg_type = $section_styles['backgroundType'] ?? 'solid';
$bg_color = $section_styles['backgroundColor'] ?? '';
$gradient_from = $section_styles['gradientFrom'] ?? '#9333ea';
$gradient_to = $section_styles['gradientTo'] ?? '#3b82f6';
$gradient_angle = $section_styles['gradientAngle'] ?? 135;
$bg_image = $section_styles['backgroundImage'] ?? '';
$overlay_opacity = $section_styles['backgroundOverlay'] ?? 0;
$pt = $section_styles['paddingTop'] ?? '';
$pb = $section_styles['paddingBottom'] ?? '';
$content_width = $section_styles['contentWidth'] ?? 'contained';
$height_preset = $section_styles['heightPreset'] ?? 'default';
$css = "";
if ($bg_type === 'gradient') {
$from = $section_styles['gradientFrom'] ?? '#9333ea';
$to = $section_styles['gradientTo'] ?? '#3b82f6';
$angle = $section_styles['gradientAngle'] ?? 135;
$css .= "background: linear-gradient({$angle}deg, {$from}, {$to});";
$css .= "background: linear-gradient({$gradient_angle}deg, {$gradient_from}, {$gradient_to});";
} elseif ($bg_image) {
$css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
} elseif ($bg_color) {
$css .= "background-color:{$bg_color};";
}
// Height Logic
$preset_padding = '';
if ($height_preset === 'screen') {
$css .= "min-height: 100vh; display: flex; align-items: center;";
$preset_padding = '5rem'; // Default padding for screen to avoid edge collision
} elseif ($height_preset === 'small') {
$preset_padding = '2rem';
} elseif ($height_preset === 'large') {
$preset_padding = '8rem';
} elseif ($height_preset === 'medium') {
$preset_padding = '4rem';
}
if ($pt) {
$css .= "padding-top:{$pt};";
} elseif ($preset_padding) {
$css .= "padding-top:{$preset_padding};";
}
if ($pb) {
$css .= "padding-bottom:{$pb};";
} elseif ($preset_padding) {
$css .= "padding-bottom:{$preset_padding};";
}
$style_attr = $css ? "style=\"{$css}\"" : "";
$contentWidth = $section_styles['contentWidth'] ?? 'contained';
$inner_html = self::render_universal_row($props, 'left', $color_scheme, $element_styles, ['contentWidth' => $contentWidth]);
return "<section id=\"{$id}\" class=\"wn-section wn-content wn-scheme--{$color_scheme}\" {$style_attr}>{$inner_html}</section>";
}
/**
* Render Image + Text section
*/
public static function render_image_text($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
{
$bg_type = $section_styles['backgroundType'] ?? 'solid';
$bg_color = $section_styles['backgroundColor'] ?? '';
$pt = $section_styles['paddingTop'] ?? '';
$pb = $section_styles['paddingBottom'] ?? '';
$css = "";
if ($bg_type === 'gradient') {
$from = $section_styles['gradientFrom'] ?? '#9333ea';
$to = $section_styles['gradientTo'] ?? '#3b82f6';
$angle = $section_styles['gradientAngle'] ?? 135;
$css .= "background: linear-gradient({$angle}deg, {$from}, {$to});";
} elseif ($bg_color) {
$css .= "background-color:{$bg_color};";
$css .= "background-color: {$bg_color};";
}
// Height Logic
@@ -361,76 +324,221 @@ class PageSSR
$preset_padding = '8rem';
} elseif ($height_preset === 'medium') {
$preset_padding = '4rem';
} else {
$preset_padding = '4rem 1rem';
}
if ($pt) {
$css .= "padding-top:{$pt};";
$css .= "padding-top: {$pt};";
} elseif ($preset_padding) {
$css .= "padding-top:{$preset_padding};";
$css .= "padding-top: {$preset_padding};";
}
if ($pb) {
$css .= "padding-bottom:{$pb};";
$css .= "padding-bottom: {$pb};";
} elseif ($preset_padding) {
$css .= "padding-bottom:{$preset_padding};";
$css .= "padding-bottom: {$preset_padding};";
}
$style_attr = $css ? "style=\"{$css}\"" : "";
// Overlay
$overlay_html = '';
if ($overlay_opacity > 0) {
$opacity = $overlay_opacity / 100;
$overlay_html = "<div class=\"wn-content__overlay\" style=\"background-color: rgba(0,0,0,{$opacity}); position: absolute; inset: 0;\"></div>";
}
$inner_html = self::render_universal_row($props, 'left', $color_scheme, $element_styles, ['contentWidth' => $content_width]);
return "<section id=\"{$id}\" class=\"wn-section wn-content wn-scheme--{$color_scheme}\" {$style_attr}>{$overlay_html}<div style=\"position: relative; z-index: 10;\">{$inner_html}</div></section>";
}
/**
* Render Image + Text section
*/
public static function render_image_text($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
{
$bg_type = $section_styles['backgroundType'] ?? 'solid';
$bg_color = $section_styles['backgroundColor'] ?? '';
$gradient_from = $section_styles['gradientFrom'] ?? '#9333ea';
$gradient_to = $section_styles['gradientTo'] ?? '#3b82f6';
$gradient_angle = $section_styles['gradientAngle'] ?? 135;
$bg_image = $section_styles['backgroundImage'] ?? '';
$overlay_opacity = $section_styles['backgroundOverlay'] ?? 0;
$pt = $section_styles['paddingTop'] ?? '';
$pb = $section_styles['paddingBottom'] ?? '';
$content_width = $section_styles['contentWidth'] ?? 'contained';
$height_preset = $section_styles['heightPreset'] ?? 'default';
$css = "";
if ($bg_type === 'gradient') {
$css .= "background: linear-gradient({$gradient_angle}deg, {$gradient_from}, {$gradient_to});";
} elseif ($bg_image) {
$css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
} elseif ($bg_color) {
$css .= "background-color: {$bg_color};";
}
// Height Logic
$preset_padding = '';
if ($height_preset === 'screen') {
$css .= "min-height: 100vh; display: flex; align-items: center;";
$preset_padding = '5rem';
} elseif ($height_preset === 'small') {
$preset_padding = '2rem';
} elseif ($height_preset === 'large') {
$preset_padding = '8rem';
} elseif ($height_preset === 'medium') {
$preset_padding = '4rem';
} else {
$preset_padding = '4rem 1rem';
}
if ($pt) {
$css .= "padding-top: {$pt};";
} elseif ($preset_padding) {
$css .= "padding-top: {$preset_padding};";
}
if ($pb) {
$css .= "padding-bottom: {$pb};";
} elseif ($preset_padding) {
$css .= "padding-bottom: {$preset_padding};";
}
$style_attr = $css ? "style=\"{$css}\"" : "";
$contentWidth = $section_styles['contentWidth'] ?? 'contained';
$inner_html = self::render_universal_row($props, $layout, $color_scheme, $element_styles, ['contentWidth' => $contentWidth]);
// Overlay
$overlay_html = '';
if ($overlay_opacity > 0) {
$opacity = $overlay_opacity / 100;
$overlay_html = "<div class=\"wn-image-text__overlay\" style=\"background-color: rgba(0,0,0,{$opacity}); position: absolute; inset: 0;\"></div>";
}
return "<section id=\"{$id}\" class=\"wn-section wn-image-text wn-scheme--{$color_scheme}\" {$style_attr}>{$inner_html}</section>";
$inner_html = self::render_universal_row($props, $layout, $color_scheme, $element_styles, ['contentWidth' => $content_width]);
return "<section id=\"{$id}\" class=\"wn-section wn-image-text wn-scheme--{$color_scheme}\" {$style_attr}>{$overlay_html}<div style=\"position: relative; z-index: 10;\">{$inner_html}</div></section>";
}
/**
* Render Feature Grid section
*/
public static function render_feature_grid($props, $layout, $color_scheme, $id, $element_styles = [])
public static function render_feature_grid($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
{
$heading = esc_html($props['heading'] ?? '');
$items = $props['items'] ?? [];
$items = $props['items'] ?? ($props['features'] ?? []);
if (!is_array($items)) {
$items = [];
}
$html = "<section id=\"{$id}\" class=\"wn-section wn-feature-grid wn-feature-grid--{$layout} wn-scheme--{$color_scheme}\">";
// Section Styles (Background & Spacing)
$bg_type = $section_styles['backgroundType'] ?? 'solid';
$bg_color = $section_styles['backgroundColor'] ?? '';
$gradient_from = $section_styles['gradientFrom'] ?? '#9333ea';
$gradient_to = $section_styles['gradientTo'] ?? '#3b82f6';
$gradient_angle = $section_styles['gradientAngle'] ?? 135;
$bg_image = $section_styles['backgroundImage'] ?? '';
$overlay_opacity = $section_styles['backgroundOverlay'] ?? 0;
$pt = $section_styles['paddingTop'] ?? '';
$pb = $section_styles['paddingBottom'] ?? '';
$content_width = $section_styles['contentWidth'] ?? 'contained';
$height_preset = $section_styles['heightPreset'] ?? 'default';
$section_css = '';
if ($bg_type === 'gradient') {
$section_css .= "background: linear-gradient({$gradient_angle}deg, {$gradient_from}, {$gradient_to});";
} elseif ($bg_image) {
$section_css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
} elseif ($bg_color) {
$section_css .= "background-color: {$bg_color};";
}
// Height presets
$height_classes = '';
switch ($height_preset) {
case 'small':
$height_classes = 'py-8 md:py-12';
break;
case 'medium':
$height_classes = 'py-16 md:py-24';
break;
case 'large':
$height_classes = 'py-24 md:py-36';
break;
case 'fullscreen':
$section_css .= "min-height: 100vh; display: flex; align-items: center;";
break;
default:
$height_classes = 'py-12 md:py-20';
}
// Padding overrides
if ($pt) $section_css .= "padding-top: {$pt};";
if ($pb) $section_css .= "padding-bottom: {$pb};";
$section_attr = $section_css ? "style=\"{$section_css}\"" : "";
$wrapper_class = $content_width === 'full' ? 'w-full' : 'container max-w-7xl mx-auto px-4';
$html = "<section id=\"{$id}\" class=\"wn-section wn-feature-grid wn-feature-grid--{$layout} wn-scheme--{$color_scheme} {$height_classes}\" {$section_attr}>";
// Overlay
if ($overlay_opacity > 0) {
$opacity = $overlay_opacity / 100;
$html .= "<div class=\"wn-feature-grid__overlay\" style=\"background-color: rgba(0,0,0,{$opacity}); position: absolute; inset: 0;\"></div>";
}
$html .= '<div class="wn-feature-grid__inner ' . esc_attr($wrapper_class) . '" style="position: relative; z-index: 10;">';
if ($heading) {
$html .= "<h2 class=\"wn-feature-grid__heading\">{$heading}</h2>";
$heading_style = self::generate_style_attr($element_styles['heading'] ?? []);
$html .= "<h2 class=\"wn-feature-grid__heading text-center mb-10\" {$heading_style}>{$heading}</h2>";
}
// Feature Item Styles (Card)
$item_style_attr = self::generate_style_attr($element_styles['feature_item'] ?? []); // BG, Border, Shadow handled by CSS classes mostly, but colors here
$item_style_attr = self::generate_style_attr($element_styles['feature_item'] ?? []);
$item_bg = $element_styles['feature_item']['backgroundColor'] ?? '';
$html .= '<div class="wn-feature-grid__items">';
// Grid columns
$grid_cols = 'grid-cols-1 md:grid-cols-3';
if ($layout === 'grid-2') {
$grid_cols = 'grid-cols-1 md:grid-cols-2';
} elseif ($layout === 'grid-4') {
$grid_cols = 'grid-cols-2 lg:grid-cols-4';
}
$html .= '<div class="wn-feature-grid__items grid gap-6 ' . esc_attr($grid_cols) . '">';
foreach ($items as $item) {
$item_title = esc_html($item['title'] ?? '');
$item_desc = esc_html($item['description'] ?? '');
$item_icon = esc_html($item['icon'] ?? '');
// Allow overriding item specific style if needed, but for now global
$html .= "<div class=\"wn-feature-grid__item\" {$item_style_attr}>";
// Background color for card
$card_style = '';
if ($item_bg) {
$card_style = "background-color: {$item_bg};";
}
$html .= "<div class=\"wn-feature-grid__item p-6 rounded-xl shadow-lg\" " . ($card_style ? "style=\"{$card_style}\"" : "") . ">";
// Render Icon SVG
if ($item_icon) {
$icon_svg = self::get_icon_svg($item_icon);
if ($icon_svg) {
$html .= "<div class=\"wn-feature-grid__icon\">{$icon_svg}</div>";
$html .= "<div class=\"wn-feature-grid__icon mb-4 inline-block p-3 rounded-full bg-primary/10 text-primary\">{$icon_svg}</div>";
}
}
if ($item_title) {
// Feature title style
$f_title_style = self::generate_style_attr($element_styles['feature_title'] ?? []);
$html .= "<h3 class=\"wn-feature-grid__item-title\" {$f_title_style}>{$item_title}</h3>";
$html .= "<h3 class=\"wn-feature-grid__item-title text-xl font-semibold mb-3\" {$f_title_style}>{$item_title}</h3>";
}
if ($item_desc) {
// Feature description style
$f_desc_style = self::generate_style_attr($element_styles['feature_description'] ?? []);
$html .= "<p class=\"wn-feature-grid__item-desc\" {$f_desc_style}>{$item_desc}</p>";
$html .= "<p class=\"wn-feature-grid__item-desc text-gray-600\" {$f_desc_style}>{$item_desc}</p>";
}
$html .= '</div>';
}
$html .= '</div>';
$html .= '</section>';
$html .= '</div></div></section>';
return $html;
}
@@ -438,24 +546,52 @@ class PageSSR
/**
* Render CTA Banner section
*/
public static function render_cta_banner($props, $layout, $color_scheme, $id, $element_styles = [])
public static function render_cta_banner($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
{
$title = esc_html($props['title'] ?? '');
$text = esc_html($props['text'] ?? '');
$button_text = esc_html($props['button_text'] ?? '');
$button_url = esc_url($props['button_url'] ?? '');
$html = "<section id=\"{$id}\" class=\"wn-section wn-cta-banner wn-cta-banner--{$layout} wn-scheme--{$color_scheme}\">";
$html .= '<div class="wn-cta-banner__content">';
// Section Styles (Background & Spacing)
$bg_type = $section_styles['backgroundType'] ?? 'solid';
$bg_color = $section_styles['backgroundColor'] ?? '';
$gradient_from = $section_styles['gradientFrom'] ?? '#9333ea';
$gradient_to = $section_styles['gradientTo'] ?? '#3b82f6';
$gradient_angle = $section_styles['gradientAngle'] ?? 135;
$bg_image = $section_styles['backgroundImage'] ?? '';
$pt = $section_styles['paddingTop'] ?? '';
$pb = $section_styles['paddingBottom'] ?? '';
$content_width = $section_styles['contentWidth'] ?? 'contained';
$section_css = '';
if ($bg_type === 'gradient') {
$section_css .= "background: linear-gradient({$gradient_angle}deg, {$gradient_from}, {$gradient_to});";
} elseif ($bg_image) {
$section_css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
} elseif ($bg_color) {
$section_css .= "background-color: {$bg_color};";
}
if ($pt) $section_css .= "padding-top: {$pt};";
if ($pb) $section_css .= "padding-bottom: {$pb};";
$section_attr = $section_css ? "style=\"{$section_css}\"" : "";
$wrapper_class = $content_width === 'full' ? 'w-full' : 'container max-w-5xl mx-auto px-4';
$html = "<section id=\"{$id}\" class=\"wn-section wn-cta-banner wn-cta-banner--{$layout} wn-scheme--{$color_scheme}\" {$section_attr}>";
$html .= '<div class="wn-cta-banner__content ' . esc_attr($wrapper_class) . '">';
if ($title) {
$html .= "<h2 class=\"wn-cta-banner__title\">{$title}</h2>";
$title_style = self::generate_style_attr($element_styles['title'] ?? []);
$html .= "<h2 class=\"wn-cta-banner__title\" {$title_style}>{$title}</h2>";
}
if ($text) {
$html .= "<p class=\"wn-cta-banner__text\">{$text}</p>";
$text_style = self::generate_style_attr($element_styles['text'] ?? []);
$html .= "<p class=\"wn-cta-banner__text\" {$text_style}>{$text}</p>";
}
if ($button_text && $button_url) {
$html .= "<a href=\"{$button_url}\" class=\"wn-cta-banner__button\">{$button_text}</a>";
$btn_style = self::generate_style_attr($element_styles['button_text'] ?? []);
$html .= "<a href=\"{$button_url}\" class=\"wn-cta-banner__button\" {$btn_style}>{$button_text}</a>";
}
$html .= '</div>';
@@ -467,12 +603,37 @@ class PageSSR
/**
* Render Contact Form section
*/
public static function render_contact_form($props, $layout, $color_scheme, $id, $element_styles = [])
public static function render_contact_form($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
{
$title = esc_html($props['title'] ?? '');
$webhook_url = esc_url($props['webhook_url'] ?? '');
$redirect_url = esc_url($props['redirect_url'] ?? '');
$fields = $props['fields'] ?? ['name', 'email', 'message'];
$fields = is_array($props['fields'] ?? null) ? $props['fields'] : ['name', 'email', 'message'];
// Section Styles (Background & Spacing)
$bg_type = $section_styles['backgroundType'] ?? 'solid';
$bg_color = $section_styles['backgroundColor'] ?? '';
$gradient_from = $section_styles['gradientFrom'] ?? '#9333ea';
$gradient_to = $section_styles['gradientTo'] ?? '#3b82f6';
$gradient_angle = $section_styles['gradientAngle'] ?? 135;
$bg_image = $section_styles['backgroundImage'] ?? '';
$pt = $section_styles['paddingTop'] ?? '';
$pb = $section_styles['paddingBottom'] ?? '';
$content_width = $section_styles['contentWidth'] ?? 'contained';
$section_css = '';
if ($bg_type === 'gradient') {
$section_css .= "background: linear-gradient({$gradient_angle}deg, {$gradient_from}, {$gradient_to});";
} elseif ($bg_image) {
$section_css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
} elseif ($bg_color) {
$section_css .= "background-color: {$bg_color};";
}
if ($pt) $section_css .= "padding-top: {$pt};";
if ($pb) $section_css .= "padding-bottom: {$pb};";
$section_attr = $section_css ? "style=\"{$section_css}\"" : "";
$wrapper_class = $content_width === 'full' ? 'w-full' : 'container max-w-2xl mx-auto px-4';
// Extract styles
$btn_bg = $element_styles['button']['backgroundColor'] ?? '';
@@ -490,14 +651,15 @@ class PageSSR
if ($field_color) $field_style .= "color: {$field_color};";
$field_attr = $field_style ? "style=\"{$field_style}\"" : "";
$html = "<section id=\"{$id}\" class=\"wn-section wn-contact-form wn-scheme--{$color_scheme}\">";
$html = "<section id=\"{$id}\" class=\"wn-section wn-contact-form wn-scheme--{$color_scheme}\" {$section_attr}>";
if ($title) {
$html .= "<h2 class=\"wn-contact-form__title\">{$title}</h2>";
$title_style = self::generate_style_attr($element_styles['title'] ?? []);
$html .= "<h2 class=\"wn-contact-form__title\" {$title_style}>{$title}</h2>";
}
// Form is rendered but won't work for bots (they just see the structure)
$html .= '<form class="wn-contact-form__form" method="post">';
$html .= '<form class="wn-contact-form__form ' . esc_attr($wrapper_class) . '" method="post">';
foreach ($fields as $field) {
$field_label = ucfirst(str_replace('_', ' ', $field));
@@ -518,6 +680,211 @@ class PageSSR
return $html;
}
/**
* Render Bento Category Grid section
*/
public static function render_bento_category_grid($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
{
$title = esc_html($props['title'] ?? '');
$items = $props['items'] ?? [];
if (!is_array($items) || empty($items)) {
$items = [
['label' => 'New Arrivals', 'size' => 'large'],
['label' => 'Best Sellers', 'size' => 'medium'],
['label' => 'On Sale', 'size' => 'small'],
['label' => 'Accessories', 'size' => 'small'],
['label' => 'Collections', 'size' => 'tall'],
];
}
$html = "<section id=\"{$id}\" class=\"wn-section wn-bento-grid wn-scheme--{$color_scheme}\">";
$html .= '<div class="wn-bento-grid__inner">';
if ($title) {
$title_style = self::generate_style_attr($element_styles['title'] ?? []);
$html .= "<h2 class=\"wn-bento-grid__title\" {$title_style}>{$title}</h2>";
}
$html .= '<div class="wn-bento-grid__items">';
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$label = esc_html($item['label'] ?? '');
$url = esc_url($item['url'] ?? '');
$image = esc_url($item['image'] ?? '');
$size = sanitize_html_class($item['size'] ?? 'small');
$content = '';
if ($image) {
$content .= "<img class=\"wn-bento-grid__image\" src=\"{$image}\" alt=\"{$label}\" loading=\"lazy\" />";
}
if ($label) {
$content .= "<span class=\"wn-bento-grid__label\">{$label}</span>";
}
$html .= "<div class=\"wn-bento-grid__item wn-bento-grid__item--{$size}\">";
$html .= $url ? "<a href=\"{$url}\" class=\"wn-bento-grid__link\">{$content}</a>" : $content;
$html .= '</div>';
}
$html .= '</div></div></section>';
return $html;
}
/**
* Render Product Carousel section
*/
public static function render_product_carousel($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
{
$title = esc_html($props['title'] ?? 'Trending Now');
$subtitle = esc_html($props['subtitle'] ?? '');
$cta_text = esc_html($props['cta_text'] ?? '');
$cta_url = esc_url($props['cta_url'] ?? '');
$products = $props['products'] ?? [];
if (!is_array($products)) {
$products = [];
}
$html = "<section id=\"{$id}\" class=\"wn-section wn-product-carousel wn-scheme--{$color_scheme}\">";
$html .= '<div class="wn-product-carousel__inner">';
$html .= '<div class="wn-product-carousel__header">';
if ($title) {
$title_style = self::generate_style_attr($element_styles['title'] ?? []);
$html .= "<h2 class=\"wn-product-carousel__title\" {$title_style}>{$title}</h2>";
}
if ($subtitle) {
$subtitle_style = self::generate_style_attr($element_styles['subtitle'] ?? []);
$html .= "<p class=\"wn-product-carousel__subtitle\" {$subtitle_style}>{$subtitle}</p>";
}
if ($cta_text && $cta_url) {
$html .= "<a href=\"{$cta_url}\" class=\"wn-product-carousel__cta\">{$cta_text}</a>";
}
$html .= '</div>';
if (!empty($products)) {
$html .= '<div class="wn-product-carousel__items">';
foreach ($products as $product) {
if (!is_array($product)) {
continue;
}
$name = esc_html($product['name'] ?? $product['title'] ?? '');
$url = esc_url($product['url'] ?? $product['permalink'] ?? '#');
$image = esc_url($product['image'] ?? $product['featured_image'] ?? '');
$price = wp_kses_post($product['price_html'] ?? $product['price'] ?? '');
$html .= "<a href=\"{$url}\" class=\"wn-product-carousel__item\">";
if ($image) {
$html .= "<img class=\"wn-product-carousel__image\" src=\"{$image}\" alt=\"{$name}\" loading=\"lazy\" />";
}
if ($name) {
$html .= "<h3 class=\"wn-product-carousel__product-title\">{$name}</h3>";
}
if ($price) {
$html .= "<div class=\"wn-product-carousel__price\">{$price}</div>";
}
$html .= '</a>';
}
$html .= '</div>';
}
$html .= '</div></section>';
return $html;
}
/**
* Render Shoppable Image section
*/
public static function render_shoppable_image($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
{
$title = esc_html($props['title'] ?? '');
$subtitle = esc_html($props['subtitle'] ?? '');
$image = esc_url($props['image'] ?? '');
$alt = esc_attr($props['alt'] ?? 'Shoppable image');
$hotspots = $props['hotspots'] ?? [];
if (!is_array($hotspots)) {
$hotspots = [];
}
$html = "<section id=\"{$id}\" class=\"wn-section wn-shoppable-image wn-scheme--{$color_scheme}\">";
$html .= '<div class="wn-shoppable-image__inner">';
if ($title || $subtitle) {
$html .= '<div class="wn-shoppable-image__header">';
if ($title) {
$title_style = self::generate_style_attr($element_styles['title'] ?? []);
$html .= "<h2 class=\"wn-shoppable-image__title\" {$title_style}>{$title}</h2>";
}
if ($subtitle) {
$subtitle_style = self::generate_style_attr($element_styles['subtitle'] ?? []);
$html .= "<p class=\"wn-shoppable-image__subtitle\" {$subtitle_style}>{$subtitle}</p>";
}
$html .= '</div>';
}
$html .= '<div class="wn-shoppable-image__media">';
if ($image) {
$html .= "<img class=\"wn-shoppable-image__image\" src=\"{$image}\" alt=\"{$alt}\" loading=\"lazy\" />";
}
if (!empty($hotspots)) {
$html .= '<ul class="wn-shoppable-image__hotspots">';
foreach ($hotspots as $hotspot) {
if (!is_array($hotspot)) {
continue;
}
$name = esc_html($hotspot['product_name'] ?? 'Product');
$slug = esc_attr($hotspot['product_slug'] ?? '');
$url = $slug ? esc_url(home_url('/product/' . $slug . '/')) : '';
$price = esc_html($hotspot['product_price'] ?? '');
$x = isset($hotspot['x']) ? esc_attr($hotspot['x']) : '50';
$y = isset($hotspot['y']) ? esc_attr($hotspot['y']) : '50';
$html .= "<li class=\"wn-shoppable-image__hotspot\" style=\"left:{$x}%;top:{$y}%\">";
$html .= $url ? "<a href=\"{$url}\">{$name}</a>" : $name;
if ($price) {
$html .= "<span class=\"wn-shoppable-image__price\">{$price}</span>";
}
$html .= '</li>';
}
$html .= '</ul>';
}
$html .= '</div></div></section>';
return $html;
}
/**
* Render Marquee Banner section
*/
public static function render_marquee_banner($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
{
$text = (string) ($props['text'] ?? '');
$separator = (string) ($props['separator'] ?? '*');
$items = array_filter(array_map('trim', explode($separator, $text)));
if (empty($items) && $text) {
$items = [$text];
}
$html = "<section id=\"{$id}\" class=\"wn-section wn-marquee wn-scheme--{$color_scheme}\">";
$html .= '<div class="wn-marquee__track">';
foreach ($items as $item) {
$label = esc_html($item);
if ($label) {
$html .= "<span class=\"wn-marquee__item\">{$label}</span>";
}
}
$html .= '</div></section>';
return $html;
}
/**
* Helper to get SVG for known icons
*/