| @@ -12,8 +12,9 @@ | ||
| 12 | 12 | use FluentCart\App\Modules\Data\ProductQuery; |
| 13 | 13 | use FluentCart\App\Services\Renderer\ProductListRenderer; |
| 14 | 14 | use FluentCart\App\Services\Renderer\ProductRenderer; |
| 15 | 15 | use FluentCart\App\Services\Renderer\ShopAppRenderer; |
| 16 | +use FluentCart\App\Services\Schema\ProductSchema; | |
| 16 | 17 | use FluentCart\App\Services\TemplateService; |
| 17 | 18 | use FluentCart\Framework\Support\Arr; |
| 18 | 19 | |
| 19 | 20 | class TemplateActions |
| @@ -22,8 +23,16 @@ | ||
| 22 | 23 | { |
| 23 | 24 | add_action('fluent_cart/template/main_content', [$this, 'renderMainContent']); |
| 24 | 25 | add_action('fluent_cart/template/product_archive', [$this, 'renderProductArchive']); |
| 25 | 26 | add_action('fluent_cart/product/render_product_header', [$this, 'renderProductHeader']); |
| 27 | + // Block themes render reviews from the product template, so the | |
| 28 | + // built-in listener is not attached there — the hook still fires for | |
| 29 | + // third-party listeners. Safe to decide here: the theme is resolved | |
| 30 | + // at after_setup_theme, before init runs this. | |
| 31 | + if (!function_exists('wp_is_block_theme') || !wp_is_block_theme()) { | |
| 32 | + add_action('fluent_cart/product/after_product_content', [$this, 'renderProductReviews']); | |
| 33 | + } | |
| 34 | + add_action('wp_head', [$this, 'renderProductJsonLd']); | |
| 26 | 35 | |
| 27 | 36 | // Universal hook for after_product_content — fires on both classic and block themes |
| 28 | 37 | // Block themes render <!-- wp:post-content --> which runs the_content filter |
| 29 | 38 | add_filter('the_content', function ($content) { |
| @@ -46,9 +55,9 @@ | ||
| 46 | 55 | } |
| 47 | 56 | |
| 48 | 57 | return $content; |
| 49 | 58 | }, 999); |
| 50 | - | |
| 59 | + | |
| 51 | 60 | add_shortcode('fluent_cart_product_header', function ($atts = []) { |
| 52 | 61 | $atts = shortcode_atts([ |
| 53 | 62 | 'id' => 0, |
| 54 | 63 | ], $atts); |
| @@ -94,9 +103,25 @@ | ||
| 94 | 103 | if (!$product || !$product->detail) { |
| 95 | 104 | return __('Product not found', 'fluent-cart'); |
| 96 | 105 | } |
| 97 | 106 | |
| 107 | + // On the product page this shortcode is the block theme's | |
| 108 | + // equivalent of what filterSingleProductContent appends for | |
| 109 | + // classic themes, so it answers to the same setting and filter. | |
| 110 | + // Placed anywhere else it is an explicit choice and still renders. | |
| 111 | + if (is_singular(FluentProducts::CPT_NAME)) { | |
| 112 | + $showRelevant = (new StoreSettings())->get('show_relevant_product_in_single_page') == 'yes'; | |
| 113 | + $showRelevant = apply_filters( | |
| 114 | + 'fluent_cart/single_product_page/show_relevant_products', | |
| 115 | + $showRelevant, | |
| 116 | + $productId | |
| 117 | + ); | |
| 98 | 118 | |
| 119 | + if (!$showRelevant) { | |
| 120 | + return ''; | |
| 121 | + } | |
| 122 | + } | |
| 123 | + | |
| 99 | 124 | $products = ShopResource::getSimilarProducts($productId, false); |
| 100 | 125 | if (empty($products)) { |
| 101 | 126 | return ''; |
| 102 | 127 | } |
| @@ -104,9 +129,10 @@ | ||
| 104 | 129 | ob_start(); |
| 105 | 130 | (new ProductListRenderer( |
| 106 | 131 | $products, |
| 107 | 132 | __('Related Products', 'fluent-cart'), |
| 108 | - 'fct-similar-product-list-container' | |
| 133 | + 'fct-similar-product-list-container', | |
| 134 | + ['rating_context' => 'relevant'] | |
| 109 | 135 | ))->render(); |
| 110 | 136 | |
| 111 | 137 | $content = ob_get_clean(); |
| 112 | 138 | |
| @@ -195,9 +221,12 @@ | ||
| 195 | 221 | ]; |
| 196 | 222 | } |
| 197 | 223 | |
| 198 | 224 | $products = $productsQuery->get(); |
| 199 | - (new ShopAppRenderer($products, [ | |
| 225 | + (new ShopAppRenderer([ | |
| 226 | + 'products' => $products, | |
| 227 | + 'total' => $products->total(), | |
| 228 | + ], [ | |
| 200 | 229 | 'default_filters' => $productsQuery->getDefaultFilters(), |
| 201 | 230 | 'custom_filters' => [ |
| 202 | 231 | 'taxonomies' => [ |
| 203 | 232 | $taxFilters |
| @@ -280,9 +309,10 @@ | ||
| 280 | 309 | ob_start(); |
| 281 | 310 | (new ProductListRenderer( |
| 282 | 311 | $products, |
| 283 | 312 | __('Related Products', 'fluent-cart'), |
| 284 | - 'fct-similar-product-list-container' | |
| 313 | + 'fct-similar-product-list-container', | |
| 314 | + ['rating_context' => 'relevant'] | |
| 285 | 315 | ))->render(); |
| 286 | 316 | |
| 287 | 317 | $relevantProducts = ob_get_clean(); |
| 288 | 318 | $content .= $relevantProducts; |
| @@ -329,7 +359,59 @@ | ||
| 329 | 359 | ], '', $content); |
| 330 | 360 | |
| 331 | 361 | // Remove extra whitespace between tags to clean up the output |
| 332 | 362 | return preg_replace('/>\s+</', '><', $cleaned); |
| 363 | + } | |
| 364 | + | |
| 365 | + /** | |
| 366 | + * Whether the product page should carry its review section at all. | |
| 367 | + * | |
| 368 | + * Sits beside the relevant-products switch in Product Page settings and | |
| 369 | + * works the same way, so one control governs the section wherever it is | |
| 370 | + * rendered from — a review block, or the append below. | |
| 371 | + * | |
| 372 | + * @param int $postId | |
| 373 | + * @return bool | |
| 374 | + */ | |
| 375 | + public static function shouldShowReviewsOnProductPage($postId): bool | |
| 376 | + { | |
| 377 | + $show = (new StoreSettings())->get('show_reviews_in_single_page', 'yes') == 'yes'; | |
| 378 | + | |
| 379 | + return (bool) apply_filters('fluent_cart/single_product_page/show_reviews', $show, $postId); | |
| 380 | + } | |
| 381 | + | |
| 382 | + private function isReviewModuleAvailable() | |
| 383 | + { | |
| 384 | + return \FluentCart\Api\ModuleSettings::isActive('reviews') | |
| 385 | + && class_exists(\FluentCart\App\Services\ProductReviewService::class); | |
| 386 | + } | |
| 387 | + | |
| 388 | + public function renderProductReviews($postId) | |
| 389 | + { | |
| 390 | + if (!$postId || !$this->isReviewModuleAvailable()) { | |
| 391 | + return; | |
| 392 | + } | |
| 393 | + | |
| 394 | + $renderer = new \FluentCart\App\Services\Renderer\ProductReviewRenderer($postId); | |
| 395 | + $renderer->render(); | |
| 396 | + $renderer->renderForm(); | |
| 397 | + } | |
| 398 | + | |
| 399 | + public function renderProductJsonLd() | |
| 400 | + { | |
| 401 | + if (!is_singular(\FluentCart\App\CPT\FluentProducts::CPT_NAME)) { | |
| 402 | + return; | |
| 403 | + } | |
| 404 | + | |
| 405 | + $postId = get_the_ID(); | |
| 406 | + if (!$postId) { | |
| 407 | + return; | |
| 408 | + } | |
| 409 | + | |
| 410 | + // The node itself — product, offers, aggregate rating, the reviews | |
| 411 | + // the page shows — is ProductSchema's; this is only the page gate. | |
| 412 | + // The reviews module gate lives there too, on the review parts | |
| 413 | + // alone: a product is for sale whether or not it takes reviews. | |
| 414 | + ProductSchema::render($postId); | |
| 333 | 415 | } |
| 334 | 416 | |
| 335 | 417 | } |