PluginProbe
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder / 51.1.83
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder v51.1.83
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 51.1.46 51.1.47 51.1.49 All 37 releases
king-addons / includes / extensions / Free_Shipping_Bar / Free_Shipping_Bar.php

Free_Shipping_Bar.php in King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder 51.1.83, at includes/extensions/Free_Shipping_Bar/Free_Shipping_Bar.php

628 lines 19.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Free Shipping Progress Bar.
4 *
5 * Shows how much a customer still has to add to qualify for free shipping.
6 * Reads the threshold from the store's own WooCommerce shipping zones, so it
7 * keeps working when the merchant changes the rule.
8 *
9 * @package King_Addons
10 */
11
12 namespace King_Addons;
13
14 if (!defined('ABSPATH')) {
15 exit;
16 }
17
18 /**
19 * Free shipping progress bar module.
20 */
21 final class Free_Shipping_Bar
22 {
23 /**
24 * Settings option key.
25 */
26 public const OPTION_KEY = 'king_addons_free_shipping_bar_settings';
27
28 /**
29 * Singleton instance.
30 *
31 * @var Free_Shipping_Bar|null
32 */
33 private static ?Free_Shipping_Bar $instance = null;
34
35 /**
36 * Cached settings.
37 *
38 * @var array<string, mixed>
39 */
40 private array $settings = [];
41
42 /**
43 * Guards against printing the bar twice on one request.
44 *
45 * @var bool
46 */
47 private bool $printed = false;
48
49 /**
50 * Singleton accessor.
51 *
52 * @return Free_Shipping_Bar
53 */
54 public static function instance(): Free_Shipping_Bar
55 {
56 if (is_null(self::$instance)) {
57 self::$instance = new self();
58 }
59
60 return self::$instance;
61 }
62
63 /**
64 * Hooks.
65 */
66 public function __construct()
67 {
68 $this->settings = $this->get_settings();
69
70 add_action('admin_menu', [$this, 'register_admin_menu'], 15);
71 add_action('admin_post_king_addons_fsb_save', [$this, 'handle_save']);
72
73 add_action('wp_enqueue_scripts', [$this, 'maybe_enqueue_assets']);
74
75 // Classic (shortcode) cart and checkout.
76 add_action('woocommerce_before_cart', [$this, 'render_for_cart'], 5);
77 add_action('woocommerce_before_checkout_form', [$this, 'render_for_checkout'], 5);
78
79 // Block cart and checkout are the default for new stores. They fire
80 // neither the classic hooks nor the_content, so hook the blocks directly.
81 add_filter('render_block_woocommerce/cart', [$this, 'prepend_to_cart_block'], 10, 1);
82 add_filter('render_block_woocommerce/checkout', [$this, 'prepend_to_checkout_block'], 10, 1);
83
84 // Block-theme mini-cart never fires woocommerce_before_mini_cart.
85 add_filter('render_block_woocommerce/filled-mini-cart-contents-block', [$this, 'prepend_to_mini_cart_block'], 10, 1);
86
87 // Woo Builder cart/checkout templates skip the classic Woo hooks.
88 add_action('king_addons/woo_builder/before_render', [$this, 'render_for_woo_builder'], 5, 2);
89
90 // Mini-cart placement (also refreshed by WooCommerce fragments).
91 add_action('woocommerce_before_mini_cart', [$this, 'render_for_mini_cart'], 5);
92
93 // Keep the bar current after AJAX cart updates.
94 add_filter('woocommerce_add_to_cart_fragments', [$this, 'add_cart_fragment']);
95 add_action('wp_ajax_king_addons_fsb_markup', [$this, 'ajax_markup']);
96 add_action('wp_ajax_nopriv_king_addons_fsb_markup', [$this, 'ajax_markup']);
97 }
98
99 /**
100 * Default settings.
101 *
102 * @return array<string, mixed>
103 */
104 public function get_default_settings(): array
105 {
106 return [
107 'enabled' => false,
108 'threshold_source' => 'auto',
109 'manual_amount' => '',
110 'show_on_cart' => 'yes',
111 'show_on_checkout' => 'yes',
112 'show_in_mini_cart' => 'yes',
113 'text_progress' => __('Add {remaining} more and your shipping is on us.', 'king-addons'),
114 'text_success' => __('Free shipping unlocked.', 'king-addons'),
115 'bar_height' => 8,
116 'bar_color' => '#2f9e5f',
117 'bar_track_color' => '#e6e6e6',
118 'text_color' => '#1f2933',
119 'hide_when_reached' => '',
120 ];
121 }
122
123 /**
124 * Settings with defaults applied.
125 *
126 * @return array<string, mixed>
127 */
128 public function get_settings(): array
129 {
130 $saved = get_option(self::OPTION_KEY, []);
131 if (!is_array($saved)) {
132 $saved = [];
133 }
134
135 return array_merge($this->get_default_settings(), $saved);
136 }
137
138 /**
139 * Whether the module should render at all.
140 *
141 * @return bool
142 */
143 private function is_active(): bool
144 {
145 if (empty($this->settings['enabled'])) {
146 return false;
147 }
148
149 // admin-ajax.php reports is_admin() === true, so treating every admin
150 // request as "off" dropped the bar from cart-table AJAX fragments and
151 // from woocommerce_add_to_cart_fragments.
152 $front_or_ajax = !is_admin() || wp_doing_ajax();
153
154 return class_exists('WooCommerce') && $front_or_ajax;
155 }
156
157 /**
158 * Free shipping threshold for the current customer, or null when the store
159 * has no amount-based free shipping rule.
160 *
161 * @return float|null
162 */
163 public function get_threshold(): ?float
164 {
165 if ('manual' === ($this->settings['threshold_source'] ?? 'auto')) {
166 $amount = (float) ($this->settings['manual_amount'] ?? 0);
167
168 return $amount > 0 ? $amount : null;
169 }
170
171 if (!function_exists('WC') || !class_exists('WC_Shipping_Zones')) {
172 return null;
173 }
174
175 $package = ['destination' => $this->get_package_destination()];
176 if (WC()->cart && method_exists(WC()->cart, 'get_shipping_packages')) {
177 $packages = WC()->cart->get_shipping_packages();
178 if (!empty($packages)) {
179 $package = reset($packages);
180 }
181 }
182
183 $zone = \WC_Shipping_Zones::get_zone_matching_package($package);
184 if (!$zone) {
185 return null;
186 }
187
188 $amounts = [];
189 foreach ($zone->get_shipping_methods(true) as $method) {
190 if ('free_shipping' !== $method->id) {
191 continue;
192 }
193
194 $requires = $method->get_option('requires');
195 if (!in_array($requires, ['min_amount', 'either', 'both'], true)) {
196 continue;
197 }
198
199 $amount = (float) $method->get_option('min_amount');
200 if ($amount > 0) {
201 $amounts[] = $amount;
202 }
203 }
204
205 if (empty($amounts)) {
206 return null;
207 }
208
209 // A zone can carry several free shipping methods; the cheapest one is
210 // the one the customer will actually hit first.
211 return (float) min($amounts);
212 }
213
214 /**
215 * A destination WooCommerce can match a shipping zone against.
216 *
217 * WC_Shipping_Zones::get_zone_matching_package() reads country, state and
218 * postcode straight out of the array with no checks of its own, so handing
219 * it an empty destination - which is what happens in wp-admin, where there
220 * is no cart - printed six "Undefined array key" warnings over the settings
221 * screen.
222 *
223 * @return array<string,string>
224 */
225 private function get_package_destination(): array
226 {
227 $destination = [
228 'country' => '',
229 'state' => '',
230 'postcode' => '',
231 'city' => '',
232 'address' => '',
233 'address_2' => '',
234 ];
235
236 $customer = function_exists('WC') ? WC()->customer : null;
237
238 if ($customer) {
239 $destination['country'] = (string) $customer->get_shipping_country();
240 $destination['state'] = (string) $customer->get_shipping_state();
241 $destination['postcode'] = (string) $customer->get_shipping_postcode();
242 $destination['city'] = (string) $customer->get_shipping_city();
243 }
244
245 // No customer session, which is the normal case in wp-admin: fall back
246 // to where the store itself is, so the detected threshold is the one
247 // most customers will see.
248 if ('' === $destination['country'] && function_exists('wc_get_base_location')) {
249 $base = wc_get_base_location();
250 $destination['country'] = (string) ($base['country'] ?? '');
251 $destination['state'] = (string) ($base['state'] ?? '');
252 }
253
254 return $destination;
255 }
256
257 /**
258 * Cart total the threshold is measured against.
259 *
260 * @return float
261 */
262 private function get_cart_total(): float
263 {
264 if (!function_exists('WC') || !WC()->cart) {
265 return 0.0;
266 }
267
268 // Subtotal after discounts, excluding shipping - this is what
269 // WooCommerce's own free shipping method compares.
270 $total = (float) WC()->cart->get_displayed_subtotal();
271
272 if (WC()->cart->display_prices_including_tax()) {
273 $total -= (float) WC()->cart->get_discount_tax();
274 }
275
276 $total -= (float) WC()->cart->get_discount_total();
277
278 return max(0.0, $total);
279 }
280
281 /**
282 * Build the bar markup, or an empty string when there is nothing to show.
283 *
284 * @param bool $mark_printed Whether this render should block a second cart/checkout copy.
285 *
286 * @return string
287 */
288 public function get_markup(bool $mark_printed = true): string
289 {
290 if (!$this->is_active()) {
291 return '';
292 }
293
294 $threshold = $this->get_threshold();
295 if (null === $threshold || $threshold <= 0) {
296 return '';
297 }
298
299 $total = $this->get_cart_total();
300 $reached = $total >= $threshold;
301
302 if ($reached && !empty($this->settings['hide_when_reached'])) {
303 return '';
304 }
305
306 $remaining = max(0.0, $threshold - $total);
307 $percent = $threshold > 0 ? min(100, ($total / $threshold) * 100) : 0;
308
309 $text = $reached
310 ? (string) $this->settings['text_success']
311 : str_replace(
312 ['{remaining}', '{total}', '{threshold}'],
313 [wc_price($remaining), wc_price($total), wc_price($threshold)],
314 (string) $this->settings['text_progress']
315 );
316
317 $style = sprintf(
318 '--ka-fsb-height:%dpx;--ka-fsb-color:%s;--ka-fsb-track:%s;--ka-fsb-text:%s;',
319 (int) $this->settings['bar_height'],
320 sanitize_hex_color((string) $this->settings['bar_color']) ?: '#2f9e5f',
321 sanitize_hex_color((string) $this->settings['bar_track_color']) ?: '#e6e6e6',
322 sanitize_hex_color((string) $this->settings['text_color']) ?: '#1f2933'
323 );
324
325 ob_start();
326 ?>
327 <div class="king-addons-fsb<?php echo $reached ? ' is-reached' : ''; ?>" style="<?php echo esc_attr($style); ?>">
328 <p class="king-addons-fsb__text">
329 <?php
330 // wc_price() returns markup; the surrounding text is translator-supplied.
331 echo wp_kses_post($text);
332 ?>
333 </p>
334 <div class="king-addons-fsb__track" role="progressbar"
335 aria-valuemin="0" aria-valuemax="100"
336 aria-valuenow="<?php echo esc_attr((string) round($percent)); ?>">
337 <span class="king-addons-fsb__fill" style="width:<?php echo esc_attr((string) round($percent, 2)); ?>%"></span>
338 </div>
339 </div>
340 <?php
341
342 if ($mark_printed) {
343 $this->printed = true;
344 }
345
346 return (string) ob_get_clean();
347 }
348
349 /**
350 * Prepend the bar to the Cart block.
351 *
352 * @param string $block_content Rendered block markup.
353 *
354 * @return string
355 */
356 public function prepend_to_cart_block(string $block_content): string
357 {
358 if (!$this->is_active() || $this->printed || empty($this->settings['show_on_cart'])) {
359 return $block_content;
360 }
361
362 return $this->get_markup() . $block_content;
363 }
364
365 /**
366 * Prepend the bar to the Checkout block.
367 *
368 * @param string $block_content Rendered block markup.
369 *
370 * @return string
371 */
372 public function prepend_to_checkout_block(string $block_content): string
373 {
374 if (!$this->is_active() || $this->printed || empty($this->settings['show_on_checkout'])) {
375 return $block_content;
376 }
377
378 return $this->get_markup() . $block_content;
379 }
380
381 /**
382 * Prepend the bar to the filled Mini-Cart block (block themes).
383 *
384 * @param string $block_content Rendered block markup.
385 *
386 * @return string
387 */
388 public function prepend_to_mini_cart_block(string $block_content): string
389 {
390 if (!$this->is_active() || empty($this->settings['show_in_mini_cart'])) {
391 return $block_content;
392 }
393
394 $markup = $this->get_markup(false);
395 if ('' === $markup) {
396 return $block_content;
397 }
398
399 $injected = preg_replace('/(<div\b[^>]*>)/', '$1' . $markup, $block_content, 1);
400
401 return is_string($injected) ? $injected : $markup . $block_content;
402 }
403
404 /**
405 * Woo Builder cart/checkout placement.
406 *
407 * @param string $context Template context.
408 * @param mixed $template_id Template id.
409 *
410 * @return void
411 */
412 public function render_for_woo_builder(string $context, $template_id): void
413 {
414 unset($template_id);
415
416 if ('cart' === $context) {
417 $this->render_for_cart();
418 return;
419 }
420
421 if ('checkout' === $context) {
422 $this->render_for_checkout();
423 }
424 }
425
426 /**
427 * Fresh markup for JS refreshers (block mini-cart quantity changes).
428 *
429 * @return void
430 */
431 public function ajax_markup(): void
432 {
433 check_ajax_referer('king_addons_fsb', 'nonce');
434
435 $this->printed = false;
436
437 wp_send_json_success(['html' => $this->get_markup(false)]);
438 }
439
440 /**
441 * Cart page placement.
442 *
443 * @return void
444 */
445 public function render_for_cart(): void
446 {
447 // Cart Table / coupon AJAX re-renders cart/cart.php. That template
448 // fires woocommerce_before_cart, which would nest a second bar inside
449 // the table fragment and leave the page-level bar stale.
450 if (wp_doing_ajax() || $this->printed || empty($this->settings['show_on_cart'])) {
451 return;
452 }
453
454 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
455 echo $this->get_markup();
456 }
457
458 /**
459 * Checkout placement.
460 *
461 * @return void
462 */
463 public function render_for_checkout(): void
464 {
465 if (wp_doing_ajax() || $this->printed || empty($this->settings['show_on_checkout'])) {
466 return;
467 }
468
469 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
470 echo $this->get_markup();
471 }
472
473 /**
474 * Mini-cart placement.
475 *
476 * @return void
477 */
478 public function render_for_mini_cart(): void
479 {
480 if (empty($this->settings['show_in_mini_cart'])) {
481 return;
482 }
483
484 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
485 echo $this->get_markup();
486 }
487
488 /**
489 * Refresh the bar through WooCommerce's own fragment mechanism.
490 *
491 * @param array<string, string> $fragments Cart fragments.
492 *
493 * @return array<string, string>
494 */
495 public function add_cart_fragment(array $fragments): array
496 {
497 if (!$this->is_active() || empty($this->settings['show_in_mini_cart'])) {
498 return $fragments;
499 }
500
501 $markup = $this->get_markup();
502 if ('' !== $markup) {
503 $fragments['div.king-addons-fsb'] = $markup;
504 }
505
506 return $fragments;
507 }
508
509 /**
510 * Front-end assets.
511 *
512 * @return void
513 */
514 public function maybe_enqueue_assets(): void
515 {
516 if (!$this->is_active()) {
517 return;
518 }
519
520 $dir = KING_ADDONS_PATH . 'includes/extensions/Free_Shipping_Bar/assets/';
521 $css_ver = file_exists($dir . 'style.css') ? (string) filemtime($dir . 'style.css') : KING_ADDONS_VERSION;
522 $js_ver = file_exists($dir . 'script.js') ? (string) filemtime($dir . 'script.js') : KING_ADDONS_VERSION;
523
524 wp_enqueue_style(
525 KING_ADDONS_ASSETS_UNIQUE_KEY . '-free-shipping-bar',
526 KING_ADDONS_URL . 'includes/extensions/Free_Shipping_Bar/assets/style.css',
527 [],
528 $css_ver
529 );
530
531 wp_enqueue_script(
532 KING_ADDONS_ASSETS_UNIQUE_KEY . '-free-shipping-bar',
533 KING_ADDONS_URL . 'includes/extensions/Free_Shipping_Bar/assets/script.js',
534 [],
535 $js_ver,
536 true
537 );
538
539 wp_localize_script(
540 KING_ADDONS_ASSETS_UNIQUE_KEY . '-free-shipping-bar',
541 'kingAddonsFsb',
542 [
543 'ajaxUrl' => admin_url('admin-ajax.php'),
544 'nonce' => wp_create_nonce('king_addons_fsb'),
545 ]
546 );
547 }
548
549 /**
550 * Admin submenu.
551 *
552 * @return void
553 */
554 public function register_admin_menu(): void
555 {
556 add_submenu_page(
557 king_addons_woo_admin_parent_slug(),
558 esc_html__('Free Shipping Bar', 'king-addons'),
559 esc_html__('Free Shipping Bar', 'king-addons'),
560 'manage_options',
561 'king-addons-free-shipping-bar',
562 [$this, 'render_admin_page']
563 );
564 }
565
566 /**
567 * Save handler.
568 *
569 * @return void
570 */
571 public function handle_save(): void
572 {
573 if (!current_user_can('manage_options')) {
574 wp_die(esc_html__('You are not allowed to do this.', 'king-addons'));
575 }
576
577 check_admin_referer('king_addons_fsb_save');
578
579 $raw = isset($_POST['ka_fsb']) && is_array($_POST['ka_fsb'])
580 ? wp_unslash($_POST['ka_fsb']) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
581 : [];
582
583 $defaults = $this->get_default_settings();
584
585 $clean = [
586 'enabled' => !empty($raw['enabled']),
587 'threshold_source' => in_array(($raw['threshold_source'] ?? 'auto'), ['auto', 'manual'], true)
588 ? $raw['threshold_source'] : 'auto',
589 'manual_amount' => is_numeric($raw['manual_amount'] ?? '') ? (float) $raw['manual_amount'] : '',
590 'show_on_cart' => !empty($raw['show_on_cart']) ? 'yes' : '',
591 'show_on_checkout' => !empty($raw['show_on_checkout']) ? 'yes' : '',
592 'show_in_mini_cart' => !empty($raw['show_in_mini_cart']) ? 'yes' : '',
593 'text_progress' => sanitize_text_field((string) ($raw['text_progress'] ?? $defaults['text_progress'])),
594 'text_success' => sanitize_text_field((string) ($raw['text_success'] ?? $defaults['text_success'])),
595 'bar_height' => max(2, min(40, (int) ($raw['bar_height'] ?? $defaults['bar_height']))),
596 'bar_color' => sanitize_hex_color((string) ($raw['bar_color'] ?? '')) ?: $defaults['bar_color'],
597 'bar_track_color' => sanitize_hex_color((string) ($raw['bar_track_color'] ?? '')) ?: $defaults['bar_track_color'],
598 'text_color' => sanitize_hex_color((string) ($raw['text_color'] ?? '')) ?: $defaults['text_color'],
599 'hide_when_reached' => !empty($raw['hide_when_reached']) ? 'yes' : '',
600 ];
601
602 update_option(self::OPTION_KEY, $clean);
603
604 wp_safe_redirect(add_query_arg(
605 ['page' => 'king-addons-free-shipping-bar', 'ka-saved' => '1'],
606 admin_url('admin.php')
607 ));
608 exit;
609 }
610
611 /**
612 * Settings screen.
613 *
614 * @return void
615 */
616 public function render_admin_page(): void
617 {
618 if (!current_user_can('manage_options')) {
619 return;
620 }
621
622 $s = $this->get_settings();
623 $detected = $this->get_threshold();
624
625 require __DIR__ . '/templates/admin-page.php';
626 }
627 }
628