views = $views; add_action( 'init', [ $this, 'init' ], 20 ); } /** * Wire the placement hooks once WooCommerce is loaded and the feature is on. */ public function init() { if ( ! class_exists( 'WooCommerce' ) ) { return; } $this->settings = WooFAQSettings::get_display_settings(); if ( empty( $this->settings['enable'] ) ) { return; } switch ( $this->settings['placement'] ) { case 'before_summary': add_action( 'woocommerce_before_single_product_summary', [ $this, 'render_hooked' ], 25 ); break; case 'after_summary': add_action( 'woocommerce_after_single_product_summary', [ $this, 'render_hooked' ], 15 ); break; case 'product_tab': default: add_filter( 'woocommerce_product_tabs', [ $this, 'add_product_tab' ] ); break; } } /** * Product FAQ group term IDs to display for a product, in priority order * (first non-empty wins): groups assigned to the product directly override * groups inherited from the product's categories. A product with no matching * group shows nothing. Returns a list of term IDs (may be empty). * * @param int $product_id * @return int[] */ public function get_group_ids_for_product( $product_id ) { $product_id = (int) $product_id; if ( isset( $this->resolved[ $product_id ] ) ) { return $this->resolved[ $product_id ]; } $product_cats = wp_get_post_terms( $product_id, 'product_cat', [ 'fields' => 'ids' ] ); $product_cats = is_wp_error( $product_cats ) ? [] : array_map( 'intval', $product_cats ); $groups = get_terms( [ 'taxonomy' => 'betterdocs_product_faq_category', 'hide_empty' => false, ] ); $direct = []; // groups assigned to this product directly $inherited = []; // groups assigned via one of the product's categories $all = []; // groups flagged to show on every product if ( ! is_wp_error( $groups ) && ! empty( $groups ) ) { // QA-014: prime term meta for every group in a single query so the // per-group get_term_meta() lookups below read from cache instead of // issuing O(N) queries (100+ on a 50-group store). update_meta_cache( 'term', wp_list_pluck( $groups, 'term_id' ) ); foreach ( $groups as $group ) { // Respect the group's enable/disable toggle (term meta 'status'). // '0' means the user disabled it in the builder, so it must not // render on the product page (even if flagged "all products"). // An unset/empty status is treated as enabled — the default // applied when a group is created. if ( '0' === (string) get_term_meta( $group->term_id, 'status', true ) ) { continue; } // Groups flagged to show on every product. if ( get_term_meta( $group->term_id, FAQBuilder::GROUP_ALL_PRODUCTS_META, true ) ) { $all[] = (int) $group->term_id; continue; } $assigned_products = get_term_meta( $group->term_id, FAQBuilder::GROUP_PRODUCTS_META, true ); $assigned_products = is_array( $assigned_products ) ? array_map( 'intval', $assigned_products ) : []; $assigned_cats = get_term_meta( $group->term_id, FAQBuilder::GROUP_PRODUCT_CATS_META, true ); $assigned_cats = is_array( $assigned_cats ) ? array_map( 'intval', $assigned_cats ) : []; if ( in_array( $product_id, $assigned_products, true ) ) { $direct[] = (int) $group->term_id; } elseif ( array_intersect( $product_cats, $assigned_cats ) ) { $inherited[] = (int) $group->term_id; } } } // Per-product assignment takes priority; fall back to category inheritance. // "All products" groups always show, on top of whatever else matched. $matched = ! empty( $direct ) ? $direct : $inherited; $matched = array_merge( $matched, $all ); // Optional extension point for add-ons to amend the resolved groups. $matched = apply_filters( 'betterdocs_woo_product_faq_group_ids', $matched, $product_id ); return $this->resolved[ $product_id ] = $this->sanitize_ids( $matched ); } /** * Whether any of the given FAQ groups contains at least one *published* FAQ. * * A group can be assigned to a product (directly or via its category) while * every FAQ inside it is still a draft. The display query only pulls * published FAQs, so in that case the tab/section would render with just its * title and no content. Use this to suppress the tab/section entirely until * there is something published to show. * * @param int[] $group_ids * @return bool */ protected function groups_have_published_faqs( $group_ids ) { $group_ids = $this->sanitize_ids( $group_ids ); if ( empty( $group_ids ) ) { return false; } // Group term IDs are globally unique across both FAQ taxonomies; an OR // query matches whichever taxonomy each ID belongs to (mismatched IDs are // simply ignored). $query = new \WP_Query( [ 'post_type' => 'betterdocs_faq', 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'tax_query' => [ 'relation' => 'OR', [ 'taxonomy' => 'betterdocs_faq_category', 'field' => 'term_id', 'terms' => $group_ids, ], [ 'taxonomy' => 'betterdocs_product_faq_category', 'field' => 'term_id', 'terms' => $group_ids, ], ], ] ); return $query->have_posts(); } /** * Normalize a list of group IDs to unique positive integers. * * @param mixed $ids * @return int[] */ protected function sanitize_ids( $ids ) { $ids = array_map( 'intval', (array) $ids ); $ids = array_filter( $ids, function ( $id ) { return $id > 0; } ); return array_values( array_unique( $ids ) ); } /** * Add the FAQ tab to the single product tabs. * * @param array $tabs * @return array */ public function add_product_tab( $tabs ) { global $product; $product_id = $product ? $product->get_id() : 0; $group_ids = $this->get_group_ids_for_product( $product_id ); if ( empty( $group_ids ) || ! $this->groups_have_published_faqs( $group_ids ) ) { return $tabs; } $tabs['betterdocs_product_faq'] = [ 'title' => $this->settings['tab_title'], 'priority' => 50, 'callback' => [ $this, 'render_tab' ], ]; return $tabs; } /** * Tab callback. */ public function render_tab() { global $product; $this->render( $product ? $product->get_id() : 0 ); } /** * Action callback for the before/after summary placements. */ public function render_hooked() { global $product; $this->render( $product ? $product->get_id() : 0 ); } /** * Thin wrapper used by the automatic placement hooks (tab / before / after * summary). Delegates to the guarded renderer. * * @param int $product_id */ public function render( $product_id ) { $this->render_for_product( $product_id ); } /** * Render the product FAQ markup, at most once per product per request. * * Shared entry point for both the automatic placement hooks and the FAQ * block / Elementor widget (product_assignments mode). The render guard * means whichever path runs first wins; subsequent calls for the same * product are no-ops, preventing duplicate output. * * @param int $product_id * @param string|null $layout Layout override (layout-1..3). Null uses the * layout from display settings. */ public function render_for_product( $product_id, $layout = null ) { $product_id = (int) $product_id; if ( ! empty( self::$rendered[ $product_id ] ) ) { return; } $group_ids = $this->get_group_ids_for_product( $product_id ); if ( empty( $group_ids ) || ! $this->groups_have_published_faqs( $group_ids ) ) { return; } self::$rendered[ $product_id ] = true; // $this->settings is populated by init() when the feature is enabled; // fall back to the saved settings when a block/widget renders while // automatic placement is off. $settings = ! empty( $this->settings ) ? $this->settings : WooFAQSettings::get_display_settings(); if ( $layout === null ) { $layout = $settings['layout']; } $allowed = [ 'layout-1', 'layout-2', 'layout-3' ]; $layout = sanitize_key( (string) $layout ); if ( ! in_array( $layout, $allowed, true ) ) { $layout = 'layout-1'; } wp_enqueue_style( 'betterdocs-faq' ); wp_enqueue_script( 'betterdocs-faq' ); // Render the accordion icon that matches the chosen layout, in the same // position the layout's shortcode uses: Classic places it before the // question, Modern/Abstract after. $this->render_layout = $layout; $icon_hook = ( 'layout-2' === $layout ) ? 'betterdocs_faq_post_before' : 'betterdocs_faq_post_after'; add_action( $icon_hook, [ $this, 'icons' ] ); $this->views->get( 'woocommerce/product-faq', [ 'group_ids' => $group_ids, 'layout' => $layout, 'enable_schema' => ! empty( $settings['enable_schema'] ) && ! Helper::seo_plugin_outputs_faq_schema(), ] ); remove_action( $icon_hook, [ $this, 'icons' ] ); } /** * Remove the automatic placement hooks. Called by the FAQ block / Elementor * widget when they render the product FAQ themselves, so the same FAQ is not * also injected into the product tab / summary. */ public function suppress_auto_placement() { remove_action( 'woocommerce_before_single_product_summary', [ $this, 'render_hooked' ], 25 ); remove_action( 'woocommerce_after_single_product_summary', [ $this, 'render_hooked' ], 15 ); remove_filter( 'woocommerce_product_tabs', [ $this, 'add_product_tab' ] ); } /** * Accordion plus/minus icons, mirroring the FAQ shortcode markup so the * shared faq.js toggle works. * * @param bool $faq_toggle */ public function icons( $faq_toggle ) { switch ( $this->render_layout ) { case 'layout-2': // Classic — mirrors FaqClassic::icons(). $markup = '
'; $markup .= ''; $markup .= ''; $markup .= '
'; break; case 'layout-3': // Abstract — mirrors FaqLayoutThree::icons(). $markup = ''; $markup .= ''; break; case 'layout-1': // Modern — mirrors FaqList::icons() (default button colour). default: $markup = ''; $markup .= ''; break; } echo $markup; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } }