PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.8.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.8.0
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.5.2 All 199 releases
betterdocs / includes / FrontEnd / WooProductFAQ.php

WooProductFAQ.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.8.0, at includes/FrontEnd/WooProductFAQ.php

374 lines 13.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPDeveloper\BetterDocs\FrontEnd;
4
5 use WPDeveloper\BetterDocs\Utils\Views;
6 use WPDeveloper\BetterDocs\Utils\Helper;
7 use WPDeveloper\BetterDocs\Core\FAQBuilder;
8 use WPDeveloper\BetterDocs\REST\WooProductFAQ as WooFAQSettings;
9
10 /**
11 * Renders Product FAQ groups on single WooCommerce product pages.
12 *
13 * Placement, layout and schema come from the display settings saved by the
14 * WooCommerce → Display Settings tab. Each Product FAQ group declares which
15 * product categories and which individual products it appears on (set in the
16 * Create/Update FAQ Group screen). A product shows every group assigned to it
17 * directly, plus every group assigned to one of its product categories.
18 */
19 class WooProductFAQ {
20 /**
21 * @var Views
22 */
23 protected $views;
24
25 /**
26 * Resolved display settings.
27 *
28 * @var array
29 */
30 protected $settings = [];
31
32 /**
33 * Per-request cache of resolved group IDs, keyed by product ID.
34 *
35 * @var array
36 */
37 protected $resolved = [];
38
39 /**
40 * Per-request render guard, keyed by product ID. Shared across the
41 * automatic placement hooks and the FAQ block/Elementor widget
42 * (product_assignments mode) so the FAQ renders at most once per product
43 * even when both placement paths are active.
44 *
45 * @var array
46 */
47 protected static $rendered = [];
48
49 /**
50 * Layout being rendered for the current product, so icons() can emit the
51 * matching per-layout accordion icon (mirroring the FAQ shortcodes).
52 *
53 * @var string
54 */
55 protected $render_layout = 'layout-1';
56
57 public function __construct( Views $views ) {
58 $this->views = $views;
59 add_action( 'init', [ $this, 'init' ], 20 );
60 }
61
62 /**
63 * Wire the placement hooks once WooCommerce is loaded and the feature is on.
64 */
65 public function init() {
66 if ( ! class_exists( 'WooCommerce' ) ) {
67 return;
68 }
69
70 $this->settings = WooFAQSettings::get_display_settings();
71
72 if ( empty( $this->settings['enable'] ) ) {
73 return;
74 }
75
76 switch ( $this->settings['placement'] ) {
77 case 'before_summary':
78 add_action( 'woocommerce_before_single_product_summary', [ $this, 'render_hooked' ], 25 );
79 break;
80 case 'after_summary':
81 add_action( 'woocommerce_after_single_product_summary', [ $this, 'render_hooked' ], 15 );
82 break;
83 case 'product_tab':
84 default:
85 add_filter( 'woocommerce_product_tabs', [ $this, 'add_product_tab' ] );
86 break;
87 }
88 }
89
90 /**
91 * Product FAQ group term IDs to display for a product, in priority order
92 * (first non-empty wins): groups assigned to the product directly override
93 * groups inherited from the product's categories. A product with no matching
94 * group shows nothing. Returns a list of term IDs (may be empty).
95 *
96 * @param int $product_id
97 * @return int[]
98 */
99 public function get_group_ids_for_product( $product_id ) {
100 $product_id = (int) $product_id;
101
102 if ( isset( $this->resolved[ $product_id ] ) ) {
103 return $this->resolved[ $product_id ];
104 }
105
106 $product_cats = wp_get_post_terms( $product_id, 'product_cat', [ 'fields' => 'ids' ] );
107 $product_cats = is_wp_error( $product_cats ) ? [] : array_map( 'intval', $product_cats );
108
109 $groups = get_terms( [
110 'taxonomy' => 'betterdocs_product_faq_category',
111 'hide_empty' => false,
112 ] );
113
114 $direct = []; // groups assigned to this product directly
115 $inherited = []; // groups assigned via one of the product's categories
116 $all = []; // groups flagged to show on every product
117 if ( ! is_wp_error( $groups ) && ! empty( $groups ) ) {
118 // QA-014: prime term meta for every group in a single query so the
119 // per-group get_term_meta() lookups below read from cache instead of
120 // issuing O(N) queries (100+ on a 50-group store).
121 update_meta_cache( 'term', wp_list_pluck( $groups, 'term_id' ) );
122
123 foreach ( $groups as $group ) {
124 // Respect the group's enable/disable toggle (term meta 'status').
125 // '0' means the user disabled it in the builder, so it must not
126 // render on the product page (even if flagged "all products").
127 // An unset/empty status is treated as enabled — the default
128 // applied when a group is created.
129 if ( '0' === (string) get_term_meta( $group->term_id, 'status', true ) ) {
130 continue;
131 }
132
133 // Groups flagged to show on every product.
134 if ( get_term_meta( $group->term_id, FAQBuilder::GROUP_ALL_PRODUCTS_META, true ) ) {
135 $all[] = (int) $group->term_id;
136 continue;
137 }
138
139 $assigned_products = get_term_meta( $group->term_id, FAQBuilder::GROUP_PRODUCTS_META, true );
140 $assigned_products = is_array( $assigned_products ) ? array_map( 'intval', $assigned_products ) : [];
141
142 $assigned_cats = get_term_meta( $group->term_id, FAQBuilder::GROUP_PRODUCT_CATS_META, true );
143 $assigned_cats = is_array( $assigned_cats ) ? array_map( 'intval', $assigned_cats ) : [];
144
145 if ( in_array( $product_id, $assigned_products, true ) ) {
146 $direct[] = (int) $group->term_id;
147 } elseif ( array_intersect( $product_cats, $assigned_cats ) ) {
148 $inherited[] = (int) $group->term_id;
149 }
150 }
151 }
152
153 // Per-product assignment takes priority; fall back to category inheritance.
154 // "All products" groups always show, on top of whatever else matched.
155 $matched = ! empty( $direct ) ? $direct : $inherited;
156 $matched = array_merge( $matched, $all );
157
158 // Optional extension point for add-ons to amend the resolved groups.
159 $matched = apply_filters( 'betterdocs_woo_product_faq_group_ids', $matched, $product_id );
160
161 return $this->resolved[ $product_id ] = $this->sanitize_ids( $matched );
162 }
163
164 /**
165 * Whether any of the given FAQ groups contains at least one *published* FAQ.
166 *
167 * A group can be assigned to a product (directly or via its category) while
168 * every FAQ inside it is still a draft. The display query only pulls
169 * published FAQs, so in that case the tab/section would render with just its
170 * title and no content. Use this to suppress the tab/section entirely until
171 * there is something published to show.
172 *
173 * @param int[] $group_ids
174 * @return bool
175 */
176 protected function groups_have_published_faqs( $group_ids ) {
177 $group_ids = $this->sanitize_ids( $group_ids );
178 if ( empty( $group_ids ) ) {
179 return false;
180 }
181
182 // Group term IDs are globally unique across both FAQ taxonomies; an OR
183 // query matches whichever taxonomy each ID belongs to (mismatched IDs are
184 // simply ignored).
185 $query = new \WP_Query( [
186 'post_type' => 'betterdocs_faq',
187 'post_status' => 'publish',
188 'posts_per_page' => 1,
189 'fields' => 'ids',
190 'no_found_rows' => true,
191 'tax_query' => [
192 'relation' => 'OR',
193 [
194 'taxonomy' => 'betterdocs_faq_category',
195 'field' => 'term_id',
196 'terms' => $group_ids,
197 ],
198 [
199 'taxonomy' => 'betterdocs_product_faq_category',
200 'field' => 'term_id',
201 'terms' => $group_ids,
202 ],
203 ],
204 ] );
205
206 return $query->have_posts();
207 }
208
209 /**
210 * Normalize a list of group IDs to unique positive integers.
211 *
212 * @param mixed $ids
213 * @return int[]
214 */
215 protected function sanitize_ids( $ids ) {
216 $ids = array_map( 'intval', (array) $ids );
217 $ids = array_filter( $ids, function ( $id ) {
218 return $id > 0;
219 } );
220 return array_values( array_unique( $ids ) );
221 }
222
223 /**
224 * Add the FAQ tab to the single product tabs.
225 *
226 * @param array $tabs
227 * @return array
228 */
229 public function add_product_tab( $tabs ) {
230 global $product;
231 $product_id = $product ? $product->get_id() : 0;
232
233 $group_ids = $this->get_group_ids_for_product( $product_id );
234 if ( empty( $group_ids ) || ! $this->groups_have_published_faqs( $group_ids ) ) {
235 return $tabs;
236 }
237
238 $tabs['betterdocs_product_faq'] = [
239 'title' => $this->settings['tab_title'],
240 'priority' => 50,
241 'callback' => [ $this, 'render_tab' ],
242 ];
243
244 return $tabs;
245 }
246
247 /**
248 * Tab callback.
249 */
250 public function render_tab() {
251 global $product;
252 $this->render( $product ? $product->get_id() : 0 );
253 }
254
255 /**
256 * Action callback for the before/after summary placements.
257 */
258 public function render_hooked() {
259 global $product;
260 $this->render( $product ? $product->get_id() : 0 );
261 }
262
263 /**
264 * Thin wrapper used by the automatic placement hooks (tab / before / after
265 * summary). Delegates to the guarded renderer.
266 *
267 * @param int $product_id
268 */
269 public function render( $product_id ) {
270 $this->render_for_product( $product_id );
271 }
272
273 /**
274 * Render the product FAQ markup, at most once per product per request.
275 *
276 * Shared entry point for both the automatic placement hooks and the FAQ
277 * block / Elementor widget (product_assignments mode). The render guard
278 * means whichever path runs first wins; subsequent calls for the same
279 * product are no-ops, preventing duplicate output.
280 *
281 * @param int $product_id
282 * @param string|null $layout Layout override (layout-1..3). Null uses the
283 * layout from display settings.
284 */
285 public function render_for_product( $product_id, $layout = null ) {
286 $product_id = (int) $product_id;
287
288 if ( ! empty( self::$rendered[ $product_id ] ) ) {
289 return;
290 }
291
292 $group_ids = $this->get_group_ids_for_product( $product_id );
293 if ( empty( $group_ids ) || ! $this->groups_have_published_faqs( $group_ids ) ) {
294 return;
295 }
296
297 self::$rendered[ $product_id ] = true;
298
299 // $this->settings is populated by init() when the feature is enabled;
300 // fall back to the saved settings when a block/widget renders while
301 // automatic placement is off.
302 $settings = ! empty( $this->settings ) ? $this->settings : WooFAQSettings::get_display_settings();
303
304 if ( $layout === null ) {
305 $layout = $settings['layout'];
306 }
307
308 $allowed = [ 'layout-1', 'layout-2', 'layout-3' ];
309 $layout = sanitize_key( (string) $layout );
310 if ( ! in_array( $layout, $allowed, true ) ) {
311 $layout = 'layout-1';
312 }
313
314 wp_enqueue_style( 'betterdocs-faq' );
315 wp_enqueue_script( 'betterdocs-faq' );
316
317 // Render the accordion icon that matches the chosen layout, in the same
318 // position the layout's shortcode uses: Classic places it before the
319 // question, Modern/Abstract after.
320 $this->render_layout = $layout;
321 $icon_hook = ( 'layout-2' === $layout ) ? 'betterdocs_faq_post_before' : 'betterdocs_faq_post_after';
322 add_action( $icon_hook, [ $this, 'icons' ] );
323
324 $this->views->get( 'woocommerce/product-faq', [
325 'group_ids' => $group_ids,
326 'layout' => $layout,
327 'enable_schema' => ! empty( $settings['enable_schema'] ) && ! Helper::seo_plugin_outputs_faq_schema(),
328 ] );
329
330 remove_action( $icon_hook, [ $this, 'icons' ] );
331 }
332
333 /**
334 * Remove the automatic placement hooks. Called by the FAQ block / Elementor
335 * widget when they render the product FAQ themselves, so the same FAQ is not
336 * also injected into the product tab / summary.
337 */
338 public function suppress_auto_placement() {
339 remove_action( 'woocommerce_before_single_product_summary', [ $this, 'render_hooked' ], 25 );
340 remove_action( 'woocommerce_after_single_product_summary', [ $this, 'render_hooked' ], 15 );
341 remove_filter( 'woocommerce_product_tabs', [ $this, 'add_product_tab' ] );
342 }
343
344 /**
345 * Accordion plus/minus icons, mirroring the FAQ shortcode markup so the
346 * shared faq.js toggle works.
347 *
348 * @param bool $faq_toggle
349 */
350 public function icons( $faq_toggle ) {
351 switch ( $this->render_layout ) {
352 case 'layout-2': // Classic — mirrors FaqClassic::icons().
353 $markup = '<div class="betterdocs-faq-post-icon-group">';
354 $markup .= '<svg class="betterdocs-faq-iconplus" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"' . ( $faq_toggle ? " style='display:none;'" : '' ) . '><path fill="#000000" d="M18 10h-4V6h-4v4H6v4h4v4h4v-4h4"></path></svg>';
355 $markup .= '<svg class="betterdocs-faq-iconminus" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"' . ( $faq_toggle ? " style='display:inline;'" : '' ) . '><path fill="#000000" d="M6 10h12v4H6z"></path></svg>';
356 $markup .= '</div>';
357 break;
358
359 case 'layout-3': // Abstract — mirrors FaqLayoutThree::icons().
360 $markup = '<svg class="betterdocs-faq-iconplus" width="21" height="20" viewBox="0 0 21 20"' . ( $faq_toggle ? " style='display:none;'" : '' ) . ' fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#clip0_8028_2975)"><path d="M5.5 7.5L10.5 12.5L15.5 7.5" stroke="#707E95" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></g><defs><clipPath id="clip0_8028_2975"><rect width="20" height="20" fill="white" transform="translate(0.5)"/></clipPath></defs></svg>';
361 $markup .= '<svg class="betterdocs-faq-iconminus" width="21" height="20" viewBox="0 0 21 20"' . ( $faq_toggle ? " style='display:inline;'" : '' ) . ' fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M15.5 12.5L10.5 7.5L5.5 12.5" stroke="#707E95" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
362 break;
363
364 case 'layout-1': // Modern — mirrors FaqList::icons() (default button colour).
365 default:
366 $markup = '<svg class="betterdocs-faq-iconminus" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"' . ( $faq_toggle ? " style='display:inline;'" : '' ) . ' stroke-width="2"><g fill="none" stroke="#528ffe" stroke-linecap="round" stroke-miterlimit="10" stroke-linejoin="round"><path d="M17 12H7"></path><circle cx="12" cy="12" r="11"></circle></g></svg>';
367 $markup .= '<svg class="betterdocs-faq-iconplus" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"' . ( $faq_toggle ? " style='display:none;'" : '' ) . '><g stroke-width="2" fill="none" stroke="#528ffe" stroke-linecap="square" stroke-miterlimit="10"><path d="M12 7v10M17 12H7"></path><circle cx="12" cy="12" r="11"></circle></g></svg>';
368 break;
369 }
370
371 echo $markup; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
372 }
373 }
374