From 9331989102bea167fb8b5586f9a1536bad0dd02a Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Sun, 11 Jan 2026 22:29:30 +0700 Subject: [PATCH] feat: Page Editor Phase 1 - Core Infrastructure - Add is_bot() detection in TemplateOverride.php (30+ bot patterns) - Add PageSSR.php for server-side rendering of page sections - Add PlaceholderRenderer.php for dynamic content resolution - Add PagesController.php REST API for pages/templates CRUD - Register PagesController routes in Routes.php API Endpoints: - GET /pages - list all pages/templates - GET /pages/{slug} - get page structure - POST /pages/{slug} - save page - GET /templates/{cpt} - get CPT template - POST /templates/{cpt} - save template - GET /content/{type}/{slug} - get content with template applied --- includes/Api/PagesController.php | 473 +++++++++++++ includes/Api/Routes.php | 4 + includes/Frontend/PageSSR.php | 290 ++++++++ includes/Frontend/PlaceholderRenderer.php | 213 ++++++ includes/Frontend/TemplateOverride.php | 96 +++ woonoow-page-editor-brief.md | 781 ++++++++++++++++++++++ 6 files changed, 1857 insertions(+) create mode 100644 includes/Api/PagesController.php create mode 100644 includes/Frontend/PageSSR.php create mode 100644 includes/Frontend/PlaceholderRenderer.php create mode 100644 woonoow-page-editor-brief.md diff --git a/includes/Api/PagesController.php b/includes/Api/PagesController.php new file mode 100644 index 0000000..47f9fc9 --- /dev/null +++ b/includes/Api/PagesController.php @@ -0,0 +1,473 @@ + 'GET', + 'callback' => [__CLASS__, 'get_pages'], + 'permission_callback' => '__return_true', + ]); + + // Get/Save page structure (structural pages) + register_rest_route($namespace, '/pages/(?P[a-zA-Z0-9_-]+)', [ + [ + 'methods' => 'GET', + 'callback' => [__CLASS__, 'get_page'], + 'permission_callback' => '__return_true', + ], + [ + 'methods' => 'POST', + 'callback' => [__CLASS__, 'save_page'], + 'permission_callback' => [__CLASS__, 'check_admin_permission'], + ], + ]); + + // Get/Save CPT templates + register_rest_route($namespace, '/templates/(?P[a-zA-Z0-9_-]+)', [ + [ + 'methods' => 'GET', + 'callback' => [__CLASS__, 'get_template'], + 'permission_callback' => '__return_true', + ], + [ + 'methods' => 'POST', + 'callback' => [__CLASS__, 'save_template'], + 'permission_callback' => [__CLASS__, 'check_admin_permission'], + ], + ]); + + // Get post with template applied (for SPA rendering) + register_rest_route($namespace, '/content/(?P[a-zA-Z0-9_-]+)/(?P[a-zA-Z0-9_-]+)', [ + 'methods' => 'GET', + 'callback' => [__CLASS__, 'get_content_with_template'], + 'permission_callback' => '__return_true', + ]); + + // Create new page + register_rest_route($namespace, '/pages', [ + 'methods' => 'POST', + 'callback' => [__CLASS__, 'create_page'], + 'permission_callback' => [__CLASS__, 'check_admin_permission'], + ]); + } + + /** + * Check admin permission + */ + public static function check_admin_permission() + { + return current_user_can('manage_woocommerce'); + } + + /** + * Get all pages and templates + */ + public static function get_pages(WP_REST_Request $request) + { + $result = []; + + // Get structural pages (pages with WooNooW structure) + $pages = get_posts([ + 'post_type' => 'page', + 'posts_per_page' => -1, + 'meta_query' => [ + [ + 'key' => '_wn_page_structure', + 'compare' => 'EXISTS', + ], + ], + ]); + + foreach ($pages as $page) { + $result[] = [ + 'id' => $page->ID, + 'type' => 'page', + 'slug' => $page->post_name, + 'title' => $page->post_title, + 'url' => get_permalink($page), + 'icon' => 'page', + ]; + } + + // Get CPT templates + $cpts = self::get_editable_post_types(); + foreach ($cpts as $cpt => $label) { + $template = get_option("wn_template_{$cpt}", null); + + $result[] = [ + 'type' => 'template', + 'cpt' => $cpt, + 'title' => "{$label} Template", + 'icon' => 'template', + 'permalink_base' => self::get_cpt_permalink_base($cpt), + 'has_template' => !empty($template), + ]; + } + + return new WP_REST_Response($result, 200); + } + + /** + * Get page structure by slug + */ + public static function get_page(WP_REST_Request $request) + { + $slug = $request->get_param('slug'); + + // Find page by slug + $page = get_page_by_path($slug); + if (!$page) { + return new WP_Error('not_found', 'Page not found', ['status' => 404]); + } + + $structure = get_post_meta($page->ID, '_wn_page_structure', true); + + // Get SEO data (Yoast/Rank Math) + $seo = self::get_seo_data($page->ID); + + return new WP_REST_Response([ + 'id' => $page->ID, + 'type' => 'page', + 'slug' => $page->post_name, + 'title' => $page->post_title, + 'url' => get_permalink($page), + 'seo' => $seo, + 'structure' => $structure ?: ['sections' => []], + ], 200); + } + + /** + * Save page structure + */ + public static function save_page(WP_REST_Request $request) + { + $slug = $request->get_param('slug'); + $body = $request->get_json_params(); + + // Find page by slug + $page = get_page_by_path($slug); + if (!$page) { + return new WP_Error('not_found', 'Page not found', ['status' => 404]); + } + + // Validate structure + $structure = $body['sections'] ?? null; + if ($structure === null) { + return new WP_Error('invalid_data', 'Missing sections data', ['status' => 400]); + } + + // Save structure + $save_data = [ + 'type' => 'page', + 'sections' => $structure, + 'updated_at' => current_time('mysql'), + ]; + + update_post_meta($page->ID, '_wn_page_structure', $save_data); + + return new WP_REST_Response([ + 'success' => true, + 'page' => [ + 'id' => $page->ID, + 'slug' => $page->post_name, + ], + ], 200); + } + + /** + * Get CPT template + */ + public static function get_template(WP_REST_Request $request) + { + $cpt = $request->get_param('cpt'); + + // Validate CPT exists + if (!post_type_exists($cpt) && $cpt !== 'post') { + return new WP_Error('invalid_cpt', 'Invalid post type', ['status' => 400]); + } + + $template = get_option("wn_template_{$cpt}", null); + $cpt_obj = get_post_type_object($cpt); + + return new WP_REST_Response([ + 'type' => 'template', + 'cpt' => $cpt, + 'title' => $cpt_obj ? $cpt_obj->labels->singular_name . ' Template' : ucfirst($cpt) . ' Template', + 'permalink_base' => self::get_cpt_permalink_base($cpt), + 'available_sources' => self::get_available_sources($cpt), + 'structure' => $template ?: ['sections' => []], + ], 200); + } + + /** + * Save CPT template + */ + public static function save_template(WP_REST_Request $request) + { + $cpt = $request->get_param('cpt'); + $body = $request->get_json_params(); + + // Validate CPT exists + if (!post_type_exists($cpt) && $cpt !== 'post') { + return new WP_Error('invalid_cpt', 'Invalid post type', ['status' => 400]); + } + + // Validate structure + $structure = $body['sections'] ?? null; + if ($structure === null) { + return new WP_Error('invalid_data', 'Missing sections data', ['status' => 400]); + } + + // Save template + $save_data = [ + 'type' => 'template', + 'cpt' => $cpt, + 'sections' => $structure, + 'updated_at' => current_time('mysql'), + ]; + + update_option("wn_template_{$cpt}", $save_data); + + return new WP_REST_Response([ + 'success' => true, + 'template' => [ + 'cpt' => $cpt, + ], + ], 200); + } + + /** + * Get content with template applied (for SPA rendering) + */ + public static function get_content_with_template(WP_REST_Request $request) + { + $type = $request->get_param('type'); + $slug = $request->get_param('slug'); + + // Handle structural pages + if ($type === 'page') { + return self::get_page($request); + } + + // For CPT items, get post and apply template + $post = get_page_by_path($slug, OBJECT, $type); + if (!$post) { + // Try with post type 'post' if type is 'blog' + if ($type === 'blog') { + $post = get_page_by_path($slug, OBJECT, 'post'); + $type = 'post'; + } + } + + if (!$post) { + return new WP_Error('not_found', 'Content not found', ['status' => 404]); + } + + // Get template for this CPT + $template = get_option("wn_template_{$type}", null); + + // Build post data + $post_data = PlaceholderRenderer::build_post_data($post); + + // Get SEO data + $seo = self::get_seo_data($post->ID); + + // If template exists, resolve placeholders + $rendered_sections = []; + if ($template && !empty($template['sections'])) { + foreach ($template['sections'] as $section) { + $resolved_section = $section; + $resolved_section['props'] = PageSSR::resolve_props($section['props'] ?? [], $post_data); + $rendered_sections[] = $resolved_section; + } + } + + return new WP_REST_Response([ + 'type' => 'content', + 'cpt' => $type, + 'post' => $post_data, + 'seo' => $seo, + 'template' => $template ?: ['sections' => []], + 'rendered' => [ + 'sections' => $rendered_sections, + ], + ], 200); + } + + /** + * Create new page + */ + public static function create_page(WP_REST_Request $request) + { + $body = $request->get_json_params(); + + $title = sanitize_text_field($body['title'] ?? ''); + $slug = sanitize_title($body['slug'] ?? $title); + + if (empty($title)) { + return new WP_Error('invalid_data', 'Title is required', ['status' => 400]); + } + + // Check if page already exists + if (get_page_by_path($slug)) { + return new WP_Error('exists', 'Page with this slug already exists', ['status' => 400]); + } + + // Create page + $page_id = wp_insert_post([ + 'post_type' => 'page', + 'post_title' => $title, + 'post_name' => $slug, + 'post_status' => 'publish', + ]); + + if (is_wp_error($page_id)) { + return $page_id; + } + + // Initialize empty structure + $structure = [ + 'type' => 'page', + 'sections' => [], + 'created_at' => current_time('mysql'), + ]; + + update_post_meta($page_id, '_wn_page_structure', $structure); + + return new WP_REST_Response([ + 'success' => true, + 'page' => [ + 'id' => $page_id, + 'slug' => $slug, + 'title' => $title, + 'url' => get_permalink($page_id), + ], + ], 201); + } + + // ======================================== + // Helper Methods + // ======================================== + + /** + * Get editable post types for templates + */ + private static function get_editable_post_types() + { + $types = [ + 'post' => 'Blog Post', + ]; + + // Get public custom post types + $custom_types = get_post_types([ + 'public' => true, + '_builtin' => false, + ], 'objects'); + + foreach ($custom_types as $type) { + // Skip WooCommerce types (handled separately) + if (in_array($type->name, ['product', 'shop_order', 'shop_coupon'])) { + continue; + } + $types[$type->name] = $type->labels->singular_name; + } + + return $types; + } + + /** + * Get permalink base for a CPT + */ + private static function get_cpt_permalink_base($cpt) + { + if ($cpt === 'post') { + // Get blog permalink structure + $struct = get_option('permalink_structure'); + if (strpos($struct, '%postname%') !== false) { + return '/blog/'; + } + return '/'; + } + + $obj = get_post_type_object($cpt); + if ($obj && isset($obj->rewrite['slug'])) { + return '/' . $obj->rewrite['slug'] . '/'; + } + + return '/' . $cpt . '/'; + } + + /** + * Get available dynamic sources for a CPT + */ + private static function get_available_sources($cpt) + { + $sources = [ + ['value' => 'post_title', 'label' => 'Title'], + ['value' => 'post_content', 'label' => 'Content'], + ['value' => 'post_excerpt', 'label' => 'Excerpt'], + ['value' => 'post_featured_image', 'label' => 'Featured Image'], + ['value' => 'post_author', 'label' => 'Author'], + ['value' => 'post_date', 'label' => 'Date'], + ['value' => 'post_url', 'label' => 'Permalink'], + ]; + + // Add taxonomy sources + $taxonomies = get_object_taxonomies($cpt, 'objects'); + foreach ($taxonomies as $tax) { + if ($tax->public) { + $sources[] = [ + 'value' => $tax->name, + 'label' => $tax->labels->name, + ]; + } + } + + // Add related posts source + $sources[] = ['value' => 'related_posts', 'label' => 'Related Posts']; + + return $sources; + } + + /** + * Get SEO data for a post (Yoast/Rank Math compatible) + */ + private static function get_seo_data($post_id) + { + $seo = []; + + // Try Yoast + $seo['meta_title'] = get_post_meta($post_id, '_yoast_wpseo_title', true) ?: get_the_title($post_id); + $seo['meta_description'] = get_post_meta($post_id, '_yoast_wpseo_metadesc', true); + $seo['canonical'] = get_post_meta($post_id, '_yoast_wpseo_canonical', true) ?: get_permalink($post_id); + $seo['og_title'] = get_post_meta($post_id, '_yoast_wpseo_opengraph-title', true); + $seo['og_description'] = get_post_meta($post_id, '_yoast_wpseo_opengraph-description', true); + $seo['og_image'] = get_post_meta($post_id, '_yoast_wpseo_opengraph-image', true); + + // Try Rank Math if Yoast not available + if (empty($seo['meta_description'])) { + $seo['meta_description'] = get_post_meta($post_id, 'rank_math_description', true); + } + + return $seo; + } +} diff --git a/includes/Api/Routes.php b/includes/Api/Routes.php index b19f879..b969796 100644 --- a/includes/Api/Routes.php +++ b/includes/Api/Routes.php @@ -35,6 +35,7 @@ use WooNooW\Frontend\HookBridge; use WooNooW\Api\Controllers\SettingsController; use WooNooW\Api\Controllers\CartController as ApiCartController; use WooNooW\Admin\AppearanceController; +use WooNooW\Api\PagesController; class Routes { public static function init() { @@ -181,6 +182,9 @@ class Routes { AddressController::register_routes(); WishlistController::register_routes(); HookBridge::register_routes(); + + // Pages and templates controller + PagesController::register_routes(); }); } } diff --git a/includes/Frontend/PageSSR.php b/includes/Frontend/PageSSR.php new file mode 100644 index 0000000..9be40c5 --- /dev/null +++ b/includes/Frontend/PageSSR.php @@ -0,0 +1,290 @@ + $prop) { + if (!is_array($prop)) { + $resolved[$key] = $prop; + continue; + } + + $type = $prop['type'] ?? 'static'; + + if ($type === 'static') { + $resolved[$key] = $prop['value'] ?? ''; + } elseif ($type === 'dynamic' && $post_data) { + $source = $prop['source'] ?? ''; + $resolved[$key] = PlaceholderRenderer::get_value($source, $post_data); + } else { + $resolved[$key] = $prop['value'] ?? ''; + } + } + + return $resolved; + } + + // ======================================== + // Section Renderers + // ======================================== + + /** + * Render Hero section + */ + public static function render_hero($props, $layout, $color_scheme, $id) + { + $title = esc_html($props['title'] ?? ''); + $subtitle = esc_html($props['subtitle'] ?? ''); + $image = esc_url($props['image'] ?? ''); + $cta_text = esc_html($props['cta_text'] ?? ''); + $cta_url = esc_url($props['cta_url'] ?? ''); + + $html = "
"; + + if ($image) { + $html .= "\"{$title}\""; + } + + $html .= '
'; + if ($title) { + $html .= "

{$title}

"; + } + if ($subtitle) { + $html .= "

{$subtitle}

"; + } + if ($cta_text && $cta_url) { + $html .= "{$cta_text}"; + } + $html .= '
'; + $html .= '
'; + + return $html; + } + + /** + * Render Content section (for post body, rich text) + */ + public static function render_content($props, $layout, $color_scheme, $id) + { + $content = $props['content'] ?? ''; + // Apply WordPress content filters (shortcodes, autop, etc.) + $content = apply_filters('the_content', $content); + + return "
{$content}
"; + } + + /** + * Render Image + Text section + */ + public static function render_image_text($props, $layout, $color_scheme, $id) + { + $title = esc_html($props['title'] ?? ''); + $text = wp_kses_post($props['text'] ?? ''); + $image = esc_url($props['image'] ?? ''); + + $html = "
"; + + if ($image) { + $html .= "
\"{$title}\"
"; + } + + $html .= '
'; + if ($title) { + $html .= "

{$title}

"; + } + if ($text) { + $html .= "
{$text}
"; + } + $html .= '
'; + $html .= '
'; + + return $html; + } + + /** + * Render Feature Grid section + */ + public static function render_feature_grid($props, $layout, $color_scheme, $id) + { + $heading = esc_html($props['heading'] ?? ''); + $items = $props['items'] ?? []; + + $html = "
"; + + if ($heading) { + $html .= "

{$heading}

"; + } + + $html .= '
'; + foreach ($items as $item) { + $item_title = esc_html($item['title'] ?? ''); + $item_desc = esc_html($item['description'] ?? ''); + $item_icon = esc_html($item['icon'] ?? ''); + + $html .= '
'; + if ($item_icon) { + $html .= "{$item_icon}"; + } + if ($item_title) { + $html .= "

{$item_title}

"; + } + if ($item_desc) { + $html .= "

{$item_desc}

"; + } + $html .= '
'; + } + $html .= '
'; + $html .= '
'; + + return $html; + } + + /** + * Render CTA Banner section + */ + public static function render_cta_banner($props, $layout, $color_scheme, $id) + { + $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 = "
"; + $html .= '
'; + + if ($title) { + $html .= "

{$title}

"; + } + if ($text) { + $html .= "

{$text}

"; + } + if ($button_text && $button_url) { + $html .= "{$button_text}"; + } + + $html .= '
'; + $html .= '
'; + + return $html; + } + + /** + * Render Contact Form section + */ + public static function render_contact_form($props, $layout, $color_scheme, $id) + { + $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']; + + $html = "
"; + + if ($title) { + $html .= "

{$title}

"; + } + + // Form is rendered but won't work for bots (they just see the structure) + $html .= '
'; + + foreach ($fields as $field) { + $field_label = ucfirst(str_replace('_', ' ', $field)); + $html .= '
'; + $html .= ""; + if ($field === 'message') { + $html .= ""; + } else { + $html .= ""; + } + $html .= '
'; + } + + $html .= ''; + $html .= '
'; + $html .= '
'; + + return $html; + } + + /** + * Generic section fallback + */ + public static function render_generic($props, $type, $id) + { + $content = ''; + foreach ($props as $key => $value) { + if (is_string($value)) { + $content .= "
" . wp_kses_post($value) . "
"; + } + } + + return "
{$content}
"; + } +} diff --git a/includes/Frontend/PlaceholderRenderer.php b/includes/Frontend/PlaceholderRenderer.php new file mode 100644 index 0000000..69c3313 --- /dev/null +++ b/includes/Frontend/PlaceholderRenderer.php @@ -0,0 +1,213 @@ + $post->ID, + 'title' => $post->post_title, + 'content' => apply_filters('the_content', $post->post_content), + 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30), + 'date' => get_the_date('', $post), + 'date_iso' => get_the_date('c', $post), + 'url' => get_permalink($post), + 'slug' => $post->post_name, + 'type' => $post->post_type, + ]; + + // Author + $author_id = $post->post_author; + $data['author'] = get_the_author_meta('display_name', $author_id); + $data['author_url'] = get_author_posts_url($author_id); + + // Featured image + $thumbnail_id = get_post_thumbnail_id($post); + if ($thumbnail_id) { + $data['featured_image'] = get_the_post_thumbnail_url($post, 'large'); + $data['featured_image_id'] = $thumbnail_id; + } + + // Taxonomies + $taxonomies = get_object_taxonomies($post->post_type); + foreach ($taxonomies as $taxonomy) { + $terms = get_the_terms($post, $taxonomy); + if ($terms && !is_wp_error($terms)) { + $data[$taxonomy] = array_map(function($term) { + return [ + 'id' => $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + 'url' => get_term_link($term), + ]; + }, $terms); + } + } + + // Shortcuts for common taxonomies + if (isset($data['category'])) { + $data['categories'] = $data['category']; + } + if (isset($data['post_tag'])) { + $data['tags'] = $data['post_tag']; + } + + // Custom meta fields + $meta = get_post_meta($post->ID); + if ($meta) { + $data['meta'] = []; + foreach ($meta as $key => $values) { + // Skip internal meta keys + if (strpos($key, '_') === 0) { + continue; + } + $data['meta'][$key] = count($values) === 1 ? $values[0] : $values; + } + } + + return $data; + } + + /** + * Get related posts + * + * @param int $post_id Current post ID + * @param int $count Number of related posts + * @param string $post_type Post type + * @return array Related posts data + */ + public static function get_related_posts($post_id, $count = 3, $post_type = 'post') + { + // Get categories of current post + $categories = get_the_category($post_id); + $category_ids = wp_list_pluck($categories, 'term_id'); + + $args = [ + 'post_type' => $post_type, + 'posts_per_page' => $count, + 'post__not_in' => [$post_id], + 'orderby' => 'date', + 'order' => 'DESC', + ]; + + if (!empty($category_ids)) { + $args['category__in'] = $category_ids; + } + + $query = new \WP_Query($args); + $related = []; + + foreach ($query->posts as $post) { + $related[] = [ + 'id' => $post->ID, + 'title' => $post->post_title, + 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 20), + 'url' => get_permalink($post), + 'featured_image' => get_the_post_thumbnail_url($post, 'medium'), + 'date' => get_the_date('', $post), + ]; + } + + wp_reset_postdata(); + + return $related; + } +} diff --git a/includes/Frontend/TemplateOverride.php b/includes/Frontend/TemplateOverride.php index 1159ecc..af1958b 100644 --- a/includes/Frontend/TemplateOverride.php +++ b/includes/Frontend/TemplateOverride.php @@ -648,4 +648,100 @@ class TemplateOverride return $template; } + + /** + * Detect if current request is from a bot/crawler + * Used to serve SSR content for SEO instead of SPA redirect + * + * @return bool True if request is from a known bot + */ + public static function is_bot() + { + // Get User-Agent + $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; + if (empty($user_agent)) { + return false; + } + + // Convert to lowercase for case-insensitive matching + $user_agent = strtolower($user_agent); + + // Known bot patterns + $bot_patterns = [ + // Search engine crawlers + 'googlebot', + 'bingbot', + 'slurp', // Yahoo + 'duckduckbot', + 'baiduspider', + 'yandexbot', + 'sogou', + 'exabot', + + // Generic patterns + 'crawler', + 'spider', + 'robot', + 'scraper', + + // Social media bots (for link previews) + 'facebookexternalhit', + 'twitterbot', + 'linkedinbot', + 'whatsapp', + 'slackbot', + 'telegrambot', + 'discordbot', + + // Other known bots + 'applebot', + 'semrushbot', + 'ahrefsbot', + 'mj12bot', + 'dotbot', + 'petalbot', + 'bytespider', + + // Prerender services + 'prerender', + 'headlesschrome', + ]; + + // Check if User-Agent contains any bot pattern + foreach ($bot_patterns as $pattern) { + if (strpos($user_agent, $pattern) !== false) { + return true; + } + } + + return false; + } + + /** + * Serve SSR content for bots + * Renders page structure as static HTML for search engine indexing + * + * @param int $page_id Page ID to render + * @param string $type 'page' or 'template' + * @param array|null $post_data Post data for template rendering (CPT items) + */ + public static function serve_ssr_content($page_id, $type = 'page', $post_data = null) + { + // Get page structure + if ($type === 'page') { + $structure = get_post_meta($page_id, '_wn_page_structure', true); + } else { + // CPT template + $structure = get_option("wn_template_{$type}", null); + } + + if (empty($structure) || empty($structure['sections'])) { + return false; // No structure, let normal WP handle it + } + + // Will be implemented in PageSSR class + // For now, return false to let normal WP handle + // TODO: Implement PageSSR::render($structure, $post_data) + return false; + } } diff --git a/woonoow-page-editor-brief.md b/woonoow-page-editor-brief.md new file mode 100644 index 0000000..4a9e331 --- /dev/null +++ b/woonoow-page-editor-brief.md @@ -0,0 +1,781 @@ +# WooNooW Page Editor Strategy - Final Decision Brief (v3) + +## Overview +Extend WooNooW SPA to support structural pages and CPT templates with: +- **Unified Page Editor** for pages (page CPT) and CPT templates (post, portfolio, custom) +- **Native WordPress `page` CPT** for structural pages (SEO compatible) +- **Dynamic placeholders** for CPT templates (blog posts, portfolio items, etc.) +- **Dual rendering strategy** for bots vs humans (SSR + SPA redirect) +- **Zero page builder plugin dependencies** +- **Integrated admin UI** with collapsible sidebar + +--- + +## Core Architecture + +### Data Storage + +#### Structural Pages (page CPT) +- **Location**: WordPress `wp_posts` table (post_type='page') +- **Structure Storage**: `wp_postmeta`, key: `_wn_page_structure` +- **Type**: Static content only (no placeholders) +- **Example**: Homepage, About, Contact, Terms, Privacy + +#### CPT Templates +- **Location**: WordPress `wp_options` table +- **Storage Keys**: `wn_template_{cpt_name}` + - `wn_template_post` (for blog posts) + - `wn_template_portfolio` (for portfolio items) + - `wn_template_custom_cpt` (for custom CPTs) +- **Type**: Dynamic content with placeholders +- **Applies To**: All items of that CPT (one template per CPT) + +### Page Structure Storage Format + +```json +{ + "type": "page|template", + "sections": [ + { + "id": "section-1", + "type": "hero", + "layoutVariant": "hero-left-image", + "colorScheme": "primary", + "props": { + "title": { + "type": "static", + "value": "Welcome" + }, + "subtitle": { + "type": "static", + "value": "Join us" + }, + "image": { + "type": "static", + "value": "/uploads/hero.jpg" + } + } + }, + { + "id": "section-2", + "type": "content", + "props": { + "content": { + "type": "dynamic", + "source": "post_content" + } + } + }, + { + "id": "section-3", + "type": "feature-grid", + "layoutVariant": "grid-3-columns", + "props": { + "heading": { + "type": "static", + "value": "Why Choose Us" + } + } + } + ] +} +``` + +### Field Types: Static vs Dynamic + +**Static Fields** (Structural Pages): +```json +{ + "type": "static", + "value": "Hardcoded text or value" +} +``` + +**Dynamic Fields** (CPT Templates): +```json +{ + "type": "dynamic", + "source": "post_title|post_content|post_excerpt|post_featured_image|related_posts|..." +} +``` + +#### Available Dynamic Sources (per CPT) + +**For `post` CPT:** +- `post_title` - Post title +- `post_excerpt` - Post excerpt +- `post_content` - Post body content +- `post_featured_image` - Featured image URL +- `post_author` - Author name +- `post_date` - Publication date +- `post_categories` - Category list +- `post_tags` - Tag list +- `related_posts` - Query for related posts (with count parameter) + +**For Custom CPTs:** +- `{cpt_name}_title` - CPT item title +- `{cpt_name}_featured_image` - Featured image +- `{cpt_name}_field_{field_name}` - Custom meta fields +- Custom sources defined per CPT + +--- + +## Unified Page Editor Concept + +### Single Editor, Two Modes + +**Mode 1: Structural Pages (Static)** +- Edit individual pages (page CPT) +- All content is static/hardcoded +- Examples: Home, About, Contact, Terms, Privacy +- SEO managed by Yoast/Rank Math + +**Mode 2: CPT Templates (Dynamic)** +- Design template for entire CPT +- Content has dynamic placeholders +- One template applies to all items of that CPT +- Examples: Blog Post template, Portfolio template, News template + +### Create New Page Flow + +When user clicks `[+ Create Page]`: + +``` +Modal Dialog: +┌──────────────────────────────────┐ +│ Create New Page │ +├──────────────────────────────────┤ +│ │ +│ What are you creating? │ +│ │ +│ ○ Structural Page │ +│ Static content (page CPT) │ +│ Examples: About, Contact │ +│ Permalink: /page-slug │ +│ │ +│ ○ Blog Post Template │ +│ Dynamic template for posts │ +│ Applies to: /blog/* │ +│ Includes placeholders │ +│ │ +│ ○ Portfolio Template │ +│ Dynamic template for portfolio │ +│ Applies to: /portfolio/* │ +│ Includes placeholders │ +│ │ +│ ○ Other CPT Template │ +│ [Select CPT ▼] │ +│ Applies to: /cpt-base/* │ +│ │ +│ [Cancel] [Next] │ +└──────────────────────────────────┘ +``` + +### Editor Left Panel: Pages List (Unified) + +``` +Pages Sidebar + +📄 STRUCTURAL PAGES +├─ ✓ Homepage (page) +├─ ○ About (page) +├─ ○ Contact (page) +└─ ○ Terms (page) + +📋 TEMPLATES (Dynamic) +├─ ○ Blog Post (post) +│ └─ Applies to: /blog/* +├─ ○ Portfolio (portfolio) +│ └─ Applies to: /portfolio/* +├─ ○ News (custom_cpt) +│ └─ Applies to: /news/* +└─ ○ Custom CPT (custom_name) + └─ Applies to: /custom-base/* + +Visual indicators: +- 📄 = Structural page (static) +- 📋 = Template (dynamic) +``` + +--- + +## Rendering Strategy: Dual Path + +### Path 1: Bot Detection → Server-Side Rendering (SSR) + +**When**: Bot user agent detected +**Action**: Serve pre-rendered HTML (no redirect) +**Content**: All sections rendered as static HTML strings +**Placeholders**: Replaced with actual post data at render time +**Result**: ✅ Full content indexed by search engines + +``` +Bot visits /blog/my-article + ↓ +is_bot() === true + ↓ +Get post data + load post template + ↓ +Replace placeholders (post_title, post_content, etc.) + ↓ +Render sections to static HTML + ↓ +Output:

Article Title

Post content...

+ ↓ +✓ Bot crawls full content +✓ All text indexed +✓ All links followable +✓ SEO perfect +``` + +### Path 2: Human Detection → SPA Redirect + +**When**: Human user agent detected +**Action**: HTTP 302 redirect to SPA single-page app +**Destination**: Matches WordPress permalink structure + - `/about` → `/store/about` + - `/blog/slug` → `/store/blog/slug` + - `/portfolio/slug` → `/store/portfolio/slug` +**Result**: ✅ Beautiful interactive React UI + +``` +Human visits /blog/my-article + ↓ +is_bot() === false + ↓ +HTTP 302 → /store/blog/my-article + ↓ +SPA loads at /store/blog/my-article + ↓ +React detects path base (/blog) → loads post template + ↓ +React fetches post data for "my-article" + ↓ +React replaces placeholders with post data + ↓ +Renders sections with React components + ↓ +✓ Interactive UI +✓ No page reload +✓ Great UX +``` + +### Bot Detection Logic + +Detect bots by User-Agent string: +```php +$bot_patterns = [ + 'googlebot', 'bingbot', 'slurp', 'duckduckbot', + 'baiduspider', 'yandexbot', 'crawler', 'robot', + 'spider', 'facebookexternalhit', 'twitterbot', + 'linkedinbot', 'whatsapp', 'slackbot', 'applebot' +]; +``` + +--- + +## SPA Architecture (Enhanced) + +### SPA Remains Centralized +- **Single page location**: WordPress page (configurable, e.g., `/store/`) +- **Single React router**: Handles all SPA routes +- **Routes**: + - `/` - Homepage (products) + - `/shop` - Shop listing + - `/product/:slug` - Product detail + - `/cart` - Shopping cart + - `/checkout` - Checkout + - `/my-account/*` - User account + - **NEW Dynamic Routes**: + - `/:slug` - Structural page (About, Contact, etc.) + - `/:pathBase/:slug` - CPT item (blog post, portfolio, etc.) + +### Dynamic Route Matching + +```tsx +// React Router detects what to render +const router = createBrowserRouter([ + // Existing routes (higher priority) + { path: '/', element: }, + { path: '/shop', element: }, + { path: '/product/:slug', element: }, + + // NEW: Dynamic routes (lower priority) + { path: '/:pathBase/:slug', element: }, + { path: '/:slug', element: }, +]); + +// DynamicPageRenderer logic +function DynamicPageRenderer() { + const { pathBase, slug } = useParams(); + const [pageData, setPageData] = useState(null); + + useEffect(() => { + // Determine what template to use + if (!pathBase) { + // /:slug → Structural page (About, Contact, etc.) + fetchPage(slug); + } else { + // /:pathBase/:slug → CPT item + detectCPTFromPath(pathBase); // /blog → post, /portfolio → portfolio + fetchCPTTemplate(cptType); + fetchPostData(slug); + } + }, [pathBase, slug]); + + return ; +} +``` + +### No Multiple "SPA Islands" +- Pages route through same SPA instance +- Same styling, same theme variables, same JavaScript context + +--- + +## Page Editor UI Layout (3-Column Design) + +### Visual Layout +``` +┌─────────────────────────────────────────────────────────────┐ +│ [☰] WooNooW Appearance > Pages > [Blog Post Template] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────┬──────────────────┬──────────────────────┐ │ +│ │ Pages │ │ │ │ +│ │ Sidebar │ Editor Panel │ Settings + Preview │ │ +│ │ │ │ │ │ +│ │ 📄 Home │ SECTIONS │ Page Settings │ │ +│ │ ○ About │ ────────────────│ ├─ Type: Template │ │ +│ │ ○ Contact │ 1. Hero Section │ ├─ CPT: post │ │ +│ │ │ [Image ◆] │ └─ Permalink: /blog │ │ +│ │ 📋 Posts │ 2. Content │ │ │ +│ │ ○ Portfolio│ [Post Body ◆]│ Section Settings │ │ +│ │ ○ News │ 3. Related Posts│ ├─ Layout: Grid 3 │ │ +│ │ │ [Query ◆] │ ├─ Count: [3___] │ │ +│ │ [+ Create] │ 4. CTA Banner │ └─ [Delete] │ │ +│ │ │ │ │ │ +│ │ │ [+ Add Section] │ Preview │ │ +│ │ │ │ [Desktop] [Mobile] │ │ +│ │ │ Legend: │ ┌──────────────────┐│ │ +│ │ │ [◆] = Dynamic │ │ Live SPA Preview ││ │ +│ │ │ placeholder │ │ (Real-time) ││ │ +│ │ │ │ └──────────────────┘│ │ +│ │ │ │ │ │ +│ └────────────┴──────────────────┴──────────────────────┘ │ +│ │ +│ [Discard Changes] [Save Changes] [Preview] │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Settings Panel Behavior + +**For Structural Pages (No Section Selected):** +``` +Page Settings +├─ Type: Page (Structural) +├─ Title: About Us +├─ Slug: about +└─ SEO: Manage in Yoast [→] +``` + +**For Templates (No Section Selected):** +``` +Template Settings +├─ Type: Template (Dynamic) +├─ CPT: Post +├─ Applies to: /blog/* +└─ Edit CPT permalink: [→] +``` + +**For Sections with Dynamic Fields:** +``` +Section Settings: Hero + +Layout Variant +[Hero Left Image ▼] + +Title Field +○ Static: [____________] +◉ Dynamic: [Post Title ◆] + +Image Field +○ Static: [Choose Image] +◉ Dynamic: [Featured Image ◆] + +[Delete Section] +``` + +--- + +## Admin SPA Integration + +### Navigation Location +- **Menu**: Appearance (existing) +- **Submenu**: Pages (NEW) +- **Path**: `/admin/appearance/pages` + +### Sidebar Behavior + +**Default (Expanded):** +- Admin sidebar shows full menu with labels +- Page editor takes remaining width (3-column layout) + +**Collapsed:** +- Admin sidebar shows icons only (~60px width) +- Page editor expands to use more width +- Tooltips show on hover + +**Layout dimensions:** + +| State | Sidebar | Content Area | Pages List | Editor | Settings+Preview | +|-------|---------|--------------|-----------|--------|------------------| +| Expanded | 240px | ~1160px | 120px | 650px | 390px | +| Collapsed | 60px | ~1340px | 120px | 750px | 470px | + +### No Full-Screen Takeover +- ✅ Part of admin SPA normal content flow +- ✅ User maintains context (can quickly jump to other admin sections) +- ✅ Sidebar collapse is user-controlled, not forced +- ✅ Can navigate back via breadcrumb or admin menu + +### Escape Routes +1. Click WooNooW logo → Dashboard +2. Click any menu item in sidebar → Navigate to that section +3. Press ESC key → Back (with unsaved changes warning) +4. Click browser back button → Back (with unsaved changes warning) +5. Click breadcrumb "Appearance" → Back to Appearance settings + +--- + +## SEO Preservation + +### Yoast/Rank Math Integration +- ✅ Meta title, meta description managed by Yoast/Rank Math +- ✅ Canonical URLs managed by WordPress +- ✅ Open Graph tags managed by SEO plugins +- ✅ Structured data can be added to page template +- ✅ No changes needed to existing SEO workflow + +### For CPT Items (Posts, Portfolio, etc.) +- Yoast/Rank Math still manages SEO for individual CPT items +- Templates don't interfere with SEO workflow +- Bot sees pre-rendered content with all placeholders filled + +### What Bots See + +**Example: Blog Post with Template** +```html +

Article Title

+Article featured image +
+

Article body content goes here...

+
+ +``` + +- ✅ All text crawlable +- ✅ All links followable +- ✅ Structure preserved +- ✅ Meta tags present +- ✅ Images indexed + +--- + +## API Endpoints + +### Get All Pages & Templates +``` +GET /wp-json/woonoow/v1/pages +``` + +**Response:** +```json +[ + { + "id": 42, + "type": "page", + "slug": "home", + "title": "Homepage", + "icon": "page" + }, + { + "type": "template", + "cpt": "post", + "title": "Blog Post Template", + "icon": "template", + "permalink_base": "/blog/" + } +] +``` + +### Get Page or Template Structure +``` +GET /wp-json/woonoow/v1/pages/about +GET /wp-json/woonoow/v1/templates/post +``` + +**Response:** +```json +{ + "type": "page|template", + "id": 42, + "slug": "about", + "title": "About Us", + "url": "https://mystore.com/about", + "seo": { + "meta_title": "About Us | My Store", + "meta_description": "Learn about our company...", + "canonical": "https://mystore.com/about", + "og_title": "About Us", + "og_image": "https://mystore.com/og-image.jpg" + }, + "structure": { + "sections": [...] + } +} +``` + +### Get Single Post with Template Applied +``` +GET /wp-json/woonoow/v1/posts/{post_id}/with-template +``` + +**Response:** +```json +{ + "post": { + "id": 123, + "title": "Article Title", + "content": "Article body...", + "featured_image": "/uploads/image.jpg", + "author": "John Doe", + "date": "2026-01-11" + }, + "template": { + "sections": [...] + }, + "rendered": { + "sections": [ + { + "type": "hero", + "props": { + "title": "Article Title", // Placeholder filled + "image": "/uploads/image.jpg" // Placeholder filled + } + } + ] + } +} +``` + +### Save Page or Template Structure +``` +POST /wp-json/woonoow/v1/pages/about +POST /wp-json/woonoow/v1/templates/post +``` + +**Request body:** +```json +{ + "sections": [...] +} +``` + +**Response:** +```json +{ + "success": true, + "page": { ... } +} +``` + +--- + +## Implementation Phases + +### Phase 1: Core Infrastructure (Priority) +- [ ] Add bot detection logic to `TemplateOverride.php` +- [ ] Build `PageSSR` class to render sections as HTML +- [ ] Build `PlaceholderRenderer` class to replace dynamic placeholders +- [ ] Create REST API endpoints: + - `GET /wp-json/woonoow/v1/pages` (list all) + - `GET /wp-json/woonoow/v1/pages/{slug}` (get page) + - `GET /wp-json/woonoow/v1/templates/{cpt}` (get template) + - `GET /wp-json/woonoow/v1/posts/{id}/with-template` (get post with template) + - `POST /wp-json/woonoow/v1/pages/{slug}` (save page) + - `POST /wp-json/woonoow/v1/templates/{cpt}` (save template) +- [ ] Update template_redirect hook: + - Detect if structural page or CPT item + - Load appropriate template + - Replace placeholders (for CPT items) + - Serve SSR for bots (exit without redirect) + - Redirect humans to SPA with matching permalink structure +- [ ] Create React `DynamicPageRenderer` component +- [ ] Test bot crawling vs human browsing + +### Phase 2: Admin SPA Integration +- [ ] Add "Pages" submenu to Appearance menu +- [ ] Create `/admin/appearance/pages` route +- [ ] Build `PageEditor` component with 3-column layout +- [ ] Implement sidebar collapse/expand functionality +- [ ] Add breadcrumb navigation +- [ ] Implement create page modal (structural vs template selection) +- [ ] Test navigation flow + +### Phase 3: WooNooW Page Editor (UI) +- [ ] Build custom admin page editor UI (React) +- [ ] Section list with drag-to-reorder +- [ ] Section selector popup when clicking "+ Add Section" +- [ ] Layout variant dropdown per section +- [ ] Color scheme selector per section +- [ ] Form fields for section content: + - Static field: text input + - Dynamic field: dropdown with available sources +- [ ] Live SPA preview iframe (real-time updates) +- [ ] Desktop/Mobile preview toggle +- [ ] Save/Publish button → stores to postmeta or wp_options +- [ ] Visual indicator for dynamic placeholders ([◆]) + +### Phase 4: Section Library +- [ ] Define section types and their props schema +- [ ] Hero section renderer (SSR + React) +- [ ] Content section renderer (for post body) +- [ ] Feature grid renderer (SSR + React) +- [ ] Related items section (for related posts/CPT items) +- [ ] CTA banner renderer (SSR + React) +- [ ] Add more sections as needed + +### Phase 5: Polish & Launch +- [ ] Caching for SSR (cache rendered HTML for 1 hour) +- [ ] Unsaved changes warning (ESC key, browser back, menu click) +- [ ] Test SEO with Google Search Console +- [ ] Test with bot simulators (curl with bot user agents) +- [ ] Test dynamic placeholder rendering +- [ ] Documentation for merchants +- [ ] Documentation for developers (extending sections, custom placeholders) + +--- + +## Database Schema + +### Structural Pages (page CPT) +```sql +wp_posts: +├─ id: 42 +├─ post_type: 'page' +├─ post_name: 'about' +├─ post_title: 'About Us' +├─ post_status: 'publish' +└─ post_modified: '2026-01-11 20:58:00' + +wp_postmeta: +├─ post_id: 42, meta_key: '_wn_page_structure' +│ value: {"type": "page", "sections": [...]} +├─ post_id: 42, meta_key: '_yoast_wpseo_title' +│ value: "About Us | My Store" +├─ post_id: 42, meta_key: '_yoast_wpseo_metadesc' +│ value: "Learn about our company..." +├─ post_id: 42, meta_key: '_yoast_wpseo_canonical' +│ value: "https://mystore.com/about" +└─ post_id: 42, meta_key: '_yoast_wpseo_opengraph-image' + value: "https://mystore.com/og-image.jpg" +``` + +### CPT Templates +```sql +wp_options: +├─ option_name: 'wn_template_post' +│ option_value: {"type": "template", "cpt": "post", "sections": [...]} +├─ option_name: 'wn_template_portfolio' +│ option_value: {"type": "template", "cpt": "portfolio", "sections": [...]} +└─ option_name: 'wn_template_custom_cpt' + option_value: {"type": "template", "cpt": "custom_cpt", "sections": [...]} +``` + +--- + +## Target Users & Experience + +### Tech-Savvy Users (Developers/Agencies) +**Use Case**: Building custom stores with WooNooW +**Tools**: WooNooW CSS variables, class documentation, section schema +**Value**: Framework for rapid SPA development, design system enforcement + +### Non-Tech Merchants +**Use Case**: Quick store setup with predefined pages and templates +**Tools**: WooNooW Page Editor (constrained choices, no freestyle design) +**Value**: Professional pages in minutes, consistent styling across all CPTs + +### Both +**Result**: Consistent visual identity across structural pages + dynamic CPT items + +--- + +## Decision Summary + +| Aspect | Decision | +|--------|----------| +| **Editor Type** | Single unified editor for pages + CPT templates | +| **Structural Pages** | Native `page` CPT, stored in postmeta | +| **CPT Templates** | Stored in wp_options (one per CPT) | +| **Field Types** | Static (hardcoded) or Dynamic (with placeholders) | +| **Available Placeholders** | post_title, post_content, featured_image, author, date, related_posts, etc. | +| **Page Builder Integration** | None (custom WooNooW editor only) | +| **Admin Location** | Appearance > Pages (single submenu) | +| **Editor Context** | Part of admin SPA (not full-screen) | +| **Sidebar Behavior** | Collapsible (expanded or icon-only) | +| **Create Flow** | Modal asks: Structural page or CPT template? | +| **SPA Routing** | Matches WordPress permalink structure | +| **Permalink Matching** | `/blog/slug` → `/store/blog/slug` | +| **SEO** | Yoast/Rank Math for pages, templates don't interfere | +| **Bot Handling** | No redirect, serve pre-rendered HTML with placeholders filled | +| **Human Handling** | HTTP 302 redirect to SPA with matching permalink structure | +| **SPA Location** | Single centralized page (e.g., `/store/`) | +| **Performance** | Cache SSR HTML (1 hour TTL) | +| **UI Pattern** | 3-column layout (pages list, editor, settings+preview) | +| **Template Types** | Hero, Content, Feature Grid, Related Items, CTA Banner | + +--- + +## What's NOT Changing + +✅ Existing shop/product/cart/checkout functionality +✅ Existing SPA architecture (single page, centralized) +✅ Existing template override logic (extended, not replaced) +✅ Existing API patterns (new endpoints follow conventions) +✅ Existing CSS variable system (reused for all sections) +✅ SEO plugin compatibility (Yoast, Rank Math continue to work) +✅ Admin menu structure (Pages added to existing Appearance menu) +✅ Admin sidebar (enhanced with collapse functionality, not replaced) + +--- + +## Key Architectural Benefits + +✅ **One editor, two modes** - Merchants don't learn separate systems +✅ **Clean separation** - Static pages vs dynamic templates clear +✅ **URL consistency** - SPA routes match WordPress permalinks +✅ **SEO perfect** - Bots see full content, humans get SPA experience +✅ **Scalable** - Works for current CPTs and future custom CPTs +✅ **Flexible placeholders** - Can add any post field or custom meta +✅ **No page builder lock-in** - Custom sections, not Gutenberg/Elementor +✅ **Maintains design consistency** - Predefined sections, no freestyle design + +--- + +## Next Discussion Topics +- [ ] Section library detailed specs (Hero, Content, Features, Related, CTA) +- [ ] Placeholder source definitions and extensibility +- [ ] Section schema validation +- [ ] Caching strategy details (Redis vs file-based vs transient) +- [ ] Migration path for existing pages/posts +- [ ] Developer documentation (extending sections, custom placeholders) +- [ ] Component structure and file organization (React + PHP)