| 1 |
<?php |
| 2 |
|
| 3 |
namespace WPDeveloper\BetterDocs\REST; |
| 4 |
|
| 5 |
use WP_Query; |
| 6 |
use WP_REST_Request; |
| 7 |
use WPDeveloper\BetterDocs\Core\BaseAPI; |
| 8 |
|
| 9 |
/** |
| 10 |
* REST endpoints backing the WooCommerce "Product FAQ" tab. |
| 11 |
* |
| 12 |
* Provides the option sources used by the Create/Update Product FAQ Group screen |
| 13 |
* (product categories + product search) and the Display Settings read/write. |
| 14 |
* Group assignments themselves are stored on each Product FAQ group term and |
| 15 |
* saved through the FAQ Builder create/update category endpoints. |
| 16 |
*/ |
| 17 |
class WooProductFAQ extends BaseAPI { |
| 18 |
/** |
| 19 |
* Option key holding the WooCommerce FAQ display settings. |
| 20 |
*/ |
| 21 |
const DISPLAY_OPTION = 'betterdocs_woo_faq_display'; |
| 22 |
|
| 23 |
/** |
| 24 |
* Default display settings. Shared with the frontend renderer (Step 7) so |
| 25 |
* saved + unsaved sites behave identically. |
| 26 |
* |
| 27 |
* @return array |
| 28 |
*/ |
| 29 |
public static function display_defaults() { |
| 30 |
return [ |
| 31 |
'enable' => true, |
| 32 |
'placement' => 'product_tab', |
| 33 |
'tab_title' => __( 'FAQ', 'betterdocs' ), |
| 34 |
'layout' => 'layout-3', |
| 35 |
'enable_schema' => true, |
| 36 |
]; |
| 37 |
} |
| 38 |
|
| 39 |
/** |
| 40 |
* Saved display settings merged over the defaults. |
| 41 |
* |
| 42 |
* @return array |
| 43 |
*/ |
| 44 |
public static function get_display_settings() { |
| 45 |
$saved = get_option( self::DISPLAY_OPTION, [] ); |
| 46 |
if ( ! is_array( $saved ) ) { |
| 47 |
$saved = []; |
| 48 |
} |
| 49 |
return wp_parse_args( $saved, self::display_defaults() ); |
| 50 |
} |
| 51 |
|
| 52 |
/** |
| 53 |
* Admin-only: assignments are part of the FAQ management surface. |
| 54 |
*/ |
| 55 |
public function permission_check() { |
| 56 |
return current_user_can( 'edit_others_posts' ); |
| 57 |
} |
| 58 |
|
| 59 |
public function register() { |
| 60 |
// The whole tab is WooCommerce-only — no point exposing the routes |
| 61 |
// when WooCommerce (and therefore product_cat) is absent. |
| 62 |
if ( ! class_exists( 'WooCommerce' ) ) { |
| 63 |
return; |
| 64 |
} |
| 65 |
|
| 66 |
$this->get( 'woo-product-faq/product-categories', [ $this, 'get_product_categories' ], [ |
| 67 |
'search' => [ 'type' => 'string', 'required' => false, 'sanitize_callback' => 'sanitize_text_field' ], |
| 68 |
] ); |
| 69 |
|
| 70 |
// Note: deliberately NOT 'product-search' — Pro registers that path. Use |
| 71 |
// a distinct route so the two never collide while Pro is active. |
| 72 |
$this->get( 'woo-product-faq/assignable-products', [ $this, 'search_products' ], [ |
| 73 |
'q' => [ 'type' => 'string', 'required' => false, 'sanitize_callback' => 'sanitize_text_field' ], |
| 74 |
'include' => [ 'type' => 'string', 'required' => false, 'sanitize_callback' => 'sanitize_text_field' ], |
| 75 |
] ); |
| 76 |
|
| 77 |
$this->get( 'woo-product-faq/display-settings', [ $this, 'get_display_settings_route' ] ); |
| 78 |
|
| 79 |
$this->post( 'woo-product-faq/display-settings', [ $this, 'save_display_settings' ], [ |
| 80 |
'settings' => [ 'type' => 'object', 'required' => true ], |
| 81 |
] ); |
| 82 |
} |
| 83 |
|
| 84 |
/** |
| 85 |
* GET /woo-product-faq/product-categories |
| 86 |
* |
| 87 |
* WooCommerce product categories as selector options ({ id, name }) for the |
| 88 |
* Create/Update Product FAQ Group screen. Optional `search` narrows by name. |
| 89 |
*/ |
| 90 |
public function get_product_categories( WP_REST_Request $request ) { |
| 91 |
$search = (string) $request->get_param( 'search' ); |
| 92 |
|
| 93 |
$args = [ |
| 94 |
'taxonomy' => 'product_cat', |
| 95 |
'hide_empty' => false, |
| 96 |
'orderby' => 'name', |
| 97 |
'order' => 'ASC', |
| 98 |
'number' => 100, |
| 99 |
]; |
| 100 |
if ( '' !== $search ) { |
| 101 |
$args['search'] = $search; |
| 102 |
} |
| 103 |
|
| 104 |
// Drop WooCommerce's default "Uncategorized" product category — it is a |
| 105 |
// placeholder, not a real category to assign a FAQ group to. |
| 106 |
$default_cat = (int) get_option( 'default_product_cat', 0 ); |
| 107 |
if ( $default_cat ) { |
| 108 |
$args['exclude'] = [ $default_cat ]; |
| 109 |
} |
| 110 |
|
| 111 |
$terms = get_terms( $args ); |
| 112 |
if ( is_wp_error( $terms ) ) { |
| 113 |
$terms = []; |
| 114 |
} |
| 115 |
|
| 116 |
$data = array_values( array_map( function ( $term ) { |
| 117 |
return [ |
| 118 |
'id' => (int) $term->term_id, |
| 119 |
'name' => $term->name, |
| 120 |
]; |
| 121 |
}, $terms ) ); |
| 122 |
|
| 123 |
return $this->success( $data ); |
| 124 |
} |
| 125 |
|
| 126 |
/** |
| 127 |
* GET /woo-product-faq/product-search |
| 128 |
* |
| 129 |
* Searchable product lookup for the Products selector. Pass `q` to search by |
| 130 |
* title, or `include` (comma-separated IDs) to resolve specific products to |
| 131 |
* { id, name } for prefilling the chips when editing a group. |
| 132 |
*/ |
| 133 |
public function search_products( WP_REST_Request $request ) { |
| 134 |
$include = array_filter( array_map( 'intval', explode( ',', (string) $request->get_param( 'include' ) ) ) ); |
| 135 |
|
| 136 |
$args = [ |
| 137 |
'post_type' => 'product', |
| 138 |
'post_status' => 'publish', |
| 139 |
'posts_per_page' => 20, |
| 140 |
'orderby' => 'title', |
| 141 |
'order' => 'ASC', |
| 142 |
'no_found_rows' => true, |
| 143 |
]; |
| 144 |
|
| 145 |
if ( ! empty( $include ) ) { |
| 146 |
$args['post__in'] = $include; |
| 147 |
$args['posts_per_page'] = count( $include ); |
| 148 |
$args['orderby'] = 'post__in'; |
| 149 |
} else { |
| 150 |
$args['s'] = (string) $request->get_param( 'q' ); |
| 151 |
} |
| 152 |
|
| 153 |
$query = new WP_Query( $args ); |
| 154 |
|
| 155 |
$results = array_map( function ( $product ) { |
| 156 |
return [ |
| 157 |
'id' => (int) $product->ID, |
| 158 |
'name' => $product->post_title, |
| 159 |
]; |
| 160 |
}, $query->posts ); |
| 161 |
|
| 162 |
return $this->success( $results ); |
| 163 |
} |
| 164 |
|
| 165 |
/** |
| 166 |
* GET /woo-product-faq/display-settings |
| 167 |
*/ |
| 168 |
public function get_display_settings_route( WP_REST_Request $request ) { |
| 169 |
return $this->success( self::get_display_settings() ); |
| 170 |
} |
| 171 |
|
| 172 |
/** |
| 173 |
* POST /woo-product-faq/display-settings |
| 174 |
* |
| 175 |
* Persist the WooCommerce FAQ display settings (sanitized against the known |
| 176 |
* keys; unknown keys are dropped). |
| 177 |
*/ |
| 178 |
public function save_display_settings( WP_REST_Request $request ) { |
| 179 |
$incoming = (array) $request->get_param( 'settings' ); |
| 180 |
$defaults = self::display_defaults(); |
| 181 |
|
| 182 |
$allowed_placements = [ 'product_tab', 'after_summary', 'before_summary' ]; |
| 183 |
$allowed_layouts = [ 'layout-1', 'layout-2', 'layout-3' ]; |
| 184 |
|
| 185 |
$placement = isset( $incoming['placement'] ) ? sanitize_key( $incoming['placement'] ) : $defaults['placement']; |
| 186 |
if ( ! in_array( $placement, $allowed_placements, true ) ) { |
| 187 |
$placement = $defaults['placement']; |
| 188 |
} |
| 189 |
|
| 190 |
$layout = isset( $incoming['layout'] ) ? sanitize_key( $incoming['layout'] ) : $defaults['layout']; |
| 191 |
if ( ! in_array( $layout, $allowed_layouts, true ) ) { |
| 192 |
$layout = $defaults['layout']; |
| 193 |
} |
| 194 |
|
| 195 |
$tab_title = isset( $incoming['tab_title'] ) ? sanitize_text_field( $incoming['tab_title'] ) : $defaults['tab_title']; |
| 196 |
if ( '' === $tab_title ) { |
| 197 |
$tab_title = $defaults['tab_title']; |
| 198 |
} |
| 199 |
|
| 200 |
$settings = [ |
| 201 |
'enable' => ! empty( $incoming['enable'] ), |
| 202 |
'placement' => $placement, |
| 203 |
'tab_title' => $tab_title, |
| 204 |
'layout' => $layout, |
| 205 |
'enable_schema' => ! empty( $incoming['enable_schema'] ), |
| 206 |
]; |
| 207 |
|
| 208 |
update_option( self::DISPLAY_OPTION, $settings ); |
| 209 |
|
| 210 |
return $this->success( $settings ); |
| 211 |
} |
| 212 |
} |
| 213 |
|