PluginProbe
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder / 51.1.84
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder v51.1.84
51.1.86 51.1.84 51.1.85 51.1.83 51.1.82 51.1.81 51.1.79 51.1.78 51.1.77 51.1.76 51.1.74 51.1.75 51.1.65 51.1.64 51.1.63 trunk 51.1.14 51.1.2 51.1.35 51.1.36 51.1.37 51.1.38 51.1.39 51.1.44 51.1.45 All 40 releases
king-addons / includes / extensions / Woo_Builder / Woo_Builder.php

Woo_Builder.php in King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder 51.1.84, at includes/extensions/Woo_Builder/Woo_Builder.php

1,919 lines 69.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WooCommerce Builder feature.
4 *
5 * @package King_Addons
6 */
7
8 namespace King_Addons;
9
10 use Elementor\Controls_Manager;
11 use Elementor\Plugin as Elementor_Plugin;
12 use Elementor\Repeater;
13 use King_Addons\Woo_Builder\Context as Woo_Context;
14 use King_Addons\Woo_Builder\Document as Woo_Document;
15 use King_Addons\Woo_Builder\My_Account_Manager;
16 use WP_Post;
17 use WP_Query;
18
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Registers and renders Elementor-based WooCommerce templates.
25 */
26 class Woo_Builder
27 {
28 /**
29 * Current template IDs keyed by context.
30 *
31 * @var array<string,int>
32 */
33 private array $current_templates = [];
34
35 /**
36 * Active context for body classes.
37 *
38 * @var string|null
39 */
40 private ?string $active_context = null;
41
42 /**
43 * Flag to ensure default blocks hook only once.
44 *
45 * @var bool
46 */
47 private bool $default_blocks_registered = false;
48
49 /**
50 * Constructor.
51 */
52 public function __construct()
53 {
54 if (!class_exists('WooCommerce') || !did_action('elementor/loaded')) {
55 return;
56 }
57
58 require_once KING_ADDONS_PATH . 'includes/helpers/Woo_Builder/Context.php';
59
60 // The ACF extra fields widgets: validation and saving run in the
61 // ?wc-ajax=checkout request and in the account form's POST, where no
62 // widget renders; display runs on order screens and in emails.
63 require_once KING_ADDONS_PATH . 'includes/helpers/Woo_Builder/ACF_Fields.php';
64 Woo_Builder\ACF_Fields::register();
65
66 // Widget AJAX endpoints have to be hooked here rather than from the
67 // widget classes: admin-ajax.php never builds Elementor widgets, so a
68 // handler added in a widget constructor is missing exactly when the
69 // request needs it.
70 require_once KING_ADDONS_PATH . 'includes/widgets/Woo_Product_Tabs/Woo_Product_Tabs_Ajax.php';
71 Woo_Product_Tabs_Ajax::register();
72
73 add_action('wp_ajax_ka_products_grid', [$this, 'ajax_products_grid']);
74 add_action('wp_ajax_nopriv_ka_products_grid', [$this, 'ajax_products_grid']);
75
76 add_action('wp_ajax_ka_cart_update', [$this, 'ajax_cart_update']);
77 add_action('wp_ajax_nopriv_ka_cart_update', [$this, 'ajax_cart_update']);
78 add_action('wp_ajax_ka_cart_coupon', [$this, 'ajax_cart_coupon']);
79 add_action('wp_ajax_nopriv_ka_cart_coupon', [$this, 'ajax_cart_coupon']);
80
81 // The order is created by ?wc-ajax=checkout, where no widget renders.
82 // These two forward to the checkout form widget, which reads the field
83 // configuration it stored in the session; both are inert when there is
84 // none. The widget class is loaded inside the callbacks - requiring an
85 // Elementor widget during plugin bootstrap breaks WooCommerce's own
86 // init chain, and with it add-to-cart.
87 add_filter('woocommerce_checkout_fields', [$this, 'filter_stored_checkout_fields'], 9998);
88 add_action('woocommerce_checkout_update_order_meta', [$this, 'save_stored_checkout_fields'], 20, 2);
89 add_action('woocommerce_admin_order_data_after_billing_address', [$this, 'render_checkout_extra_fields_admin']);
90 add_action('woocommerce_order_details_after_order_table', [$this, 'render_checkout_extra_fields_front']);
91 add_filter('woocommerce_email_order_meta_fields', [$this, 'email_checkout_extra_fields'], 10, 3);
92
93 // Custom account endpoints need their rewrite rule and menu entry in
94 // place at init, long before the widget that defines them renders.
95 add_action('init', [$this, 'boot_account_endpoints'], 5);
96
97 add_action('init', [$this, 'register_template_types']);
98 add_action('wp_enqueue_scripts', [$this, 'register_assets']);
99 add_filter('template_include', [$this, 'override_wc_template'], 99);
100 add_filter('king_addons/woo_builder/current_template_id', [$this, 'filter_current_template_id'], 10, 2);
101 add_filter('body_class', [$this, 'filter_body_class']);
102
103 // My Account endpoint manager (Pro routing).
104 // This class is constructed while plugins_loaded is already finished,
105 // so hooking the manager onto that action meant it was never created:
106 // no endpoint templates, no endpoint permissions and no logout
107 // confirmation screen.
108 $boot_account_manager = static function () {
109 if (!class_exists(My_Account_Manager::class)) {
110 require_once KING_ADDONS_PATH . 'includes/helpers/Woo_Builder/My_Account_Manager.php';
111 }
112 new My_Account_Manager();
113 };
114
115 if (did_action('plugins_loaded')) {
116 $boot_account_manager();
117 } else {
118 add_action('plugins_loaded', $boot_account_manager);
119 }
120 add_filter('default_post_metadata', [$this, 'prefill_template_meta'], 10, 5);
121
122 // Save template type meta when post is created via URL parameter
123 add_action('save_post_elementor_library', [$this, 'save_template_type_from_url'], 10, 3);
124
125 // Also hook into wp_insert_post for auto-draft creation
126 add_action('wp_insert_post', [$this, 'save_template_type_on_insert'], 10, 3);
127
128 // Hook into admin to set meta when creating new template from URL
129 add_action('admin_init', [$this, 'maybe_set_woo_template_meta_on_new_post']);
130
131 // All conditions are managed in Elementor document settings (no WP metabox UI).
132
133 add_action('elementor/documents/register_controls', [$this, 'register_document_controls']);
134 add_action('elementor/document/after_save', [$this, 'handle_document_after_save'], 10, 2);
135 }
136
137 /**
138 * Forward the products-grid load-more request to the widget.
139 *
140 * The widget class is pulled in here rather than at load time: by the time
141 * an AJAX request runs, Elementor is fully booted and Widget_Base resolves.
142 *
143 * @return void
144 */
145 public function ajax_products_grid(): void
146 {
147 if (!class_exists('King_Addons\\Woo_Products_Grid')) {
148 $file = KING_ADDONS_PATH . 'includes/widgets/Woo_Products_Grid/Woo_Products_Grid.php';
149 if (file_exists($file)) {
150 require_once KING_ADDONS_PATH . 'includes/helpers/Woo_Builder/Abstract_Archive_Widget.php';
151 require_once $file;
152 }
153 }
154
155 if (!class_exists('King_Addons\\Woo_Products_Grid')) {
156 wp_send_json_error(['message' => esc_html__('Products Grid is not available.', 'king-addons')], 400);
157 }
158
159 Woo_Products_Grid::ajax_render();
160 }
161
162 /**
163 * Register the account endpoints stored by the My Account Content widget.
164 *
165 * @return void
166 */
167 public function boot_account_endpoints(): void
168 {
169 // Custom endpoints are a Pro feature. Without this check a stored set
170 // would keep adding menu entries and URLs after a licence lapses,
171 // while the widget that fills them no longer renders.
172 if (!$this->can_use_pro()) {
173 return;
174 }
175
176 $stored = get_option('king_addons_account_endpoints', []);
177 if (!is_array($stored) || empty($stored)) {
178 return;
179 }
180
181 if (!class_exists('King_Addons\\Woo_My_Account_Content')) {
182 $base = KING_ADDONS_PATH . 'includes/helpers/Woo_Builder/Abstract_My_Account_Widget.php';
183 $file = KING_ADDONS_PATH . 'includes/widgets/Woo_My_Account_Content/Woo_My_Account_Content.php';
184 if (!file_exists($base) || !file_exists($file) || !class_exists('\\Elementor\\Widget_Base')) {
185 return;
186 }
187 require_once $base;
188 require_once $file;
189 }
190
191 if (class_exists('King_Addons\\Woo_My_Account_Content')) {
192 Woo_My_Account_Content::boot();
193 }
194 }
195
196 /**
197 * Load the checkout form widget on demand.
198 *
199 * @return bool True when the class is available.
200 */
201 private function load_checkout_widget(): bool
202 {
203 if (class_exists('King_Addons\\Woo_Checkout_Form')) {
204 return true;
205 }
206
207 $base = KING_ADDONS_PATH . 'includes/helpers/Woo_Builder/Abstract_Checkout_Widget.php';
208 $file = KING_ADDONS_PATH . 'includes/widgets/Woo_Checkout_Form/Woo_Checkout_Form.php';
209 if (!file_exists($base) || !file_exists($file) || !class_exists('\\Elementor\\Widget_Base')) {
210 return false;
211 }
212
213 require_once $base;
214 require_once $file;
215
216 return class_exists('King_Addons\\Woo_Checkout_Form');
217 }
218
219 /**
220 * Apply the checkout field configuration stored by the widget.
221 *
222 * @param array<string,mixed> $fields WooCommerce checkout fields.
223 *
224 * @return array<string,mixed>
225 */
226 public function filter_stored_checkout_fields($fields)
227 {
228 if (!is_array($fields) || !$this->load_checkout_widget()) {
229 return $fields;
230 }
231
232 return Woo_Checkout_Form::filter_stored_checkout_fields($fields);
233 }
234
235 /**
236 * Save extra checkout fields for an order created without a widget render.
237 *
238 * @param int $order_id Order ID.
239 * @param array<string,mixed> $data Posted data.
240 *
241 * @return void
242 */
243 public function save_stored_checkout_fields($order_id, $data): void
244 {
245 if (!$this->load_checkout_widget()) {
246 return;
247 }
248
249 Woo_Checkout_Form::save_stored_extra_fields((int) $order_id, is_array($data) ? $data : []);
250 }
251
252 /**
253 * Extra checkout fields on the admin order screen.
254 *
255 * @param \WC_Order $order Order.
256 *
257 * @return void
258 */
259 public function render_checkout_extra_fields_admin($order): void
260 {
261 if (!$this->load_checkout_widget()) {
262 return;
263 }
264
265 Woo_Checkout_Form::render_extra_fields_admin($order);
266 }
267
268 /**
269 * Extra checkout fields on thank-you / view-order.
270 *
271 * @param \WC_Order $order Order.
272 *
273 * @return void
274 */
275 public function render_checkout_extra_fields_front($order): void
276 {
277 if (!$this->load_checkout_widget()) {
278 return;
279 }
280
281 Woo_Checkout_Form::render_extra_fields_front($order);
282 }
283
284 /**
285 * Extra checkout fields in emails.
286 *
287 * @param array<string,mixed> $fields Existing fields.
288 * @param bool $sent_to_admin Unused.
289 * @param \WC_Order $order Order.
290 *
291 * @return array<string,mixed>
292 */
293 public function email_checkout_extra_fields($fields, $sent_to_admin, $order)
294 {
295 if (!$this->load_checkout_widget()) {
296 return $fields;
297 }
298
299 return Woo_Checkout_Form::email_extra_fields($fields, $sent_to_admin, $order);
300 }
301
302 /**
303 * Load the cart widget classes on demand for an AJAX request.
304 *
305 * @return bool True when the class is available.
306 */
307 private function load_cart_widget(): bool
308 {
309 if (class_exists('King_Addons\\Woo_Cart_Table')) {
310 return true;
311 }
312
313 $base = KING_ADDONS_PATH . 'includes/helpers/Woo_Builder/Abstract_Cart_Widget.php';
314 $table = KING_ADDONS_PATH . 'includes/widgets/Woo_Cart_Table/Woo_Cart_Table.php';
315 $totals = KING_ADDONS_PATH . 'includes/widgets/Woo_Cart_Totals/Woo_Cart_Totals.php';
316
317 if (!file_exists($base) || !file_exists($table)) {
318 return false;
319 }
320
321 require_once $base;
322 // Woo_Cart_Table::render_totals_html() calls into the totals widget.
323 if (file_exists($totals)) {
324 require_once $totals;
325 }
326 require_once $table;
327
328 return class_exists('King_Addons\\Woo_Cart_Table');
329 }
330
331 /**
332 * Forward a cart quantity/remove request to the cart widget.
333 *
334 * @return void
335 */
336 public function ajax_cart_update(): void
337 {
338 if (!$this->load_cart_widget()) {
339 wp_send_json_error(['message' => esc_html__('Cart widget is not available.', 'king-addons')], 400);
340 }
341
342 Woo_Cart_Table::ajax_update();
343 }
344
345 /**
346 * Forward a coupon request to the cart widget.
347 *
348 * @return void
349 */
350 public function ajax_cart_coupon(): void
351 {
352 if (!$this->load_cart_widget()) {
353 wp_send_json_error(['message' => esc_html__('Cart widget is not available.', 'king-addons')], 400);
354 }
355
356 Woo_Cart_Table::ajax_coupon();
357 }
358
359 /**
360 * Register assets for builder.
361 *
362 * @return void
363 */
364 public function register_assets(): void
365 {
366 $deps = is_admin() ? ['jquery', 'select2', 'wp-api'] : [];
367
368 wp_register_style(
369 KING_ADDONS_ASSETS_UNIQUE_KEY . '-woo-builder-style',
370 KING_ADDONS_URL . 'includes/extensions/Woo_Builder/style.css',
371 [],
372 KING_ADDONS_VERSION
373 );
374
375 wp_register_script(
376 KING_ADDONS_ASSETS_UNIQUE_KEY . '-woo-builder-script',
377 KING_ADDONS_URL . 'includes/extensions/Woo_Builder/script.js',
378 $deps,
379 KING_ADDONS_VERSION,
380 true
381 );
382 }
383
384 /**
385 * Register Elementor document type for Woo Builder.
386 *
387 * @return void
388 */
389 public function register_template_types(): void
390 {
391 if (!class_exists('Elementor\\Plugin')) {
392 return;
393 }
394
395 if (!class_exists('King_Addons\\Woo_Builder\\Document')) {
396 require_once KING_ADDONS_PATH . 'includes/helpers/Woo_Builder/Document.php';
397 }
398
399 if (class_exists('King_Addons\\Woo_Builder\\Document')) {
400 Elementor_Plugin::$instance->documents->register_document_type(
401 Woo_Document::get_type(),
402 Woo_Document::class
403 );
404 }
405 }
406
407 /**
408 * Register meta box for Elementor templates.
409 *
410 * @return void
411 */
412 public function register_meta_boxes(): void
413 {
414 add_meta_box(
415 'king-addons-woo-builder',
416 esc_html__('Woo Builder Conditions', 'king-addons'),
417 [$this, 'render_meta_box'],
418 'elementor_library',
419 'side',
420 'default'
421 );
422 }
423
424 /**
425 * Render meta box UI.
426 *
427 * @param WP_Post $post Post object.
428 *
429 * @return void
430 */
431 public function render_meta_box(WP_Post $post): void
432 {
433 wp_nonce_field('king_addons_woo_builder_meta', 'king_addons_woo_builder_meta_nonce');
434
435 $template_type = get_post_meta($post->ID, 'ka_woo_template_type', true);
436 $conditions = get_post_meta($post->ID, 'ka_woo_conditions', true);
437 $enabled = !empty($conditions['enabled']);
438 $priority = isset($conditions['priority']) ? (int) $conditions['priority'] : 10;
439 $rules = [];
440 if (!empty($conditions['rules']) && is_array($conditions['rules'])) {
441 $rules = $conditions['rules'];
442 }
443 foreach ($rules as $idx => $rule) {
444 $rules[$idx]['type'] = $this->normalize_rule_type($rule['type'] ?? '');
445 }
446 if (empty($rules)) {
447 $rules[] = ['type' => 'always', 'values' => []];
448 }
449
450 $types = [
451 '' => esc_html__('Select type', 'king-addons'),
452 'single_product' => esc_html__('Single Product', 'king-addons'),
453 'product_archive' => esc_html__('Product Archive', 'king-addons'),
454 'cart' => esc_html__('Cart', 'king-addons'),
455 'checkout' => esc_html__('Checkout', 'king-addons'),
456 'my_account' => esc_html__('My Account', 'king-addons'),
457 ];
458
459 $rule_types = [
460 'always' => esc_html__('Always', 'king-addons'),
461 'all_products' => esc_html__('All products', 'king-addons'),
462 'product_in' => esc_html__('Specific products', 'king-addons'),
463 'product_cat_in' => esc_html__('Product categories', 'king-addons'),
464 'product_tag_in' => esc_html__('Product tags', 'king-addons'),
465 'product_type_in' => esc_html__('Product types', 'king-addons'),
466 'is_shop' => esc_html__('Shop page', 'king-addons'),
467 'product_cat_archive_in' => esc_html__('Product category archives', 'king-addons'),
468 'product_tag_archive_in' => esc_html__('Product tag archives', 'king-addons'),
469 'cart' => esc_html__('Cart page', 'king-addons'),
470 'checkout' => esc_html__('Checkout page', 'king-addons'),
471 'my_account' => esc_html__('My Account page', 'king-addons'),
472 // Legacy aliases kept for backward compatibility.
473 'products' => esc_html__('Specific products (IDs)', 'king-addons') . ' (legacy)',
474 'product_categories' => esc_html__('Product categories (IDs)', 'king-addons') . ' (legacy)',
475 'product_tags' => esc_html__('Product tags (IDs)', 'king-addons') . ' (legacy)',
476 'product_types' => esc_html__('Product types (slugs)', 'king-addons') . ' (legacy)',
477 'shop' => esc_html__('Shop page', 'king-addons') . ' (legacy)',
478 'product_cat_archives' => esc_html__('Product category archives (IDs)', 'king-addons') . ' (legacy)',
479 ];
480
481 // Admin assets for select2 UI and REST.
482 wp_enqueue_style('select2');
483 wp_enqueue_script('select2');
484 wp_enqueue_script('wp-api');
485 wp_enqueue_script(KING_ADDONS_ASSETS_UNIQUE_KEY . '-woo-builder-script');
486 wp_enqueue_style(KING_ADDONS_ASSETS_UNIQUE_KEY . '-woo-builder-style');
487 wp_localize_script(
488 KING_ADDONS_ASSETS_UNIQUE_KEY . '-woo-builder-script',
489 'KingAddonsWooBuilder',
490 [
491 'labels' => [
492 'valuePlaceholder' => esc_html__('Search or select…', 'king-addons'),
493 ],
494 ]
495 );
496 ?>
497 <p>
498 <label>
499 <input type="checkbox" name="ka_woo_enabled" value="1" <?php checked($enabled); ?> />
500 <?php esc_html_e('Enable template', 'king-addons'); ?>
501 </label>
502 </p>
503 <p>
504 <label for="ka_woo_template_type"><strong><?php esc_html_e('Template Type', 'king-addons'); ?></strong></label><br />
505 <select name="ka_woo_template_type" id="ka_woo_template_type" style="width:100%;">
506 <?php foreach ($types as $value => $label) : ?>
507 <option value="<?php echo esc_attr($value); ?>" <?php selected($template_type, $value); ?>><?php echo esc_html($label); ?></option>
508 <?php endforeach; ?>
509 </select>
510 </p>
511 <?php if (!$this->can_use_pro()) : ?>
512 <p class="king-addons-woo-builder-pro-lock">
513 <?php esc_html_e('Cart, Checkout, and My Account templates require the Pro version.', 'king-addons'); ?>
514 </p>
515 <?php endif; ?>
516 <p>
517 <label for="ka_woo_priority"><strong><?php esc_html_e('Priority', 'king-addons'); ?></strong></label><br />
518 <input type="number" min="0" name="ka_woo_priority" id="ka_woo_priority" value="<?php echo esc_attr($priority); ?>" style="width:100%;" />
519 </p>
520 <div id="ka-woo-rules">
521 <strong><?php esc_html_e('Rules (AND)', 'king-addons'); ?></strong>
522 <?php foreach ($rules as $index => $rule) : ?>
523 <div class="ka-woo-rule" style="border:1px solid #e0e0e0;padding:8px;margin-top:8px;border-radius:4px;">
524 <?php
525 $rule_type_value = $rule['type'] ?? '';
526 if ($rule_type_value && !isset($rule_types[$rule_type_value])) {
527 $rule_types[$rule_type_value] = sprintf(
528 /* translators: %s: rule type slug */
529 esc_html__('Unknown rule (%s)', 'king-addons'),
530 esc_html($rule_type_value)
531 );
532 }
533 ?>
534 <select class="ka-woo-rule-type" name="ka_woo_rule_type[<?php echo esc_attr($index); ?>]" style="width:100%;margin-bottom:6px;">
535 <?php foreach ($rule_types as $value => $label) : ?>
536 <option value="<?php echo esc_attr($value); ?>" <?php selected($rule['type'] ?? '', $value); ?>><?php echo esc_html($label); ?></option>
537 <?php endforeach; ?>
538 </select>
539 <?php
540 $rule_values = isset($rule['values']) && is_array($rule['values']) ? $rule['values'] : [];
541 $value_options = $this->get_rule_value_options($rule['type'] ?? '', $rule_values);
542 ?>
543 <select
544 class="ka-woo-rule-values"
545 name="ka_woo_rule_values[<?php echo esc_attr($index); ?>][]"
546 multiple
547 data-rule-type="<?php echo esc_attr($rule['type'] ?? ''); ?>"
548 data-selected-ids="<?php echo esc_attr(implode(',', array_map('strval', $rule_values))); ?>"
549 style="width:100%;"
550 >
551 <?php foreach ($value_options as $option_value => $option_label) : ?>
552 <option value="<?php echo esc_attr($option_value); ?>" <?php selected(true, in_array((string) $option_value, array_map('strval', $rule_values), true)); ?>>
553 <?php echo esc_html($option_label); ?>
554 </option>
555 <?php endforeach; ?>
556 </select>
557 <button type="button" class="button ka-woo-remove-rule" style="margin-top:6px;"><?php esc_html_e('Remove', 'king-addons'); ?></button>
558 </div>
559 <?php endforeach; ?>
560 </div>
561 <p><button type="button" class="button" id="ka-woo-add-rule"><?php esc_html_e('Add rule', 'king-addons'); ?></button></p>
562 <?php
563 }
564
565 /**
566 * Save meta box data.
567 *
568 * @param int $post_id Post ID.
569 * @param WP_Post $post Post.
570 *
571 * @return void
572 */
573 public function save_template_meta(int $post_id, WP_Post $post): void
574 {
575 if ('elementor_library' !== $post->post_type) {
576 return;
577 }
578
579 if (!isset($_POST['king_addons_woo_builder_meta_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['king_addons_woo_builder_meta_nonce'])), 'king_addons_woo_builder_meta')) {
580 return;
581 }
582
583 if (!current_user_can('edit_post', $post_id)) {
584 return;
585 }
586
587 $template_type = sanitize_text_field($_POST['ka_woo_template_type'] ?? '');
588 update_post_meta($post_id, 'ka_woo_template_type', $template_type);
589 if (in_array($template_type, ['single_product', 'product_archive', 'cart', 'checkout', 'my_account'], true)) {
590 $document_type = class_exists(Woo_Document::class) ? Woo_Document::get_type() : 'king-addons-woo-builder';
591 update_post_meta($post_id, '_elementor_template_type', $document_type);
592 }
593
594 $enabled = !empty($_POST['ka_woo_enabled']);
595 $priority = isset($_POST['ka_woo_priority']) ? (int) $_POST['ka_woo_priority'] : 10;
596
597 $types = $_POST['ka_woo_rule_type'] ?? [];
598 $values = $_POST['ka_woo_rule_values'] ?? [];
599
600 $rules = [];
601 if (is_array($types) && is_array($values)) {
602 foreach ($types as $idx => $type) {
603 $type = sanitize_text_field($type);
604 if ('' === $type) {
605 continue;
606 }
607 $val_raw = $values[$idx] ?? '';
608 if (is_array($val_raw)) {
609 $vals = array_filter(array_map('trim', array_map('strval', $val_raw)));
610 } else {
611 $vals = array_filter(array_map('trim', explode(',', (string) $val_raw)));
612 }
613 $parsed_vals = [];
614 foreach ($vals as $v) {
615 if (is_numeric($v)) {
616 $parsed_vals[] = (int) $v;
617 } else {
618 $parsed_vals[] = sanitize_text_field($v);
619 }
620 }
621 $rules[] = [
622 'type' => $this->normalize_rule_type($type),
623 'values' => $parsed_vals,
624 ];
625 }
626 }
627
628 $conditions = [
629 'enabled' => $enabled,
630 'priority' => $priority,
631 'rules' => $rules,
632 ];
633
634 update_post_meta($post_id, 'ka_woo_conditions', $conditions);
635 }
636
637 /**
638 * Register Elementor document controls for Woo Builder templates.
639 *
640 * @param mixed $document Elementor document.
641 *
642 * @return void
643 */
644 public function register_document_controls($document): void
645 {
646 if (!is_object($document) || !method_exists($document, 'get_main_id')) {
647 return;
648 }
649
650 $post_id = (int) $document->get_main_id();
651 if ('elementor_library' !== get_post_type($post_id)) {
652 return;
653 }
654
655 $template_type = (string) get_post_meta($post_id, 'ka_woo_template_type', true);
656 $doc_type = class_exists(Woo_Document::class) ? Woo_Document::get_type() : 'king-addons-woo-builder';
657 $elementor_type = (string) get_post_meta($post_id, '_elementor_template_type', true);
658 if ('' === $template_type && $elementor_type !== $doc_type) {
659 return;
660 }
661
662 $conditions = get_post_meta($post_id, 'ka_woo_conditions', true);
663 $enabled = !empty($conditions['enabled']);
664 $priority = isset($conditions['priority']) ? (int) $conditions['priority'] : 10;
665 $rules = is_array($conditions['rules'] ?? null) ? $conditions['rules'] : [];
666 if (empty($rules)) {
667 $rules = [
668 [
669 'type' => 'always',
670 'values' => [],
671 ],
672 ];
673 }
674
675 $types = [
676 '' => esc_html__('Select type', 'king-addons'),
677 'single_product' => esc_html__('Single Product', 'king-addons'),
678 'product_archive' => esc_html__('Product Archive', 'king-addons'),
679 'cart' => esc_html__('Cart', 'king-addons'),
680 'checkout' => esc_html__('Checkout', 'king-addons'),
681 'my_account' => esc_html__('My Account', 'king-addons'),
682 ];
683
684 $rule_types = [
685 'always' => esc_html__('Always', 'king-addons'),
686 'all_products' => esc_html__('All products', 'king-addons'),
687 'product_in' => esc_html__('Specific products', 'king-addons'),
688 'product_cat_in' => esc_html__('Product categories', 'king-addons'),
689 'product_tag_in' => esc_html__('Product tags', 'king-addons'),
690 'product_type_in' => esc_html__('Product types', 'king-addons'),
691 'is_shop' => esc_html__('Shop page', 'king-addons'),
692 'product_cat_archive_in' => esc_html__('Product category archives', 'king-addons'),
693 'product_tag_archive_in' => esc_html__('Product tag archives', 'king-addons'),
694 'cart' => esc_html__('Cart page', 'king-addons'),
695 'checkout' => esc_html__('Checkout page', 'king-addons'),
696 'my_account' => esc_html__('My Account page', 'king-addons'),
697 ];
698
699 $product_types = function_exists('wc_get_product_types')
700 ? wc_get_product_types()
701 : [
702 'simple' => esc_html__('Simple', 'king-addons'),
703 'variable' => esc_html__('Variable', 'king-addons'),
704 'grouped' => esc_html__('Grouped', 'king-addons'),
705 'external' => esc_html__('External/Affiliate', 'king-addons'),
706 ];
707
708 $repeater = new Repeater();
709 $repeater->add_control(
710 'rule_type',
711 [
712 'label' => esc_html__('Rule Type', 'king-addons'),
713 'type' => Controls_Manager::SELECT,
714 'options' => $rule_types,
715 'default' => 'always',
716 ]
717 );
718 $repeater->add_control(
719 'values_products',
720 [
721 'label' => esc_html__('Products', 'king-addons'),
722 'type' => 'king-addons-ajax-select2',
723 'options' => 'ajaxselect2/getPostsByPostType',
724 'query_slug' => 'product',
725 'multiple' => true,
726 'condition' => [
727 'rule_type' => ['product_in', 'products'],
728 ],
729 ]
730 );
731 $repeater->add_control(
732 'values_product_cats',
733 [
734 'label' => esc_html__('Product categories', 'king-addons'),
735 'type' => 'king-addons-ajax-select2',
736 'options' => 'ajaxselect2/getTaxonomies',
737 'query_slug' => 'product_cat',
738 'multiple' => true,
739 'condition' => [
740 'rule_type' => ['product_cat_in', 'product_categories', 'product_cat_archive_in', 'product_cat_archives'],
741 ],
742 ]
743 );
744 $repeater->add_control(
745 'values_product_tags',
746 [
747 'label' => esc_html__('Product tags', 'king-addons'),
748 'type' => 'king-addons-ajax-select2',
749 'options' => 'ajaxselect2/getTaxonomies',
750 'query_slug' => 'product_tag',
751 'multiple' => true,
752 'condition' => [
753 'rule_type' => ['product_tag_in', 'product_tags', 'product_tag_archive_in', 'product_tag_archives'],
754 ],
755 ]
756 );
757 $repeater->add_control(
758 'values_product_types',
759 [
760 'label' => esc_html__('Product types', 'king-addons'),
761 'type' => Controls_Manager::SELECT2,
762 'options' => $product_types,
763 'multiple' => true,
764 'condition' => [
765 'rule_type' => ['product_type_in', 'product_types'],
766 ],
767 ]
768 );
769
770 $default_rules = [];
771 foreach ($rules as $rule) {
772 $type = $this->normalize_rule_type($rule['type'] ?? '');
773 $values = is_array($rule['values'] ?? null) ? $rule['values'] : [];
774 $item = [
775 'rule_type' => $type ?: 'always',
776 ];
777 switch ($type) {
778 case 'product_in':
779 case 'products':
780 $item['values_products'] = array_map('strval', $values);
781 break;
782 case 'product_cat_in':
783 case 'product_categories':
784 case 'product_cat_archive_in':
785 case 'product_cat_archives':
786 $item['values_product_cats'] = array_map('strval', $values);
787 break;
788 case 'product_tag_in':
789 case 'product_tags':
790 case 'product_tag_archive_in':
791 case 'product_tag_archives':
792 $item['values_product_tags'] = array_map('strval', $values);
793 break;
794 case 'product_type_in':
795 case 'product_types':
796 $item['values_product_types'] = array_map('strval', $values);
797 break;
798 }
799 $default_rules[] = $item;
800 }
801
802 if (empty($default_rules)) {
803 $default_rules[] = ['rule_type' => 'always'];
804 }
805
806 $document->start_controls_section(
807 'ka_woo_builder_settings',
808 [
809 'label' => esc_html__('Woo Builder', 'king-addons'),
810 'tab' => Controls_Manager::TAB_SETTINGS,
811 ]
812 );
813
814 $document->add_control(
815 'ka_woo_template_type',
816 [
817 'label' => esc_html__('Template Type', 'king-addons'),
818 'type' => Controls_Manager::SELECT,
819 'options' => $types,
820 'default' => $template_type ?: '',
821 ]
822 );
823
824 if (!$this->can_use_pro()) {
825 $document->add_control(
826 'ka_woo_pro_notice',
827 [
828 'label' => esc_html__('Pro', 'king-addons'),
829 'type' => Controls_Manager::RAW_HTML,
830 'raw' => esc_html__('Cart, Checkout, and My Account templates require Pro.', 'king-addons'),
831 'content_classes' => 'ka-theme-builder-readonly',
832 ]
833 );
834 }
835
836 $document->add_control(
837 'ka_woo_enabled',
838 [
839 'label' => esc_html__('Enable template', 'king-addons'),
840 'type' => Controls_Manager::SWITCHER,
841 'label_on' => esc_html__('Yes', 'king-addons'),
842 'label_off' => esc_html__('No', 'king-addons'),
843 'return_value' => 'yes',
844 'default' => $enabled ? 'yes' : 'no',
845 ]
846 );
847
848 $document->add_control(
849 'ka_woo_priority',
850 [
851 'label' => esc_html__('Priority', 'king-addons'),
852 'type' => Controls_Manager::NUMBER,
853 'default' => $priority,
854 'min' => 0,
855 ]
856 );
857
858 $document->add_control(
859 'ka_woo_rules',
860 [
861 'label' => esc_html__('Rules (AND)', 'king-addons'),
862 'type' => Controls_Manager::REPEATER,
863 'fields' => $repeater->get_controls(),
864 'default' => $default_rules,
865 'title_field' => '{{ rule_type }}',
866 ]
867 );
868
869 $document->end_controls_section();
870 }
871
872 /**
873 * Persist Woo Builder meta from Elementor document settings.
874 *
875 * @param mixed $document Elementor document.
876 * @param array $data Saved data.
877 *
878 * @return void
879 */
880 public function handle_document_after_save($document, array $data): void
881 {
882 if (!is_object($document) || !method_exists($document, 'get_main_id')) {
883 return;
884 }
885
886 $post_id = (int) $document->get_main_id();
887 if ('elementor_library' !== get_post_type($post_id)) {
888 return;
889 }
890
891 $valid_types = ['single_product', 'product_archive', 'cart', 'checkout', 'my_account'];
892 $template_type = sanitize_text_field((string) $document->get_settings('ka_woo_template_type'));
893 if (!in_array($template_type, $valid_types, true)) {
894 $template_type = '';
895 }
896
897 // If template_type not in settings, check existing meta or URL parameter
898 if ('' === $template_type) {
899 // First check if meta already exists
900 $existing_type = get_post_meta($post_id, 'ka_woo_template_type', true);
901 if (!empty($existing_type) && in_array($existing_type, $valid_types, true)) {
902 $template_type = $existing_type;
903 } else {
904 // Check URL parameter from referrer
905 $template_type = $this->get_template_type_from_request();
906 }
907 }
908
909 if ('' === $template_type && !get_post_meta($post_id, 'ka_woo_template_type', true)) {
910 return;
911 }
912
913 if ('' === $template_type) {
914 delete_post_meta($post_id, 'ka_woo_template_type');
915 } else {
916 update_post_meta($post_id, 'ka_woo_template_type', $template_type);
917 $document_type = class_exists(Woo_Document::class) ? Woo_Document::get_type() : 'king-addons-woo-builder';
918 update_post_meta($post_id, '_elementor_template_type', $document_type);
919 }
920
921 $enabled_setting = $document->get_settings('ka_woo_enabled');
922 $enabled = ('yes' === $enabled_setting || true === $enabled_setting);
923 $priority_setting = $document->get_settings('ka_woo_priority');
924 $priority = is_numeric($priority_setting) ? (int) $priority_setting : 10;
925
926 $raw_rules = $document->get_settings('ka_woo_rules');
927 $rules = [];
928
929 if (is_array($raw_rules)) {
930 foreach ($raw_rules as $rule) {
931 $type = $this->normalize_rule_type(sanitize_text_field($rule['rule_type'] ?? ''));
932 if ('' === $type) {
933 continue;
934 }
935 $values = [];
936 switch ($type) {
937 case 'product_in':
938 case 'products':
939 $values = $this->sanitize_rule_values($rule['values_products'] ?? [], true);
940 break;
941 case 'product_cat_in':
942 case 'product_categories':
943 case 'product_cat_archive_in':
944 case 'product_cat_archives':
945 $values = $this->sanitize_rule_values($rule['values_product_cats'] ?? [], true);
946 break;
947 case 'product_tag_in':
948 case 'product_tags':
949 case 'product_tag_archive_in':
950 case 'product_tag_archives':
951 $values = $this->sanitize_rule_values($rule['values_product_tags'] ?? [], true);
952 break;
953 case 'product_type_in':
954 case 'product_types':
955 $values = $this->sanitize_rule_values($rule['values_product_types'] ?? [], false);
956 break;
957 default:
958 $values = [];
959 break;
960 }
961 $rules[] = [
962 'type' => $type,
963 'values' => $values,
964 ];
965 }
966 }
967
968 if (empty($rules)) {
969 $rules[] = [
970 'type' => 'always',
971 'values' => [],
972 ];
973 }
974
975 $conditions = [
976 'enabled' => $enabled,
977 'priority' => $priority,
978 'rules' => $rules,
979 ];
980
981 update_post_meta($post_id, 'ka_woo_conditions', $conditions);
982 }
983
984 /**
985 * Template include override.
986 *
987 * @param string $template Default template.
988 *
989 * @return string
990 */
991 public function override_wc_template(string $template): string
992 {
993 if (is_admin() || wp_doing_ajax() || (defined('REST_REQUEST') && REST_REQUEST)) {
994 return $template;
995 }
996
997 $context = Woo_Context::detect_context();
998 if (null === $context) {
999 return $template;
1000 }
1001
1002 if (!$this->can_use_pro() && in_array($context, ['cart', 'checkout', 'my_account'], true)) {
1003 return $template;
1004 }
1005
1006 $template_id = $this->get_active_template_for_context($context);
1007 $this->current_templates[$context] = $template_id ?: 0;
1008 $this->active_context = $template_id ? $context : null;
1009
1010 if (!$template_id) {
1011 return $template;
1012 }
1013
1014 $this->maybe_register_default_blocks();
1015
1016 $wrapper = KING_ADDONS_PATH . 'includes/extensions/Woo_Builder/templates/' . $context . '.php';
1017 if (file_exists($wrapper)) {
1018 return $wrapper;
1019 }
1020
1021 return $template;
1022 }
1023
1024 /**
1025 * Get active template ID for context.
1026 *
1027 * @param string $context Context.
1028 *
1029 * @return int|null
1030 */
1031 public function get_active_template_for_context(string $context): ?int
1032 {
1033 $query = new WP_Query([
1034 'post_type' => 'elementor_library',
1035 'posts_per_page' => -1,
1036 'post_status' => 'publish', // Only published templates should be applied on frontend
1037 'meta_query' => [
1038 [
1039 'key' => 'ka_woo_template_type',
1040 'value' => $context,
1041 ],
1042 ],
1043 ]);
1044
1045 if (!$query->have_posts()) {
1046 return null;
1047 }
1048
1049 $candidates = [];
1050
1051 foreach ($query->posts as $post) {
1052 $conditions = get_post_meta($post->ID, 'ka_woo_conditions', true);
1053 if (!is_array($conditions) || empty($conditions['enabled'])) {
1054 continue;
1055 }
1056
1057 if (!$this->can_use_pro() && !in_array($context, ['single_product', 'product_archive'], true)) {
1058 continue;
1059 }
1060
1061 if (!Woo_Context::match_conditions($context, $conditions)) {
1062 continue;
1063 }
1064
1065 $priority = isset($conditions['priority']) ? (int) $conditions['priority'] : 10;
1066 $candidates[] = [
1067 'id' => (int) $post->ID,
1068 'priority' => $priority,
1069 'specificity' => $this->get_conditions_specificity_score($context, $conditions),
1070 ];
1071 }
1072
1073 if (empty($candidates)) {
1074 return null;
1075 }
1076
1077 usort(
1078 $candidates,
1079 static function ($a, $b) {
1080 $spec = ($b['specificity'] ?? 0) <=> ($a['specificity'] ?? 0);
1081 if (0 !== $spec) {
1082 return $spec;
1083 }
1084
1085 $prio = ($a['priority'] ?? 10) <=> ($b['priority'] ?? 10);
1086 if (0 !== $prio) {
1087 return $prio;
1088 }
1089
1090 return ($a['id'] ?? 0) <=> ($b['id'] ?? 0);
1091 }
1092 );
1093
1094 // Free: limit one template per context by picking top; Pro may have multiple but we still pick top priority.
1095 return (int) $candidates[0]['id'];
1096 }
1097
1098 /**
1099 * Compute how specific a condition set is for the given context.
1100 * Higher score = more specific.
1101 *
1102 * @param string $context
1103 * @param array<string,mixed> $conditions
1104 *
1105 * @return int
1106 */
1107 private function get_conditions_specificity_score(string $context, array $conditions): int
1108 {
1109 if (empty($conditions['rules']) || !is_array($conditions['rules'])) {
1110 return 0;
1111 }
1112
1113 $score = 0;
1114
1115 foreach ($conditions['rules'] as $rule) {
1116 $type = (string) ($rule['type'] ?? '');
1117 $values = is_array($rule['values'] ?? null) ? $rule['values'] : [];
1118 $has_values = !empty(array_filter($values, static fn($v) => '' !== (string) $v));
1119
1120 if ('single_product' === $context) {
1121 if (in_array($type, ['specific_product', 'product_in', 'products'], true) && $has_values) {
1122 $score += 300;
1123 } elseif (in_array($type, ['product_cat', 'product_cat_in', 'product_categories'], true) && $has_values) {
1124 $score += 200;
1125 } elseif (in_array($type, ['product_tag', 'product_tag_in', 'product_tags'], true) && $has_values) {
1126 $score += 200;
1127 } elseif (in_array($type, ['all', 'all_products', 'always'], true)) {
1128 $score += 100;
1129 }
1130 } elseif ('product_archive' === $context) {
1131 if (in_array($type, ['shop', 'is_shop'], true)) {
1132 $score += 300;
1133 } elseif (in_array($type, ['product_cat', 'product_cat_archive_in', 'product_cat_archives', 'product_cat_in', 'product_categories'], true) && $has_values) {
1134 $score += 200;
1135 } elseif (in_array($type, ['product_tag', 'product_tag_archive_in', 'product_tag_archives', 'product_tag_in', 'product_tags'], true) && $has_values) {
1136 $score += 200;
1137 } elseif (in_array($type, ['all', 'always'], true)) {
1138 $score += 100;
1139 }
1140 } else {
1141 if (in_array($type, ['cart', 'checkout', 'my_account'], true)) {
1142 $score += 200;
1143 } elseif (in_array($type, ['all', 'always'], true)) {
1144 $score += 100;
1145 }
1146 }
1147 }
1148
1149 return $score;
1150 }
1151
1152 /**
1153 * Filter to expose current template id.
1154 *
1155 * @param int $template_id Template ID.
1156 * @param string|null $context Context.
1157 *
1158 * @return int
1159 */
1160 public function filter_current_template_id(int $template_id, ?string $context = null): int
1161 {
1162 if ($context && !empty($this->current_templates[$context])) {
1163 return (int) $this->current_templates[$context];
1164 }
1165 return $template_id;
1166 }
1167
1168 /**
1169 * Add body classes for active Woo Builder context.
1170 *
1171 * @param array<int,string> $classes Existing classes.
1172 *
1173 * @return array<int,string>
1174 */
1175 public function filter_body_class(array $classes): array
1176 {
1177 if (null !== $this->active_context) {
1178 $classes[] = 'king-addons-woo-builder';
1179 $classes[] = 'king-addons-woo-builder--' . $this->active_context;
1180 if ($this->can_use_pro()) {
1181 $classes[] = 'king-addons-woo-builder--pro';
1182 }
1183 }
1184
1185 return $classes;
1186 }
1187
1188 /**
1189 * Register default Pro blocks (upsells, quick links) once.
1190 *
1191 * @return void
1192 */
1193 private function maybe_register_default_blocks(): void
1194 {
1195 if ($this->default_blocks_registered || !$this->can_use_pro()) {
1196 return;
1197 }
1198
1199 add_action('king_addons/woo_builder/after_render', [$this, 'render_default_block'], 10, 2);
1200 $this->default_blocks_registered = true;
1201 }
1202
1203 /**
1204 * Render default Pro blocks for supported contexts.
1205 *
1206 * @param string $context Context.
1207 * @param int $template_id Template id.
1208 *
1209 * @return void
1210 */
1211 public function render_default_block(string $context, int $template_id): void
1212 {
1213 if (!$this->can_use_pro()) {
1214 return;
1215 }
1216
1217 $disable_meta = get_post_meta($template_id, 'ka_woo_disable_autoblocks', true);
1218 if (!empty($disable_meta)) {
1219 return;
1220 }
1221
1222 if ('checkout' === $context) {
1223 $this->render_checkout_upsells();
1224 } elseif ('cart' === $context) {
1225 $this->render_cart_cross_sells();
1226 } elseif ('my_account' === $context) {
1227 $this->render_account_quick_links();
1228 }
1229 }
1230
1231 /**
1232 * Render checkout upsells block.
1233 *
1234 * @return void
1235 */
1236 private function render_checkout_upsells(): void
1237 {
1238 if (!function_exists('WC') || !WC()->cart || apply_filters('king_addons/woo_builder/disable_checkout_upsells', false)) {
1239 return;
1240 }
1241
1242 $cart_items = WC()->cart->get_cart();
1243 $upsell_ids = [];
1244 foreach ($cart_items as $item) {
1245 $product = $item['data'] ?? null;
1246 if ($product && is_object($product) && method_exists($product, 'get_upsell_ids')) {
1247 $upsell_ids = array_merge($upsell_ids, $product->get_upsell_ids());
1248 }
1249 }
1250 $upsell_ids = array_values(array_unique(array_filter(array_map('absint', $upsell_ids))));
1251
1252 /**
1253 * Allow custom upsell IDs (e.g., personalization/A/B).
1254 *
1255 * @param array<int> $upsell_ids Upsell product IDs.
1256 * @param array $cart_items Cart contents.
1257 */
1258 $upsell_ids = apply_filters('king_addons/woo_builder/checkout_upsell_ids', $upsell_ids, $cart_items);
1259
1260 if (empty($upsell_ids)) {
1261 // Fallback: same categories as cart items.
1262 $cat_ids = [];
1263 $tag_ids = [];
1264 foreach ($cart_items as $item) {
1265 $product_id = isset($item['product_id']) ? (int) $item['product_id'] : 0;
1266 if ($product_id) {
1267 $terms = wp_get_post_terms($product_id, 'product_cat', ['fields' => 'ids']);
1268 $cat_ids = array_merge($cat_ids, $terms);
1269 $tags = wp_get_post_terms($product_id, 'product_tag', ['fields' => 'ids']);
1270 $tag_ids = array_merge($tag_ids, $tags);
1271 }
1272 }
1273 $cat_ids = array_values(array_unique(array_filter(array_map('absint', $cat_ids))));
1274 $tag_ids = array_values(array_unique(array_filter(array_map('absint', $tag_ids))));
1275 if (!empty($cat_ids)) {
1276 $upsell_ids = get_posts(
1277 [
1278 'post_type' => 'product',
1279 'fields' => 'ids',
1280 'numberposts' => (int) apply_filters('king_addons/woo_builder/checkout_upsells_fallback_limit', 6),
1281 'tax_query' => [
1282 [
1283 'taxonomy' => 'product_cat',
1284 'field' => 'term_id',
1285 'terms' => $cat_ids,
1286 ],
1287 ],
1288 ]
1289 );
1290 } elseif (!empty($tag_ids)) {
1291 $upsell_ids = get_posts(
1292 [
1293 'post_type' => 'product',
1294 'fields' => 'ids',
1295 'numberposts' => (int) apply_filters('king_addons/woo_builder/checkout_upsells_tag_limit', 6),
1296 'tax_query' => [
1297 [
1298 'taxonomy' => 'product_tag',
1299 'field' => 'term_id',
1300 'terms' => $tag_ids,
1301 ],
1302 ],
1303 ]
1304 );
1305 }
1306 }
1307
1308 if (empty($upsell_ids)) {
1309 return;
1310 }
1311
1312 $limit = (int) apply_filters('king_addons/woo_builder/checkout_upsells_limit', 4);
1313 $query = new WP_Query(
1314 apply_filters(
1315 'king_addons/woo_builder/checkout_upsells_query_args',
1316 [
1317 'post_type' => 'product',
1318 'post__in' => $upsell_ids,
1319 'posts_per_page' => $limit,
1320 'orderby' => 'post__in',
1321 ]
1322 )
1323 );
1324
1325 if (!$query->have_posts()) {
1326 wp_reset_postdata();
1327 return;
1328 }
1329
1330 $title = apply_filters('king_addons/woo_builder/checkout_upsells_title', esc_html__('You may also like', 'king-addons'));
1331
1332 echo '<section class="ka-woo-checkout-upsells" aria-label="' . esc_attr($title) . '">';
1333 if ($title) {
1334 echo '<h3 class="ka-woo-checkout-upsells__title">' . esc_html($title) . '</h3>';
1335 }
1336 echo '<div class="ka-woo-checkout-upsells__grid">';
1337 while ($query->have_posts()) {
1338 $query->the_post();
1339 $product = wc_get_product(get_the_ID());
1340 if (!$product) {
1341 continue;
1342 }
1343 $this->render_simple_product_card($product);
1344 }
1345 echo '</div>';
1346 echo '</section>';
1347 wp_reset_postdata();
1348 }
1349
1350 /**
1351 * Render cart cross-sells fallback.
1352 *
1353 * @return void
1354 */
1355 private function render_cart_cross_sells(): void
1356 {
1357 if (!function_exists('woocommerce_cross_sell_display') || apply_filters('king_addons/woo_builder/disable_cart_cross_sells', false)) {
1358 return;
1359 }
1360 $cart_items = (function_exists('WC') && WC()->cart) ? WC()->cart->get_cart() : [];
1361 $suggested_ids = [];
1362 foreach ($cart_items as $item) {
1363 $product_id = isset($item['product_id']) ? (int) $item['product_id'] : 0;
1364 if ($product_id) {
1365 $tags = wp_get_post_terms($product_id, 'product_tag', ['fields' => 'ids']);
1366 $suggested_ids = array_merge($suggested_ids, $tags);
1367 }
1368 }
1369 $suggested_ids = array_values(array_unique(array_filter(array_map('absint', $suggested_ids))));
1370 $suggested_args = [];
1371 if (!empty($suggested_ids)) {
1372 $suggested_args = [
1373 'post_type' => 'product',
1374 'posts_per_page' => (int) apply_filters('king_addons/woo_builder/cart_cross_sells_suggested_limit', 4),
1375 'tax_query' => [
1376 [
1377 'taxonomy' => 'product_tag',
1378 'field' => 'term_id',
1379 'terms' => $suggested_ids,
1380 ],
1381 ],
1382 ];
1383 }
1384
1385 /**
1386 * Allow overriding cross-sell query (e.g., custom tags/meta).
1387 *
1388 * @param array<string,mixed> $args Query args, empty for Woo default.
1389 * @param array $cart_items Cart items.
1390 */
1391 $suggested_args = apply_filters('king_addons/woo_builder/cart_cross_sells_query_args', $suggested_args, $cart_items);
1392
1393 $title = apply_filters('king_addons/woo_builder/cart_cross_sells_title', esc_html__('You may also like', 'king-addons'));
1394 echo '<section class="ka-woo-cart-cross-sells" aria-label="' . esc_attr($title) . '">';
1395 if ($title) {
1396 echo '<h3 class="ka-woo-cart-cross-sells__title">' . esc_html($title) . '</h3>';
1397 }
1398 if (!empty($suggested_args)) {
1399 $loop = new WP_Query($suggested_args);
1400 if ($loop->have_posts()) {
1401 echo '<div class="ka-woo-checkout-upsells__grid">';
1402 while ($loop->have_posts()) {
1403 $loop->the_post();
1404 $product = wc_get_product(get_the_ID());
1405 if ($product) {
1406 $this->render_simple_product_card($product);
1407 }
1408 }
1409 echo '</div>';
1410 }
1411 wp_reset_postdata();
1412 } else {
1413 woocommerce_cross_sell_display(
1414 (int) apply_filters('king_addons/woo_builder/cart_cross_sells_limit', 4),
1415 (int) apply_filters('king_addons/woo_builder/cart_cross_sells_columns', 4)
1416 );
1417 }
1418 echo '</section>';
1419 }
1420
1421 /**
1422 * Render My Account quick links.
1423 *
1424 * @return void
1425 */
1426 private function render_account_quick_links(): void
1427 {
1428 if (!function_exists('wc_get_account_endpoint_url') || apply_filters('king_addons/woo_builder/disable_account_quick_links', false)) {
1429 return;
1430 }
1431
1432 $links = apply_filters(
1433 'king_addons/woo_builder/account_quick_links',
1434 [
1435 'orders' => esc_html__('Orders', 'king-addons'),
1436 'downloads' => esc_html__('Downloads', 'king-addons'),
1437 'edit-address' => esc_html__('Addresses', 'king-addons'),
1438 'payment-methods' => esc_html__('Payment Methods', 'king-addons'),
1439 'edit-account' => esc_html__('Account Details', 'king-addons'),
1440 ]
1441 );
1442
1443 echo '<nav class="ka-woo-account-quick-links" aria-label="' . esc_attr__('Account quick links', 'king-addons') . '">';
1444 echo '<ul class="ka-woo-account-quick-links__list">';
1445 foreach ($links as $endpoint => $label) {
1446 $url = wc_get_account_endpoint_url($endpoint);
1447 if (!$url) {
1448 continue;
1449 }
1450 echo '<li class="ka-woo-account-quick-links__item">';
1451 echo '<a class="ka-woo-account-quick-links__link" href="' . esc_url($url) . '">' . esc_html($label) . '</a>';
1452 echo '</li>';
1453 }
1454 echo '</ul>';
1455 echo '</nav>';
1456
1457 /**
1458 * Hook to output additional custom blocks in My Account (Pro).
1459 *
1460 * @param array<string,string> $links Current quick links.
1461 */
1462 do_action('king_addons/woo_builder/account_after_quick_links', $links);
1463 }
1464
1465 /**
1466 * Render a simple product card for upsells.
1467 *
1468 * @param \WC_Product $product Product instance.
1469 *
1470 * @return void
1471 */
1472 private function render_simple_product_card(\WC_Product $product): void
1473 {
1474 $link = get_permalink($product->get_id());
1475 $image = $product->get_image('woocommerce_thumbnail');
1476 $title = $product->get_name();
1477 $price = $product->get_price_html();
1478
1479 echo '<article class="ka-woo-checkout-upsells__item">';
1480 echo '<a class="ka-woo-checkout-upsells__thumb" href="' . esc_url($link) . '">' . wp_kses_post($image) . '</a>';
1481 echo '<h4 class="ka-woo-checkout-upsells__title"><a href="' . esc_url($link) . '">' . esc_html($title) . '</a></h4>';
1482 if (!empty($price)) {
1483 echo '<div class="ka-woo-checkout-upsells__price">' . wp_kses_post($price) . '</div>';
1484 }
1485 echo $this->render_add_to_cart_button($product); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1486 echo '</article>';
1487 }
1488
1489 /**
1490 * Render a controlled Add to Cart button without relying on Woo shortcode.
1491 *
1492 * @param \WC_Product $product Product instance.
1493 *
1494 * @return string
1495 */
1496 private function render_add_to_cart_button(\WC_Product $product): string
1497 {
1498 if (!$product->is_purchasable() || !$product->is_in_stock()) {
1499 return '<span class="ka-woo-checkout-upsells__add-to-cart button is-disabled">' . esc_html__('Unavailable', 'king-addons') . '</span>';
1500 }
1501
1502 if (function_exists('wp_enqueue_script') && function_exists('wp_script_is') && wp_script_is('wc-add-to-cart', 'registered')) {
1503 wp_enqueue_script('wc-add-to-cart');
1504 }
1505
1506 $classes = [
1507 'ka-woo-checkout-upsells__add-to-cart',
1508 'button',
1509 'product_type_' . $product->get_type(),
1510 ];
1511
1512 if ($product->supports('ajax_add_to_cart')) {
1513 $classes[] = 'ajax_add_to_cart';
1514 $classes[] = 'add_to_cart_button';
1515 }
1516
1517 $attributes = [
1518 'href' => $product->add_to_cart_url(),
1519 'data-quantity' => 1,
1520 'data-product_id' => $product->get_id(),
1521 'data-product_sku' => $product->get_sku(),
1522 'rel' => 'nofollow',
1523 'class' => implode(' ', array_filter($classes)),
1524 'aria-label' => wp_strip_all_tags($product->add_to_cart_description()),
1525 ];
1526
1527 return '<a ' . wc_implode_html_attributes($attributes) . '><span class="ka-woo-checkout-upsells__add-to-cart-label">' . esc_html($product->add_to_cart_text()) . '</span></a>';
1528 }
1529
1530 /**
1531 * Prefill meta values when creating a new Elementor template via custom links.
1532 *
1533 * @param mixed $value Default meta value.
1534 * @param int $object_id Object ID.
1535 * @param string $meta_key Meta key.
1536 * @param bool $single Whether meta is single.
1537 * @param string|null $meta_type Meta type.
1538 *
1539 * @return mixed
1540 */
1541 public function prefill_template_meta($value, int $object_id, string $meta_key, bool $single, ?string $meta_type)
1542 {
1543 if ('post' !== $meta_type) {
1544 return $value;
1545 }
1546
1547 $requested_type = isset($_GET['ka_woo_template_type'])
1548 ? sanitize_text_field(wp_unslash($_GET['ka_woo_template_type']))
1549 : '';
1550
1551 if (empty($requested_type)) {
1552 return $value;
1553 }
1554
1555 $post_type = isset($_GET['post_type']) ? sanitize_text_field(wp_unslash($_GET['post_type'])) : '';
1556
1557 if ('elementor_library' !== $post_type) {
1558 return $value;
1559 }
1560
1561 if ('ka_woo_template_type' === $meta_key) {
1562 return $requested_type;
1563 }
1564
1565 if ('ka_woo_conditions' === $meta_key) {
1566 return [
1567 'enabled' => true,
1568 'priority' => 10,
1569 'rules' => [
1570 [
1571 'type' => 'always',
1572 'values' => [],
1573 ],
1574 ],
1575 ];
1576 }
1577 if ('_elementor_template_type' === $meta_key) {
1578 return class_exists(Woo_Document::class) ? Woo_Document::get_type() : 'king-addons-woo-builder';
1579 }
1580
1581 return $value;
1582 }
1583
1584 /**
1585 * Save template type meta when any post is inserted (catches auto-draft creation).
1586 *
1587 * @param int $post_id Post ID.
1588 * @param \WP_Post $post Post object.
1589 * @param bool $update Whether this is an update.
1590 *
1591 * @return void
1592 */
1593 public function save_template_type_on_insert(int $post_id, \WP_Post $post, bool $update): void
1594 {
1595 // Only for elementor_library posts
1596 if ('elementor_library' !== $post->post_type) {
1597 return;
1598 }
1599
1600 // Skip if meta already exists
1601 if (get_post_meta($post_id, 'ka_woo_template_type', true)) {
1602 return;
1603 }
1604
1605 // Get template type from request or transient
1606 $template_type = $this->get_template_type_from_request();
1607
1608 // If not found in request, check transient
1609 if (empty($template_type)) {
1610 $user_id = get_current_user_id();
1611 $transient_key = 'ka_woo_pending_template_type_' . $user_id;
1612 $template_type = get_transient($transient_key);
1613 if ($template_type) {
1614 delete_transient($transient_key);
1615 }
1616 }
1617
1618 if (empty($template_type)) {
1619 return;
1620 }
1621
1622 // Save the template type meta
1623 update_post_meta($post_id, 'ka_woo_template_type', $template_type);
1624
1625 // Set Elementor template type
1626 $document_type = class_exists(Woo_Document::class) ? Woo_Document::get_type() : 'king-addons-woo-builder';
1627 update_post_meta($post_id, '_elementor_template_type', $document_type);
1628
1629 // Set default conditions (enabled by default)
1630 $default_conditions = [
1631 'enabled' => true,
1632 'priority' => 10,
1633 'rules' => [
1634 [
1635 'type' => 'always',
1636 'values' => [],
1637 ],
1638 ],
1639 ];
1640 update_post_meta($post_id, 'ka_woo_conditions', $default_conditions);
1641 }
1642
1643 /**
1644 * Set WooCommerce template meta when creating a new post from URL parameter.
1645 * This runs on admin_init to catch post creation before Elementor takes over.
1646 *
1647 * @return void
1648 */
1649 public function maybe_set_woo_template_meta_on_new_post(): void
1650 {
1651 global $pagenow;
1652
1653 // Only on post-new.php for elementor_library
1654 if ('post-new.php' !== $pagenow) {
1655 return;
1656 }
1657
1658 $post_type = isset($_GET['post_type']) ? sanitize_text_field(wp_unslash($_GET['post_type'])) : '';
1659 if ('elementor_library' !== $post_type) {
1660 return;
1661 }
1662
1663 // Get template type from URL
1664 $template_type = $this->get_template_type_from_request();
1665 if (empty($template_type)) {
1666 return;
1667 }
1668
1669 // Store in session/transient for later use when the post is actually created
1670 $user_id = get_current_user_id();
1671 set_transient('ka_woo_pending_template_type_' . $user_id, $template_type, 300);
1672 }
1673
1674 /**
1675 * Save template type from URL parameter when post is created.
1676 *
1677 * @param int $post_id Post ID.
1678 * @param \WP_Post $post Post object.
1679 * @param bool $update Whether this is an update.
1680 *
1681 * @return void
1682 */
1683 public function save_template_type_from_url(int $post_id, \WP_Post $post, bool $update): void
1684 {
1685 // Skip if meta already exists
1686 if (get_post_meta($post_id, 'ka_woo_template_type', true)) {
1687 return;
1688 }
1689
1690 // Get template type from request or transient
1691 $template_type = $this->get_template_type_from_request();
1692
1693 // If not found in request, check transient
1694 if (empty($template_type)) {
1695 $user_id = get_current_user_id();
1696 $transient_key = 'ka_woo_pending_template_type_' . $user_id;
1697 $template_type = get_transient($transient_key);
1698 if ($template_type) {
1699 delete_transient($transient_key);
1700 }
1701 }
1702
1703 if (empty($template_type)) {
1704 return;
1705 }
1706
1707 // Save the template type meta
1708 update_post_meta($post_id, 'ka_woo_template_type', $template_type);
1709
1710 // Set Elementor template type
1711 $document_type = class_exists(Woo_Document::class) ? Woo_Document::get_type() : 'king-addons-woo-builder';
1712 update_post_meta($post_id, '_elementor_template_type', $document_type);
1713
1714 // Set default conditions (enabled by default)
1715 $conditions = [
1716 'enabled' => true,
1717 'priority' => 10,
1718 'rules' => [
1719 [
1720 'type' => 'always',
1721 'values' => [],
1722 ],
1723 ],
1724 ];
1725 update_post_meta($post_id, 'ka_woo_conditions', $conditions);
1726 }
1727
1728 /**
1729 * Get template type from current request or referrer URL.
1730 *
1731 * @return string Template type or empty string.
1732 */
1733 private function get_template_type_from_request(): string
1734 {
1735 $valid_types = ['single_product', 'product_archive', 'cart', 'checkout', 'my_account'];
1736 $template_type = '';
1737
1738 // Check current request
1739 if (isset($_GET['ka_woo_template_type'])) {
1740 $template_type = sanitize_text_field(wp_unslash($_GET['ka_woo_template_type']));
1741 }
1742
1743 // Check POST data
1744 if (empty($template_type) && isset($_POST['ka_woo_template_type'])) {
1745 $template_type = sanitize_text_field(wp_unslash($_POST['ka_woo_template_type']));
1746 }
1747
1748 // Check referrer URL
1749 if (empty($template_type) && isset($_SERVER['HTTP_REFERER'])) {
1750 $referrer = wp_unslash($_SERVER['HTTP_REFERER']);
1751 $parsed = wp_parse_url($referrer);
1752 if (!empty($parsed['query'])) {
1753 parse_str($parsed['query'], $query_args);
1754 if (!empty($query_args['ka_woo_template_type'])) {
1755 $template_type = sanitize_text_field($query_args['ka_woo_template_type']);
1756 }
1757 }
1758 }
1759
1760 // Validate
1761 if (!in_array($template_type, $valid_types, true)) {
1762 return '';
1763 }
1764
1765 // Check Pro restriction
1766 if (!$this->can_use_pro() && in_array($template_type, ['cart', 'checkout', 'my_account'], true)) {
1767 return '';
1768 }
1769
1770 return $template_type;
1771 }
1772
1773 /**
1774 * Check Pro availability.
1775 *
1776 * @return bool
1777 */
1778 private function can_use_pro(): bool
1779 {
1780 // Was a private copy of the same freemius check. Keeping a second copy
1781 // meant this gate could drift from the one every widget uses, and it
1782 // already did not follow filters applied to the shared helper.
1783 if (!function_exists('king_addons_can_use_pro')) {
1784 return false;
1785 }
1786
1787 return king_addons_can_use_pro();
1788 }
1789
1790 /**
1791 * Map rule values to human-readable labels for select pre-fill.
1792 *
1793 * @param string $type Rule type.
1794 * @param array $values Raw values.
1795 *
1796 * @return array<string,string>
1797 */
1798 private function get_rule_value_options(string $type, array $values): array
1799 {
1800 if (empty($values)) {
1801 return [];
1802 }
1803
1804 $options = [];
1805 $string_values = array_map('strval', $values);
1806
1807 switch ($type) {
1808 case 'product_in':
1809 case 'products':
1810 $products = get_posts([
1811 'post_type' => 'product',
1812 'post__in' => array_map('intval', $values),
1813 'numberposts' => -1,
1814 ]);
1815 foreach ($products as $product) {
1816 $options[(string) $product->ID] = $product->post_title;
1817 }
1818 break;
1819 case 'product_cat_in':
1820 case 'product_cat_archive_in':
1821 case 'product_categories':
1822 case 'product_cat_archives':
1823 $terms = get_terms([
1824 'taxonomy' => 'product_cat',
1825 'include' => array_map('intval', $values),
1826 'hide_empty' => false,
1827 ]);
1828 foreach ($terms as $term) {
1829 $options[(string) $term->term_id] = $term->name;
1830 }
1831 break;
1832 case 'product_tag_in':
1833 case 'product_tag_archive_in':
1834 case 'product_tag_archives':
1835 case 'product_tags':
1836 $terms = get_terms([
1837 'taxonomy' => 'product_tag',
1838 'include' => array_map('intval', $values),
1839 'hide_empty' => false,
1840 ]);
1841 foreach ($terms as $term) {
1842 $options[(string) $term->term_id] = $term->name;
1843 }
1844 break;
1845 case 'product_type_in':
1846 case 'product_types':
1847 $types = [
1848 'simple' => esc_html__('Simple', 'king-addons'),
1849 'variable' => esc_html__('Variable', 'king-addons'),
1850 'grouped' => esc_html__('Grouped', 'king-addons'),
1851 'external' => esc_html__('External/Affiliate', 'king-addons'),
1852 ];
1853 foreach ($string_values as $v) {
1854 if (isset($types[$v])) {
1855 $options[$v] = $types[$v];
1856 }
1857 }
1858 break;
1859 default:
1860 foreach ($string_values as $v) {
1861 $options[$v] = $v;
1862 }
1863 }
1864
1865 return $options;
1866 }
1867
1868 /**
1869 * Sanitize rule values from Elementor document settings.
1870 *
1871 * @param mixed $values Raw values.
1872 * @param bool $numeric Whether values should be numeric IDs.
1873 *
1874 * @return array<int|string>
1875 */
1876 private function sanitize_rule_values($values, bool $numeric): array
1877 {
1878 if (is_string($values)) {
1879 $values = explode(',', $values);
1880 }
1881
1882 if (!is_array($values)) {
1883 return [];
1884 }
1885
1886 $values = array_filter(array_map('strval', $values), 'strlen');
1887
1888 if ($numeric) {
1889 $values = array_values(array_unique(array_filter(array_map('absint', $values))));
1890 } else {
1891 $values = array_values(array_unique(array_filter(array_map('sanitize_text_field', $values), 'strlen')));
1892 }
1893
1894 return $values;
1895 }
1896
1897 /**
1898 * Normalize legacy rule type slugs to current ones.
1899 *
1900 * @param string $type Rule type.
1901 *
1902 * @return string
1903 */
1904 private function normalize_rule_type(string $type): string
1905 {
1906 $map = [
1907 'products' => 'product_in',
1908 'product_categories' => 'product_cat_in',
1909 'product_tags' => 'product_tag_in',
1910 'product_types' => 'product_type_in',
1911 'shop' => 'is_shop',
1912 'product_cat_archives' => 'product_cat_archive_in',
1913 'product_tag_archives' => 'product_tag_archive_in',
1914 ];
1915
1916 return $map[$type] ?? $type;
1917 }
1918 }
1919