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:
@@ -6,6 +6,7 @@ use WP_REST_Request;
|
||||
use WP_REST_Response;
|
||||
use WP_Error;
|
||||
use WooNooW\Frontend\PlaceholderRenderer;
|
||||
use WooNooW\Frontend\SchemaMigration;
|
||||
|
||||
use WooNooW\Frontend\PageSSR;
|
||||
use WooNooW\Templates\TemplateRegistry;
|
||||
@@ -107,6 +108,20 @@ class PagesController
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Resolve template sections for editor canvas preview.
|
||||
register_rest_route($namespace, '/preview/resolve/(?P<cpt>[a-zA-Z0-9_-]+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'resolve_template_preview'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// List sample posts for template preview context selector.
|
||||
register_rest_route($namespace, '/preview/samples/(?P<cpt>[a-zA-Z0-9_-]+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_template_preview_samples'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Set page as SPA Landing (shown at SPA root route)
|
||||
register_rest_route($namespace, '/pages/(?P<id>\d+)/set-as-spa-landing', [
|
||||
'methods' => 'POST',
|
||||
@@ -153,6 +168,7 @@ class PagesController
|
||||
$pages = get_posts([
|
||||
'post_type' => 'page',
|
||||
'posts_per_page' => -1,
|
||||
'post_status' => 'any',
|
||||
'meta_query' => [
|
||||
[
|
||||
'key' => '_wn_page_structure',
|
||||
@@ -206,6 +222,12 @@ class PagesController
|
||||
|
||||
$structure = get_post_meta($page->ID, '_wn_page_structure', true);
|
||||
|
||||
// Migrate structure if needed
|
||||
if ($structure && SchemaMigration::needs_migration($structure)) {
|
||||
$structure = SchemaMigration::migrate($structure);
|
||||
update_post_meta($page->ID, '_wn_page_structure', $structure);
|
||||
}
|
||||
|
||||
// Get SPA settings
|
||||
$settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_frontpage_id = $settings['general']['spa_frontpage'] ?? 0;
|
||||
@@ -217,16 +239,13 @@ class PagesController
|
||||
$container_width = get_post_meta($page->ID, '_wn_page_container_width', true);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'id' => $page->ID,
|
||||
'type' => 'page',
|
||||
'slug' => $page->post_name,
|
||||
'id' => $page->ID,
|
||||
'type' => 'page',
|
||||
'slug' => $page->post_name,
|
||||
'title' => $page->post_title,
|
||||
'seo' => $seo,
|
||||
'is_spa_frontpage' => (int)$page->ID === (int)$spa_frontpage_id,
|
||||
'container_width' => $container_width ?: 'default', // local setting
|
||||
'container_width' => $container_width ?: 'default',
|
||||
'effective_container_width' => ($container_width && $container_width !== 'default')
|
||||
? $container_width
|
||||
: (($settings['general']['container_width'] ?? 'boxed') ?: 'boxed'),
|
||||
@@ -254,10 +273,14 @@ class PagesController
|
||||
return new WP_Error('invalid_data', 'Missing sections data', ['status' => 400]);
|
||||
}
|
||||
|
||||
// Save structure
|
||||
// Migrate structure before saving to ensure consistency
|
||||
$migrated_structure = SchemaMigration::migrate(['sections' => $structure, 'type' => 'page']);
|
||||
|
||||
// Save structure with schema version
|
||||
$save_data = [
|
||||
'type' => 'page',
|
||||
'sections' => $structure,
|
||||
'sections' => $migrated_structure['sections'],
|
||||
'schemaVersion' => SchemaMigration::get_current_version(),
|
||||
'updated_at' => current_time('mysql'),
|
||||
];
|
||||
|
||||
@@ -293,6 +316,13 @@ class PagesController
|
||||
}
|
||||
|
||||
$template = get_option("wn_template_{$cpt}", null);
|
||||
|
||||
// Migrate template if needed
|
||||
if ($template && SchemaMigration::needs_migration($template)) {
|
||||
$template = SchemaMigration::migrate($template);
|
||||
update_option("wn_template_{$cpt}", $template);
|
||||
}
|
||||
|
||||
$cpt_obj = get_post_type_object($cpt);
|
||||
|
||||
return new WP_REST_Response([
|
||||
@@ -324,11 +354,15 @@ class PagesController
|
||||
return new WP_Error('invalid_data', 'Missing sections data', ['status' => 400]);
|
||||
}
|
||||
|
||||
// Save template
|
||||
// Migrate structure before saving
|
||||
$migrated_structure = SchemaMigration::migrate(['sections' => $structure, 'type' => 'template']);
|
||||
|
||||
// Save template with schema version
|
||||
$save_data = [
|
||||
'type' => 'template',
|
||||
'cpt' => $cpt,
|
||||
'sections' => $structure,
|
||||
'sections' => $migrated_structure['sections'],
|
||||
'schemaVersion' => SchemaMigration::get_current_version(),
|
||||
'updated_at' => current_time('mysql'),
|
||||
];
|
||||
|
||||
@@ -409,41 +443,7 @@ class PagesController
|
||||
// If template exists, resolve placeholders
|
||||
$rendered_sections = [];
|
||||
if ($template && !empty($template['sections'])) {
|
||||
foreach ($template['sections'] as $section) {
|
||||
$resolved_section = $section;
|
||||
|
||||
// Pre-resolve special dynamic sources that produce arrays before PageSSR::resolve_props
|
||||
$props = $section['props'] ?? [];
|
||||
foreach ($props as $key => $prop) {
|
||||
if (is_array($prop) && ($prop['type'] ?? '') === 'dynamic' && ($prop['source'] ?? '') === 'related_posts') {
|
||||
$props[$key] = [
|
||||
'type' => 'static',
|
||||
'value' => PlaceholderRenderer::get_related_posts($post->ID, 3, $type),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$resolved_section['props'] = PageSSR::resolve_props($props, $post_data);
|
||||
|
||||
// Resolve dynamicBackground in styles
|
||||
// If styles.dynamicBackground === 'post_featured_image', set styles.backgroundImage from post data
|
||||
$styles = $resolved_section['styles'] ?? [];
|
||||
if (!empty($styles['dynamicBackground']) && (empty($styles['backgroundType']) || $styles['backgroundType'] === 'image')) {
|
||||
$dyn_source = $styles['dynamicBackground'];
|
||||
if ($dyn_source === 'post_featured_image' || $dyn_source === 'featured_image') {
|
||||
$featured_url = $post_data['featured_image'] ?? '';
|
||||
if (!empty($featured_url)) {
|
||||
$styles['backgroundImage'] = $featured_url;
|
||||
$styles['backgroundType'] = 'image';
|
||||
}
|
||||
}
|
||||
// Remove the internal marker from the rendered output
|
||||
unset($styles['dynamicBackground']);
|
||||
$resolved_section['styles'] = $styles;
|
||||
}
|
||||
|
||||
$rendered_sections[] = $resolved_section;
|
||||
}
|
||||
$rendered_sections = self::resolve_sections_for_post($template['sections'], $post, $type);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
@@ -458,6 +458,64 @@ class PagesController
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve template preview sections for editor canvas rendering.
|
||||
*/
|
||||
public static function resolve_template_preview(WP_REST_Request $request)
|
||||
{
|
||||
$cpt = $request->get_param('cpt');
|
||||
$body = $request->get_json_params();
|
||||
$sections = $body['sections'] ?? [];
|
||||
$sample_post = self::get_preview_sample_post($cpt, $body['sample_post_id'] ?? null);
|
||||
|
||||
if (!$sample_post) {
|
||||
return new WP_REST_Response([
|
||||
'sections' => $sections,
|
||||
'sample_post' => null,
|
||||
'resolved' => false,
|
||||
], 200);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'sections' => self::resolve_sections_for_post($sections, $sample_post, $cpt),
|
||||
'sample_post' => [
|
||||
'id' => $sample_post->ID,
|
||||
'title' => $sample_post->post_title,
|
||||
'type' => $sample_post->post_type,
|
||||
],
|
||||
'resolved' => true,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sample posts for editor template preview context.
|
||||
*/
|
||||
public static function get_template_preview_samples(WP_REST_Request $request)
|
||||
{
|
||||
$cpt = $request->get_param('cpt');
|
||||
if (!$cpt || $cpt === 'page') {
|
||||
return new WP_REST_Response(['items' => []], 200);
|
||||
}
|
||||
|
||||
$posts = get_posts([
|
||||
'post_type' => $cpt,
|
||||
'posts_per_page' => 20,
|
||||
'post_status' => 'publish',
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
]);
|
||||
|
||||
$items = array_map(function ($post) {
|
||||
return [
|
||||
'id' => $post->ID,
|
||||
'title' => $post->post_title,
|
||||
'type' => $post->post_type,
|
||||
];
|
||||
}, $posts);
|
||||
|
||||
return new WP_REST_Response(['items' => $items], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set page as SPA Landing (the page shown at SPA root route)
|
||||
* This does NOT affect WordPress page_on_front setting.
|
||||
@@ -747,28 +805,12 @@ class PagesController
|
||||
$sections = $body['sections'] ?? [];
|
||||
|
||||
// Get sample post for dynamic placeholders
|
||||
$sample_post = null;
|
||||
if ($cpt && $cpt !== 'page') {
|
||||
$posts = get_posts([
|
||||
'post_type' => $cpt,
|
||||
'posts_per_page' => 1,
|
||||
'post_status' => 'publish',
|
||||
]);
|
||||
if (!empty($posts)) {
|
||||
$sample_post = $posts[0];
|
||||
}
|
||||
}
|
||||
$sample_post = self::get_preview_sample_post($cpt, $body['sample_post_id'] ?? null);
|
||||
|
||||
// Resolve placeholders if sample post exists
|
||||
$resolved_sections = $sections;
|
||||
if ($sample_post) {
|
||||
$post_data = PlaceholderRenderer::build_post_data($sample_post);
|
||||
$resolved_sections = [];
|
||||
foreach ($sections as $section) {
|
||||
$resolved_section = $section;
|
||||
$resolved_section['props'] = PageSSR::resolve_props($section['props'] ?? [], $post_data);
|
||||
$resolved_sections[] = $resolved_section;
|
||||
}
|
||||
$resolved_sections = self::resolve_sections_for_post($sections, $sample_post, $cpt);
|
||||
}
|
||||
|
||||
$cpt_obj = get_post_type_object($cpt);
|
||||
@@ -786,6 +828,66 @@ class PagesController
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve section props/styles against a concrete post context.
|
||||
*/
|
||||
private static function resolve_sections_for_post($sections, $post, $type)
|
||||
{
|
||||
$post_data = PlaceholderRenderer::build_post_data($post);
|
||||
$resolved_sections = [];
|
||||
|
||||
foreach ($sections as $section) {
|
||||
$resolved_section = $section;
|
||||
$props = $section['props'] ?? [];
|
||||
|
||||
// Resolve all props using PlaceholderRenderer (handles related_posts via get_value)
|
||||
$resolved_section['props'] = PageSSR::resolve_props($props, $post_data);
|
||||
|
||||
$styles = $resolved_section['styles'] ?? [];
|
||||
if (!empty($styles['dynamicBackground']) && (empty($styles['backgroundType']) || $styles['backgroundType'] === 'image')) {
|
||||
$dyn_source = $styles['dynamicBackground'];
|
||||
if ($dyn_source === 'post_featured_image' || $dyn_source === 'featured_image') {
|
||||
$featured_url = $post_data['featured_image'] ?? '';
|
||||
if (!empty($featured_url)) {
|
||||
$styles['backgroundImage'] = $featured_url;
|
||||
$styles['backgroundType'] = 'image';
|
||||
}
|
||||
}
|
||||
unset($styles['dynamicBackground']);
|
||||
$resolved_section['styles'] = $styles;
|
||||
}
|
||||
|
||||
$resolved_sections[] = $resolved_section;
|
||||
}
|
||||
|
||||
return $resolved_sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a preview sample post by explicit id, or fall back to the newest published item.
|
||||
*/
|
||||
private static function get_preview_sample_post($cpt, $sample_post_id = null)
|
||||
{
|
||||
if (!$cpt || $cpt === 'page') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($sample_post_id) {
|
||||
$post = get_post((int) $sample_post_id);
|
||||
if ($post && $post->post_type === $cpt) {
|
||||
return $post;
|
||||
}
|
||||
}
|
||||
|
||||
$posts = get_posts([
|
||||
'post_type' => $cpt,
|
||||
'posts_per_page' => 1,
|
||||
'post_status' => 'publish',
|
||||
]);
|
||||
|
||||
return !empty($posts) ? $posts[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Render preview HTML document
|
||||
*/
|
||||
|
||||
@@ -527,9 +527,11 @@ class ProductsController
|
||||
|
||||
// Virtual and downloadable
|
||||
if (array_key_exists('virtual', $data)) {
|
||||
error_log("Setting virtual to: " . ($data['virtual'] ? 'true' : 'false') . " for product ID: " . $product->get_id());
|
||||
$product->set_virtual((bool) $data['virtual']);
|
||||
}
|
||||
if (array_key_exists('downloadable', $data)) {
|
||||
error_log("Setting downloadable to: " . ($data['downloadable'] ? 'true' : 'false') . " for product ID: " . $product->get_id());
|
||||
$product->set_downloadable((bool) $data['downloadable']);
|
||||
}
|
||||
if (array_key_exists('featured', $data)) {
|
||||
@@ -887,6 +889,62 @@ class ProductsController
|
||||
}
|
||||
$data['gallery'] = $gallery;
|
||||
|
||||
// Video / rich media - stored in product meta
|
||||
$video_url = get_post_meta($product->get_id(), '_woonoow_video_url', true) ?: '';
|
||||
$data['video_url'] = $video_url;
|
||||
|
||||
// Detect embed type (youtube / vimeo / direct mp4)
|
||||
$data['video_type'] = '';
|
||||
if ($video_url) {
|
||||
if (strpos($video_url, 'youtube.com') !== false || strpos($video_url, 'youtu.be') !== false) {
|
||||
$data['video_type'] = 'youtube';
|
||||
} elseif (strpos($video_url, 'vimeo.com') !== false) {
|
||||
$data['video_type'] = 'vimeo';
|
||||
} else {
|
||||
$data['video_type'] = 'mp4';
|
||||
}
|
||||
}
|
||||
|
||||
// Media attachments - allow plugins/themes to inject additional media (e.g. 3D models, extra videos)
|
||||
$media_attachments = apply_filters('woonoow_product_media_attachments', [], $product->get_id(), $product);
|
||||
$data['media_attachments'] = is_array($media_attachments) ? $media_attachments : [];
|
||||
|
||||
// Cross-sells (shown in mini-cart / cart page)
|
||||
$cross_sell_ids = $product->get_cross_sell_ids();
|
||||
$cross_sells = [];
|
||||
foreach (array_slice($cross_sell_ids, 0, 4) as $cs_id) {
|
||||
$cs = wc_get_product($cs_id);
|
||||
if (!$cs || !$cs->is_visible()) continue;
|
||||
$cs_image_id = $cs->get_image_id();
|
||||
$cross_sells[] = [
|
||||
'id' => $cs->get_id(),
|
||||
'name' => $cs->get_name(),
|
||||
'slug' => $cs->get_slug(),
|
||||
'price' => $cs->get_price(),
|
||||
'regular_price' => $cs->get_regular_price(),
|
||||
'image' => $cs_image_id ? wp_get_attachment_url($cs_image_id) : '',
|
||||
];
|
||||
}
|
||||
$data['cross_sells'] = $cross_sells;
|
||||
|
||||
// Upsells (shown on product page below main content)
|
||||
$upsell_ids = $product->get_upsell_ids();
|
||||
$upsells = [];
|
||||
foreach (array_slice($upsell_ids, 0, 4) as $up_id) {
|
||||
$up = wc_get_product($up_id);
|
||||
if (!$up || !$up->is_visible()) continue;
|
||||
$up_image_id = $up->get_image_id();
|
||||
$upsells[] = [
|
||||
'id' => $up->get_id(),
|
||||
'name' => $up->get_name(),
|
||||
'slug' => $up->get_slug(),
|
||||
'price' => $up->get_price(),
|
||||
'regular_price' => $up->get_regular_price(),
|
||||
'image' => $up_image_id ? wp_get_attachment_url($up_image_id) : '',
|
||||
];
|
||||
}
|
||||
$data['upsells'] = $upsells;
|
||||
|
||||
// Variable product specifics
|
||||
if ($product->is_type('variable')) {
|
||||
$data['attributes'] = self::get_product_attributes($product);
|
||||
@@ -993,6 +1051,8 @@ class ProductsController
|
||||
'stock_status' => $variation->get_stock_status(),
|
||||
'stock_quantity' => $variation->get_stock_quantity(),
|
||||
'manage_stock' => $variation->get_manage_stock(),
|
||||
'virtual' => $variation->is_virtual(),
|
||||
'downloadable' => $variation->is_downloadable(),
|
||||
'attributes' => $formatted_attributes,
|
||||
'image_id' => $variation->get_image_id(),
|
||||
'image_url' => $image_url,
|
||||
@@ -1118,9 +1178,11 @@ class ProductsController
|
||||
$variation->set_image_id($var_data['image_id']);
|
||||
}
|
||||
|
||||
// Inherit virtual status from parent if parent is virtual
|
||||
if ($product->is_virtual()) {
|
||||
$variation->set_virtual(true);
|
||||
if (array_key_exists('virtual', $var_data)) {
|
||||
$variation->set_virtual((bool) $var_data['virtual']);
|
||||
}
|
||||
if (array_key_exists('downloadable', $var_data)) {
|
||||
$variation->set_downloadable((bool) $var_data['downloadable']);
|
||||
}
|
||||
|
||||
// Save variation first
|
||||
|
||||
174
includes/Features.php
Normal file
174
includes/Features.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
namespace WooNooW;
|
||||
|
||||
/**
|
||||
* Feature Flags
|
||||
* Manages feature toggles for staged rollout of new features
|
||||
*/
|
||||
class Features
|
||||
{
|
||||
/**
|
||||
* Feature flag defaults
|
||||
*/
|
||||
private static $defaults = [
|
||||
'dynamic_preview' => false, // Enable template preview with real data
|
||||
'schema_v1' => true, // Use v1 schema with migrations
|
||||
'enhanced_ssr' => true, // Enhanced SSR with full style support
|
||||
'placeholder_cache' => true, // Cache resolved placeholders
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a feature flag value
|
||||
*
|
||||
* @param string $feature Feature name
|
||||
* @param bool $default Default value if not set
|
||||
* @return bool
|
||||
*/
|
||||
public static function get($feature, $default = null)
|
||||
{
|
||||
$defaults = self::$defaults;
|
||||
$default = $default ?? ($defaults[$feature] ?? false);
|
||||
|
||||
$settings = get_option('woonoow_feature_settings', []);
|
||||
return isset($settings[$feature]) ? (bool) $settings[$feature] : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a feature flag value
|
||||
*
|
||||
* @param string $feature Feature name
|
||||
* @param bool $value Value to set
|
||||
*/
|
||||
public static function set($feature, $value)
|
||||
{
|
||||
$settings = get_option('woonoow_feature_settings', []);
|
||||
$settings[$feature] = (bool) $value;
|
||||
update_option('woonoow_feature_settings', $settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if dynamic preview is enabled
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_dynamic_preview_enabled()
|
||||
{
|
||||
return self::get('dynamic_preview');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if schema v1 migration is enabled
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_schema_v1_enabled()
|
||||
{
|
||||
return self::get('schema_v1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if enhanced SSR is enabled
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enhanced_ssr_enabled()
|
||||
{
|
||||
return self::get('enhanced_ssr');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if placeholder caching is enabled
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_placeholder_cache_enabled()
|
||||
{
|
||||
return self::get('placeholder_cache');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all feature flag values
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_all()
|
||||
{
|
||||
$settings = get_option('woonoow_feature_settings', []);
|
||||
$result = [];
|
||||
|
||||
foreach (self::$defaults as $feature => $default) {
|
||||
$result[$feature] = isset($settings[$feature])
|
||||
? (bool) $settings[$feature]
|
||||
: $default;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all feature flags to defaults
|
||||
*/
|
||||
public static function reset_all()
|
||||
{
|
||||
delete_option('woonoow_feature_settings');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook helper to conditionally enable features based on user role
|
||||
*/
|
||||
class FeatureAccess
|
||||
{
|
||||
/**
|
||||
* Check if current user can access a feature
|
||||
*
|
||||
* @param string $feature Feature name
|
||||
* @return bool
|
||||
*/
|
||||
public static function can_access($feature)
|
||||
{
|
||||
// Check if feature is globally enabled
|
||||
if (!Features::get($feature)) {
|
||||
// Check for admin override
|
||||
$override_key = "woonoow_{$feature}_override";
|
||||
if (get_transient($override_key) === 'enabled') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a feature for a specific user
|
||||
*
|
||||
* @param string $feature Feature name
|
||||
* @param int $user_id User ID
|
||||
*/
|
||||
public static function enable_for_user($feature, $user_id = null)
|
||||
{
|
||||
if (!$user_id) {
|
||||
$user_id = get_current_user_id();
|
||||
}
|
||||
|
||||
$transient_key = "woonoow_user_{$user_id}_{$feature}";
|
||||
set_transient($transient_key, 'enabled', DAY_IN_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a feature for a specific user
|
||||
*
|
||||
* @param string $feature Feature name
|
||||
* @param int $user_id User ID
|
||||
*/
|
||||
public static function disable_for_user($feature, $user_id = null)
|
||||
{
|
||||
if (!$user_id) {
|
||||
$user_id = get_current_user_id();
|
||||
}
|
||||
|
||||
$transient_key = "woonoow_user_{$user_id}_{$feature}";
|
||||
delete_transient($transient_key);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -7,83 +7,296 @@ namespace WooNooW\Frontend;
|
||||
*/
|
||||
class PlaceholderRenderer
|
||||
{
|
||||
/**
|
||||
* Cache timeout in seconds (5 minutes)
|
||||
*/
|
||||
const CACHE_TIMEOUT = 300;
|
||||
|
||||
/**
|
||||
* Dynamic source type definitions
|
||||
* Used for typed output contracts and validation
|
||||
*/
|
||||
const SOURCE_TYPES = [
|
||||
'post_title' => 'scalar',
|
||||
'title' => 'scalar',
|
||||
'post_content' => 'html',
|
||||
'content' => 'html',
|
||||
'post_excerpt' => 'scalar',
|
||||
'excerpt' => 'scalar',
|
||||
'post_featured_image' => 'url',
|
||||
'featured_image' => 'url',
|
||||
'post_author' => 'scalar',
|
||||
'author' => 'scalar',
|
||||
'post_date' => 'scalar',
|
||||
'date' => 'scalar',
|
||||
'post_categories' => 'array',
|
||||
'categories' => 'array',
|
||||
'post_tags' => 'array',
|
||||
'tags' => 'array',
|
||||
'post_url' => 'url',
|
||||
'url' => 'url',
|
||||
'permalink' => 'url',
|
||||
'related_posts' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fallback values for when a source resolves to empty
|
||||
*/
|
||||
const FALLBACK_VALUES = [
|
||||
'post_title' => '(Untitled)',
|
||||
'title' => '(Untitled)',
|
||||
'post_featured_image' => '',
|
||||
'featured_image' => '',
|
||||
'post_excerpt' => '',
|
||||
'excerpt' => '',
|
||||
'related_posts' => [],
|
||||
];
|
||||
|
||||
/**
|
||||
* Get expected type for a source
|
||||
*
|
||||
* @param string $source Placeholder source
|
||||
* @return string Type: scalar, html, url, array
|
||||
*/
|
||||
public static function get_source_type($source)
|
||||
{
|
||||
if (isset(self::SOURCE_TYPES[$source])) {
|
||||
return self::SOURCE_TYPES[$source];
|
||||
}
|
||||
|
||||
// Custom field types
|
||||
if (strpos($source, '_field_') !== false) {
|
||||
return 'scalar';
|
||||
}
|
||||
|
||||
// Direct key - guess based on naming
|
||||
if (strpos($source, 'image') !== false || strpos($source, 'thumbnail') !== false) {
|
||||
return 'url';
|
||||
}
|
||||
|
||||
if (strpos($source, 'ids') !== false || strpos($source, 'tags') !== false) {
|
||||
return 'array';
|
||||
}
|
||||
|
||||
return 'scalar';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate resolved value matches expected type
|
||||
*
|
||||
* @param mixed $value Resolved value
|
||||
* @param string $expected_type Expected type
|
||||
* @return bool True if valid
|
||||
*/
|
||||
public static function validate_value_type($value, $expected_type)
|
||||
{
|
||||
switch ($expected_type) {
|
||||
case 'scalar':
|
||||
case 'html':
|
||||
return is_string($value) || is_numeric($value);
|
||||
|
||||
case 'url':
|
||||
return is_string($value) && !empty($value);
|
||||
|
||||
case 'array':
|
||||
return is_array($value);
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback value for a source
|
||||
*
|
||||
* @param string $source Placeholder source
|
||||
* @param string $expected_type Expected type
|
||||
* @return mixed Fallback value
|
||||
*/
|
||||
public static function get_fallback($source, $expected_type = null)
|
||||
{
|
||||
if ($expected_type === 'array') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isset(self::FALLBACK_VALUES[$source])) {
|
||||
return self::FALLBACK_VALUES[$source];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a source is array-based (needs special handling)
|
||||
*
|
||||
* @param string $source Placeholder source
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_array_source($source)
|
||||
{
|
||||
return self::get_source_type($source) === 'array';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached post data
|
||||
*
|
||||
* @param int $post_id Post ID
|
||||
* @return array|null Cached post data or null if not cached
|
||||
*/
|
||||
public static function get_cached_post_data($post_id)
|
||||
{
|
||||
$cache_key = "wn_post_data_{$post_id}";
|
||||
return get_transient($cache_key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache post data
|
||||
*
|
||||
* @param int $post_id Post ID
|
||||
* @param array $data Post data
|
||||
*/
|
||||
public static function cache_post_data($post_id, $data)
|
||||
{
|
||||
$cache_key = "wn_post_data_{$post_id}";
|
||||
set_transient($cache_key, $data, self::CACHE_TIMEOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate post data cache
|
||||
*
|
||||
* @param int $post_id Post ID
|
||||
*/
|
||||
public static function invalidate_post_data_cache($post_id)
|
||||
{
|
||||
$cache_key = "wn_post_data_{$post_id}";
|
||||
delete_transient($cache_key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value for a dynamic placeholder source
|
||||
*
|
||||
*
|
||||
* @param string $source Placeholder source (e.g., 'post_title', 'post_content')
|
||||
* @param array $post_data Post data array
|
||||
* @param array $options Resolution options
|
||||
* @return mixed Resolved value
|
||||
*/
|
||||
public static function get_value($source, $post_data)
|
||||
public static function get_value($source, $post_data, $options = [])
|
||||
{
|
||||
$options = wp_parse_args($options, [
|
||||
'use_fallback' => true,
|
||||
'validate_type' => true,
|
||||
]);
|
||||
|
||||
if (empty($source) || empty($post_data)) {
|
||||
return '';
|
||||
return $options['use_fallback'] ? self::get_fallback($source) : '';
|
||||
}
|
||||
|
||||
|
||||
$expected_type = $options['validate_type'] ? self::get_source_type($source) : 'scalar';
|
||||
$value = '';
|
||||
|
||||
// Standard post fields
|
||||
switch ($source) {
|
||||
case 'post_title':
|
||||
case 'title':
|
||||
return $post_data['title'] ?? $post_data['post_title'] ?? '';
|
||||
|
||||
$value = $post_data['title'] ?? $post_data['post_title'] ?? '';
|
||||
break;
|
||||
|
||||
case 'post_content':
|
||||
case 'content':
|
||||
return $post_data['content'] ?? $post_data['post_content'] ?? '';
|
||||
|
||||
$value = $post_data['content'] ?? $post_data['post_content'] ?? '';
|
||||
break;
|
||||
|
||||
case 'post_excerpt':
|
||||
case 'excerpt':
|
||||
return $post_data['excerpt'] ?? $post_data['post_excerpt'] ?? '';
|
||||
|
||||
$value = $post_data['excerpt'] ?? $post_data['post_excerpt'] ?? '';
|
||||
break;
|
||||
|
||||
case 'post_featured_image':
|
||||
case 'featured_image':
|
||||
return $post_data['featured_image'] ??
|
||||
$post_data['thumbnail'] ??
|
||||
$value = $post_data['featured_image'] ??
|
||||
$post_data['thumbnail'] ??
|
||||
$post_data['_thumbnail_url'] ?? '';
|
||||
|
||||
break;
|
||||
|
||||
case 'post_author':
|
||||
case 'author':
|
||||
return $post_data['author'] ?? $post_data['post_author'] ?? '';
|
||||
|
||||
$value = $post_data['author'] ?? $post_data['post_author'] ?? '';
|
||||
break;
|
||||
|
||||
case 'post_date':
|
||||
case 'date':
|
||||
return $post_data['date'] ?? $post_data['post_date'] ?? '';
|
||||
|
||||
$value = $post_data['date'] ?? $post_data['post_date'] ?? '';
|
||||
break;
|
||||
|
||||
case 'post_categories':
|
||||
case 'categories':
|
||||
return $post_data['categories'] ?? [];
|
||||
|
||||
$value = $post_data['categories'] ?? [];
|
||||
break;
|
||||
|
||||
case 'post_tags':
|
||||
case 'tags':
|
||||
return $post_data['tags'] ?? [];
|
||||
|
||||
$value = $post_data['tags'] ?? [];
|
||||
break;
|
||||
|
||||
case 'post_url':
|
||||
case 'url':
|
||||
case 'permalink':
|
||||
return $post_data['url'] ?? $post_data['permalink'] ?? '';
|
||||
$value = $post_data['url'] ?? $post_data['permalink'] ?? '';
|
||||
break;
|
||||
|
||||
default:
|
||||
// Check for custom meta fields (format: {cpt}_field_{name})
|
||||
if (strpos($source, '_field_') !== false) {
|
||||
$parts = explode('_field_', $source);
|
||||
$field_name = end($parts);
|
||||
|
||||
// Try to get from meta array
|
||||
if (isset($post_data['meta'][$field_name])) {
|
||||
$value = $post_data['meta'][$field_name];
|
||||
} elseif (isset($post_data[$field_name])) {
|
||||
// Try direct field access
|
||||
$value = $post_data[$field_name];
|
||||
}
|
||||
} elseif (strpos($source, 'related_posts') !== false) {
|
||||
// Handle related_posts through the dedicated method
|
||||
$value = self::get_related_posts_for_post_data($post_data);
|
||||
} elseif (isset($post_data[$source])) {
|
||||
// Check for direct key match
|
||||
$value = $post_data[$source];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for custom meta fields (format: {cpt}_field_{name})
|
||||
if (strpos($source, '_field_') !== false) {
|
||||
$parts = explode('_field_', $source);
|
||||
$field_name = end($parts);
|
||||
|
||||
// Try to get from meta array
|
||||
if (isset($post_data['meta'][$field_name])) {
|
||||
return $post_data['meta'][$field_name];
|
||||
}
|
||||
|
||||
// Try direct field access
|
||||
if (isset($post_data[$field_name])) {
|
||||
return $post_data[$field_name];
|
||||
}
|
||||
|
||||
// Validate type if enabled
|
||||
if ($options['validate_type'] && !self::validate_value_type($value, $expected_type)) {
|
||||
$value = $options['use_fallback'] ? self::get_fallback($source, $expected_type) : '';
|
||||
}
|
||||
|
||||
// Check for direct key match
|
||||
if (isset($post_data[$source])) {
|
||||
return $post_data[$source];
|
||||
|
||||
// Apply fallback for empty values if enabled
|
||||
if ($options['use_fallback'] && $value === '') {
|
||||
$value = self::get_fallback($source, $expected_type);
|
||||
}
|
||||
|
||||
return '';
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related posts from post_data array
|
||||
*
|
||||
* @param array $post_data Post data array (must contain 'id' and 'type')
|
||||
* @param int $count Number of related posts
|
||||
* @return array Related posts data
|
||||
*/
|
||||
public static function get_related_posts_for_post_data($post_data, $count = 3)
|
||||
{
|
||||
$post_id = $post_data['id'] ?? 0;
|
||||
$post_type = $post_data['type'] ?? 'post';
|
||||
|
||||
if (!$post_id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::get_related_posts($post_id, $count, $post_type);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
253
includes/Frontend/SchemaMigration.php
Normal file
253
includes/Frontend/SchemaMigration.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
namespace WooNooW\Frontend;
|
||||
|
||||
/**
|
||||
* Schema Migration Handler
|
||||
* Handles backward compatibility for legacy saved structures
|
||||
*/
|
||||
class SchemaMigration
|
||||
{
|
||||
/**
|
||||
* Current schema version
|
||||
*/
|
||||
const CURRENT_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Migration functions per version
|
||||
*/
|
||||
private static $migrations = [
|
||||
1 => 'migrate_to_v1',
|
||||
];
|
||||
|
||||
/**
|
||||
* Migrate a page/template structure to current schema
|
||||
*
|
||||
* @param array $structure Page or template structure
|
||||
* @return array Migrated structure
|
||||
*/
|
||||
public static function migrate($structure)
|
||||
{
|
||||
$version = $structure['schemaVersion'] ?? 0;
|
||||
|
||||
// Already at current version
|
||||
if ($version >= self::CURRENT_VERSION) {
|
||||
return $structure;
|
||||
}
|
||||
|
||||
// Apply migrations in order
|
||||
for ($v = $version + 1; $v <= self::CURRENT_VERSION; $v++) {
|
||||
if (isset(self::$migrations[$v])) {
|
||||
$method = self::$migrations[$v];
|
||||
$structure = self::$method($structure);
|
||||
}
|
||||
}
|
||||
|
||||
// Set current version
|
||||
$structure['schemaVersion'] = self::CURRENT_VERSION;
|
||||
|
||||
return $structure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate to version 1
|
||||
* - Normalize feature-grid items/features
|
||||
* - Normalize style keys
|
||||
* - Add missing defaults
|
||||
*
|
||||
* @param array $structure
|
||||
* @return array
|
||||
*/
|
||||
private static function migrate_to_v1($structure)
|
||||
{
|
||||
if (!isset($structure['sections']) || !is_array($structure['sections'])) {
|
||||
return $structure;
|
||||
}
|
||||
|
||||
foreach ($structure['sections'] as &$section) {
|
||||
// Migrate section type
|
||||
if (!isset($section['type'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Feature grid: normalize items/features
|
||||
if ($section['type'] === 'feature-grid') {
|
||||
$section = self::migrate_feature_grid($section);
|
||||
}
|
||||
|
||||
// Normalize section styles
|
||||
$section = self::migrate_section_styles($section);
|
||||
|
||||
// Normalize element styles
|
||||
$section = self::migrate_element_styles($section);
|
||||
}
|
||||
|
||||
return $structure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate feature-grid section
|
||||
* - Ensure items array exists
|
||||
* - Copy features to items if needed
|
||||
*
|
||||
* @param array $section
|
||||
* @return array
|
||||
*/
|
||||
private static function migrate_feature_grid($section)
|
||||
{
|
||||
$props = $section['props'] ?? [];
|
||||
|
||||
// Handle items/features normalization
|
||||
if (!isset($props['items']) && isset($props['features'])) {
|
||||
// features exists but items doesn't - copy features to items
|
||||
$props['items'] = $props['features'];
|
||||
|
||||
// If features was an empty string, convert to empty array
|
||||
if ($props['items'] === '') {
|
||||
$props['items'] = [];
|
||||
}
|
||||
|
||||
// Keep features for backward compat but prefer items
|
||||
// Don't remove it yet - some old code may still reference it
|
||||
}
|
||||
|
||||
// Ensure items is always an array
|
||||
if (!isset($props['items'])) {
|
||||
$props['items'] = [];
|
||||
} elseif (!is_array($props['items'])) {
|
||||
$props['items'] = [];
|
||||
}
|
||||
|
||||
$section['props'] = $props;
|
||||
|
||||
return $section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate section styles
|
||||
* - Normalize key names
|
||||
* - Add missing defaults
|
||||
*
|
||||
* @param array $section
|
||||
* @return array
|
||||
*/
|
||||
private static function migrate_section_styles($section)
|
||||
{
|
||||
$styles = $section['styles'] ?? [];
|
||||
|
||||
// Normalize contentWidth if missing
|
||||
if (!isset($styles['contentWidth']) && isset($styles['container_width'])) {
|
||||
// Old key name
|
||||
$styles['contentWidth'] = $styles['container_width'];
|
||||
unset($styles['container_width']);
|
||||
}
|
||||
|
||||
// Normalize background type
|
||||
if (isset($styles['backgroundImage']) && !isset($styles['backgroundType'])) {
|
||||
// If there's a background image but no type, assume image type
|
||||
$styles['backgroundType'] = 'image';
|
||||
}
|
||||
|
||||
// Normalize height preset
|
||||
if (isset($styles['height']) && !isset($styles['heightPreset'])) {
|
||||
$height = $styles['height'];
|
||||
$map = [
|
||||
'small' => 'small',
|
||||
'medium' => 'medium',
|
||||
'large' => 'large',
|
||||
'fullscreen' => 'fullscreen',
|
||||
'screen' => 'fullscreen', // Old naming
|
||||
'default' => 'default',
|
||||
];
|
||||
$styles['heightPreset'] = $map[$height] ?? 'default';
|
||||
unset($styles['height']);
|
||||
}
|
||||
|
||||
// Add default background settings if missing
|
||||
if (!isset($styles['backgroundType'])) {
|
||||
$styles['backgroundType'] = 'solid';
|
||||
}
|
||||
|
||||
$section['styles'] = $styles;
|
||||
|
||||
return $section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate element styles
|
||||
* - Normalize key names
|
||||
* - Add missing defaults
|
||||
*
|
||||
* @param array $section
|
||||
* @return array
|
||||
*/
|
||||
private static function migrate_element_styles($section)
|
||||
{
|
||||
$elementStyles = $section['elementStyles'] ?? [];
|
||||
|
||||
// Normalize common element style keys
|
||||
$normalizations = [
|
||||
'cta' => 'cta_text', // Old key
|
||||
'button' => 'cta_text', // Alias for button
|
||||
'heading' => 'heading', // Standardize
|
||||
'text' => 'text', // Standardize
|
||||
'subtitle' => 'subtitle', // Standardize
|
||||
];
|
||||
|
||||
$normalized = [];
|
||||
foreach ($elementStyles as $key => $style) {
|
||||
$normalized_key = $normalizations[$key] ?? $key;
|
||||
$normalized[$normalized_key] = $style;
|
||||
}
|
||||
|
||||
// Add default styling keys if missing
|
||||
$defaults = [
|
||||
'title' => [],
|
||||
'subtitle' => [],
|
||||
'text' => [],
|
||||
'cta_text' => [],
|
||||
'feature_item' => [],
|
||||
];
|
||||
|
||||
foreach ($defaults as $key => $default) {
|
||||
if (!isset($normalized[$key])) {
|
||||
$normalized[$key] = $default;
|
||||
}
|
||||
}
|
||||
|
||||
$section['elementStyles'] = $normalized;
|
||||
|
||||
return $section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a structure needs migration
|
||||
*
|
||||
* @param array $structure
|
||||
* @return bool
|
||||
*/
|
||||
public static function needs_migration($structure)
|
||||
{
|
||||
return ($structure['schemaVersion'] ?? 0) < self::CURRENT_VERSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch migrate multiple structures
|
||||
*
|
||||
* @param array $structures Array of structures
|
||||
* @return array Migrated structures
|
||||
*/
|
||||
public static function migrate_all($structures)
|
||||
{
|
||||
return array_map([self::class, 'migrate'], $structures);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current schema version
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function get_current_version()
|
||||
{
|
||||
return self::CURRENT_VERSION;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user