Files
WooNooW/includes/Frontend/TemplateOverride.php
Dwindi Ramadhana 9331989102 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
2026-01-11 22:29:30 +07:00

748 lines
25 KiB
PHP

<?php
namespace WooNooW\Frontend;
/**
* Template Override
* Overrides WooCommerce templates to use WooNooW SPA
*/
class TemplateOverride
{
/**
* Initialize
*/
public static function init()
{
// Register rewrite rules for BrowserRouter SEO (must be on 'init')
add_action('init', [__CLASS__, 'register_spa_rewrite_rules']);
// Flush rewrite rules when relevant settings change
add_action('update_option_woonoow_appearance_settings', function($old_value, $new_value) {
$old_general = $old_value['general'] ?? [];
$new_general = $new_value['general'] ?? [];
// Only flush if spa_mode, spa_page, or use_browser_router changed
$needs_flush =
($old_general['spa_mode'] ?? '') !== ($new_general['spa_mode'] ?? '') ||
($old_general['spa_page'] ?? '') !== ($new_general['spa_page'] ?? '') ||
($old_general['use_browser_router'] ?? true) !== ($new_general['use_browser_router'] ?? true);
if ($needs_flush) {
flush_rewrite_rules();
}
}, 10, 2);
// Redirect WooCommerce pages to SPA routes early (before template loads)
add_action('template_redirect', [__CLASS__, 'redirect_wc_pages_to_spa'], 5);
// Serve SPA directly for frontpage routes (priority 1 = very early, before WC)
add_action('template_redirect', [__CLASS__, 'serve_spa_for_frontpage_routes'], 1);
// Hook to wp_loaded with priority 10 (BEFORE WooCommerce's priority 20)
// This ensures we process add-to-cart before WooCommerce does
add_action('wp_loaded', [__CLASS__, 'intercept_add_to_cart'], 10);
// Use blank template for full-page SPA
add_filter('template_include', [__CLASS__, 'use_spa_template'], 999);
// Disable canonical redirects for SPA routes
add_filter('redirect_canonical', [__CLASS__, 'disable_canonical_redirect'], 10, 2);
// Override WooCommerce shop page
add_filter('woocommerce_show_page_title', '__return_false');
// Replace WooCommerce content with our SPA
add_action('woocommerce_before_main_content', [__CLASS__, 'start_spa_wrapper'], 5);
add_action('woocommerce_after_main_content', [__CLASS__, 'end_spa_wrapper'], 999);
// Remove WooCommerce default content
remove_action('woocommerce_before_shop_loop', 'woocommerce_result_count', 20);
remove_action('woocommerce_before_shop_loop', 'woocommerce_catalog_ordering', 30);
remove_action('woocommerce_before_main_content', 'woocommerce_output_content_wrapper', 10);
remove_action('woocommerce_after_main_content', 'woocommerce_output_content_wrapper_end', 10);
// Override single product template
add_filter('woocommerce_locate_template', [__CLASS__, 'override_template'], 10, 3);
// Remove theme header and footer when SPA is active
add_action('get_header', [__CLASS__, 'remove_theme_header']);
add_action('get_footer', [__CLASS__, 'remove_theme_footer']);
}
/**
* Register rewrite rules for BrowserRouter SEO
* Catches all SPA routes and serves the SPA page
*/
public static function register_spa_rewrite_rules()
{
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
// Check if BrowserRouter is enabled (default: true for new installs)
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
if (!$spa_page_id || !$use_browser_router) {
return;
}
$spa_page = get_post($spa_page_id);
if (!$spa_page) {
return;
}
$spa_slug = $spa_page->post_name;
// Check if SPA page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front');
$is_spa_frontpage = $frontpage_id && $frontpage_id === (int) $spa_page_id;
if ($is_spa_frontpage) {
// When SPA is frontpage, add root-level routes
// /shop, /shop/* → SPA page
add_rewrite_rule(
'^shop/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=shop',
'top'
);
add_rewrite_rule(
'^shop/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=shop/$matches[1]',
'top'
);
// /product/* → SPA page
add_rewrite_rule(
'^product/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=product/$matches[1]',
'top'
);
// /cart → SPA page
add_rewrite_rule(
'^cart/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=cart',
'top'
);
// /checkout → SPA page
add_rewrite_rule(
'^checkout/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=checkout',
'top'
);
// /my-account, /my-account/* → SPA page
add_rewrite_rule(
'^my-account/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=my-account',
'top'
);
add_rewrite_rule(
'^my-account/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=my-account/$matches[1]',
'top'
);
// /login, /register, /reset-password → SPA page
add_rewrite_rule(
'^login/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=login',
'top'
);
add_rewrite_rule(
'^register/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=register',
'top'
);
add_rewrite_rule(
'^reset-password/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=reset-password',
'top'
);
} else {
// Rewrite /slug/anything to serve the SPA page
// React Router handles the path after that
add_rewrite_rule(
'^' . preg_quote($spa_slug, '/') . '/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=$matches[1]',
'top'
);
}
// Register query var for the SPA path
add_filter('query_vars', function($vars) {
$vars[] = 'woonoow_spa_path';
return $vars;
});
}
/**
* Intercept add-to-cart redirect (NOT the add-to-cart itself)
* Let WooCommerce handle the cart operation properly, we just redirect afterward
*
* This is the proper approach - WooCommerce manages sessions correctly,
* we just customize where the redirect goes.
*/
public static function intercept_add_to_cart()
{
// Only act if add-to-cart is present
if (!isset($_GET['add-to-cart'])) {
return;
}
// Get SPA page from appearance settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = isset($appearance_settings['general']['spa_page']) ? $appearance_settings['general']['spa_page'] : 0;
if (!$spa_page_id) {
return; // No SPA page configured, let WooCommerce handle everything
}
// Hook into WooCommerce's redirect filter AFTER it adds to cart
// This is the proper way to customize the redirect destination
add_filter('woocommerce_add_to_cart_redirect', function ($url) use ($spa_page_id) {
// Get redirect parameter from original request
$redirect_to = isset($_GET['redirect']) ? sanitize_text_field($_GET['redirect']) : 'cart';
// Build redirect URL with hash route for SPA
$redirect_url = get_permalink($spa_page_id);
// Determine hash route based on redirect parameter
$hash_route = '/cart'; // Default
if ($redirect_to === 'checkout') {
$hash_route = '/checkout';
} elseif ($redirect_to === 'shop') {
$hash_route = '/shop';
}
// Return the SPA URL with hash route
return trailingslashit($redirect_url) . '#' . $hash_route;
}, 999);
// Prevent caching
add_action('template_redirect', function () {
nocache_headers();
}, 1);
}
/**
* Redirect WooCommerce pages to SPA routes
* Maps: /shop → /store/, /cart → /store/cart, etc.
*/
public static function redirect_wc_pages_to_spa()
{
// Get SPA settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
// Only redirect when SPA mode is 'full'
if ($spa_mode !== 'full') {
return;
}
if (!$spa_page_id) {
return; // No SPA page configured
}
// Skip if SPA is set as frontpage (serve_spa_for_frontpage_routes handles it)
$frontpage_id = (int) get_option('page_on_front');
if ($frontpage_id && $frontpage_id === (int) $spa_page_id) {
return;
}
// Already on SPA page, don't redirect
global $post;
if ($post && $post->ID == $spa_page_id) {
return;
}
$spa_url = trailingslashit(get_permalink($spa_page_id));
// Helper function to build route URL based on router type
$build_route = function($path) use ($spa_url, $use_browser_router) {
if ($use_browser_router) {
// Path format: /store/cart
return $spa_url . ltrim($path, '/');
}
// Hash format: /store/#/cart
return rtrim($spa_url, '/') . '#/' . ltrim($path, '/');
};
// Check which WC page we're on and redirect
if (is_shop()) {
wp_redirect($build_route('shop'), 302);
exit;
}
if (is_product()) {
// Use get_queried_object() which returns the WP_Post, then get slug
$product_post = get_queried_object();
if ($product_post && isset($product_post->post_name)) {
$slug = $product_post->post_name;
wp_redirect($build_route('product/' . $slug), 302);
exit;
}
}
if (is_cart()) {
wp_redirect($build_route('cart'), 302);
exit;
}
if (is_checkout() && !is_order_received_page()) {
wp_redirect($build_route('checkout'), 302);
exit;
}
if (is_account_page()) {
wp_redirect($build_route('my-account'), 302);
exit;
}
}
/**
* Serve SPA template directly for frontpage SPA routes
* When SPA page is set as WordPress frontpage, intercept known routes
* and serve the SPA template directly (bypasses WooCommerce templates)
*/
public static function serve_spa_for_frontpage_routes()
{
// Get SPA settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// Only run in full SPA mode
if ($spa_mode !== 'full' || !$spa_page_id) {
return;
}
// Check if SPA page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front');
if (!$frontpage_id || $frontpage_id !== (int) $spa_page_id) {
return; // SPA is not frontpage, let normal routing handle it
}
// Get the current request path
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
$path = parse_url($request_uri, PHP_URL_PATH);
$path = '/' . trim($path, '/');
// Define SPA routes that should be intercepted when SPA is frontpage
$spa_routes = [
'/', // Frontpage itself
'/shop', // Shop page
'/cart', // Cart page
'/checkout', // Checkout page
'/my-account', // Account page
'/login', // Login page
'/register', // Register page
'/reset-password', // Password reset
];
// Check for exact matches or path prefixes
$should_serve_spa = false;
// Check exact matches
if (in_array($path, $spa_routes)) {
$should_serve_spa = true;
}
// Check path prefixes (for sub-routes)
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
foreach ($prefix_routes as $prefix) {
if (strpos($path, $prefix) === 0) {
$should_serve_spa = true;
break;
}
}
// Not a SPA route
if (!$should_serve_spa) {
return;
}
// Prevent caching for dynamic SPA content
nocache_headers();
// Load the SPA template directly and exit
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
if (file_exists($spa_template)) {
// Set up minimal WordPress environment for the template
status_header(200);
// Define constant to tell Assets to load unconditionally
if (!defined('WOONOOW_SERVE_SPA')) {
define('WOONOOW_SERVE_SPA', true);
}
// Include the SPA template
include $spa_template;
exit;
}
}
/**
* Disable canonical redirects for SPA routes
* This prevents WordPress from redirecting /product/slug URLs
*/
public static function disable_canonical_redirect($redirect_url, $requested_url)
{
$settings = get_option('woonoow_customer_spa_settings', []);
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled';
// Only disable redirects in full SPA mode
if ($mode !== 'full') {
return $redirect_url;
}
// Check if this is a SPA route
$spa_routes = ['/product/', '/cart', '/checkout', '/my-account'];
foreach ($spa_routes as $route) {
if (strpos($requested_url, $route) !== false) {
// This is a SPA route, disable WordPress redirect
return false;
}
}
return $redirect_url;
}
/**
* Use SPA template (blank page)
*/
public static function use_spa_template($template)
{
// Check spa_mode from appearance settings FIRST
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// If SPA is disabled, return original template immediately
if ($spa_mode === 'disabled') {
return $template;
}
// Check if current page is a designated SPA page
if (self::is_spa_page()) {
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
if (file_exists($spa_template)) {
return $spa_template;
}
}
// For spa_mode = 'full', override WooCommerce pages
if ($spa_mode === 'full') {
// Override all WooCommerce pages
if (is_woocommerce() || is_product() || is_cart() || is_checkout() || is_account_page()) {
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
if (file_exists($spa_template)) {
return $spa_template;
}
}
}
// For spa_mode = 'checkout_only'
if ($spa_mode === 'checkout_only') {
if (is_checkout() || is_order_received_page() || is_account_page() || is_cart()) {
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
if (file_exists($spa_template)) {
return $spa_template;
}
}
}
return $template;
}
/**
* Start SPA wrapper
*/
public static function start_spa_wrapper()
{
// Check if we should use SPA
if (!self::should_use_spa()) {
return;
}
// Determine page type
$page_type = 'shop';
$data_attrs = 'data-page="shop"';
if (is_product()) {
$page_type = 'product';
global $post;
$data_attrs = 'data-page="product" data-product-id="' . esc_attr($post->ID) . '"';
} elseif (is_cart()) {
$page_type = 'cart';
$data_attrs = 'data-page="cart"';
} elseif (is_checkout()) {
$page_type = 'checkout';
$data_attrs = 'data-page="checkout"';
} elseif (is_account_page()) {
$page_type = 'account';
$data_attrs = 'data-page="account"';
}
// Output SPA mount point
echo '<div id="woonoow-customer-app" ' . $data_attrs . '>';
echo '<div class="woonoow-loading">';
echo '<p>' . esc_html__('Loading...', 'woonoow') . '</p>';
echo '</div>';
echo '</div>';
// Hide WooCommerce content
echo '<div style="display: none;">';
}
/**
* End SPA wrapper
*/
public static function end_spa_wrapper()
{
if (!self::should_use_spa()) {
return;
}
// Close hidden wrapper
echo '</div>';
}
/**
* Check if we should use SPA
*/
private static function should_use_spa()
{
// Check spa_mode from appearance settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// Only use SPA when mode is 'full'
if ($spa_mode !== 'full') {
return false;
}
// For full SPA mode, use SPA on WooCommerce pages
if (is_shop() || is_product() || is_cart() || is_checkout() || is_account_page()) {
return true;
}
return false;
}
/**
* Remove theme header when SPA is active
*/
public static function remove_theme_header()
{
if (self::should_remove_theme_elements()) {
remove_all_actions('wp_head');
// Re-add essential WordPress head actions
add_action('wp_head', 'wp_enqueue_scripts', 1);
add_action('wp_head', 'wp_print_styles', 8);
add_action('wp_head', 'wp_print_head_scripts', 9);
add_action('wp_head', 'wp_resource_hints', 2);
add_action('wp_head', 'wp_site_icon', 99);
}
}
/**
* Remove theme footer when SPA is active
*/
public static function remove_theme_footer()
{
if (self::should_remove_theme_elements()) {
remove_all_actions('wp_footer');
// Re-add essential WordPress footer actions
add_action('wp_footer', 'wp_print_footer_scripts', 20);
}
}
/**
* Check if current page is the designated SPA page
*/
private static function is_spa_page()
{
global $post;
// Get SPA settings from appearance
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// Only check if spa_mode is 'full' and SPA page is configured
if ($spa_mode !== 'full' || !$spa_page_id) {
return false;
}
// Check if current page is the SPA page
if ($post && $post->ID == $spa_page_id) {
return true;
}
// Check if SPA page is set as WordPress frontpage and we're on frontpage
$frontpage_id = (int) get_option('page_on_front');
if ($frontpage_id && $frontpage_id === (int) $spa_page_id && is_front_page()) {
return true;
}
return false;
}
/**
* Check if we should remove theme header/footer
*/
private static function should_remove_theme_elements()
{
// Remove for designated SPA pages
if (self::is_spa_page()) {
return true;
}
// Check spa_mode from appearance settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// Check if we're on a WooCommerce page in full mode
if ($spa_mode === 'full') {
if (is_shop() || is_product() || is_cart() || is_checkout() || is_account_page() || is_woocommerce()) {
return true;
}
}
// When SPA is disabled, don't remove theme elements
if ($spa_mode === 'disabled') {
return false;
}
return false;
}
/**
* Override WooCommerce templates
*/
public static function override_template($template, $template_name, $template_path)
{
// Only override if SPA is enabled
if (!self::should_use_spa()) {
return $template;
}
// Templates to override
$override_templates = [
'archive-product.php',
'single-product.php',
'cart/cart.php',
'checkout/form-checkout.php',
];
// Check if this template should be overridden
foreach ($override_templates as $override) {
if (strpos($template_name, $override) !== false) {
// Return empty template (SPA will handle rendering)
return plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-wrapper.php';
}
}
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;
}
}