PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / utils / helper.php

helper.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/utils/helper.php

2,378 lines 75.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php /** @noinspection PhpUnused */
2
3 namespace StoreEngine\Utils;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 use StoreEngine;
10 use StoreEngine\Classes\Countries;
11 use StoreEngine\Classes\Exceptions\StoreEngineException;
12 use StoreEngine\Classes\Logger;
13 use StoreEngine\Models\Cart;
14 use StoreEngine\Shipping\Methods\ShippingMethod;
15 use StoreEngine\Utils\traits\{Attribute,
16 Currency,
17 Customer,
18 DownloadPermission,
19 Gateway,
20 Integration,
21 Order,
22 Pages,
23 Product,
24 ThemePlugin};
25 use WP_Error;
26 use WP_Post;
27
28 class Helper extends Template {
29
30 use Customer, Pages, Order, Currency, Gateway,
31 Product, Integration, Attribute, DownloadPermission,
32 ThemePlugin, StoreEngine\Utils\Traits\Repository;
33
34 const PRODUCT_POST_TYPE = STOREENGINE_PLUGIN_SLUG . '_product';
35
36 const PRODUCT_CATEGORY_TAXONOMY = self::PRODUCT_POST_TYPE . '_category';
37
38 const PRODUCT_ATTRIBUTE_TAXONOMY = self::PRODUCT_POST_TYPE . '_attribute';
39
40 const PRODUCT_TAG_TAXONOMY = self::PRODUCT_POST_TYPE . '_tag';
41
42 const COUPON_POST_TYPE = STOREENGINE_PLUGIN_SLUG . '_coupon';
43
44 // Post-type names are capped at 20 chars; keep this short (storeengine_faqs).
45 const FAQ_POST_TYPE = STOREENGINE_PLUGIN_SLUG . '_faqs';
46
47 // Size chart library. Same 20-char cap as above, so '_sizes' rather than
48 // '_size_charts' (which would be 23). The REST base is 'size-charts'.
49 const SIZE_CHART_POST_TYPE = STOREENGINE_PLUGIN_SLUG . '_sizes';
50
51 const DB_PREFIX = STOREENGINE_PLUGIN_SLUG . '_';
52
53 /**
54 * Amount the payment gateway actually settled, in store currency.
55 */
56 const META_GATEWAY_CAPTURED_AMOUNT = '_storeengine_gateway_captured_amount';
57
58 /**
59 * Currency the gateway settled in.
60 */
61 const META_GATEWAY_CAPTURED_CURRENCY = '_storeengine_gateway_captured_currency';
62
63 /**
64 * Set to '1' when the captured amount diverges from the order total, so the
65 * invoice may under-report what the customer paid.
66 */
67 const META_INVOICE_TAX_MISMATCH = '_storeengine_invoice_tax_mismatch';
68
69 /**
70 * @var null|bool|int
71 */
72 protected static $dashboard_index = null;
73
74 public static function get_time() {
75 return time() + ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS );
76 }
77
78 /**
79 * Record the amount the payment gateway actually settled and flag the order
80 * when it diverges from the StoreEngine order total.
81 *
82 * Invoices render on-demand from the stored order, so when the captured amount
83 * differs from get_total() (e.g. gateway-added tax) the invoice would
84 * under-report what the customer paid. The flag surfaces an admin action to
85 * reconcile the order before (re)sending the invoice.
86 *
87 * @param \StoreEngine\Classes\Order|mixed $order Paid order.
88 * @param float $captured_amount Amount settled by the gateway, in store currency.
89 * @param string $currency Currency the gateway settled in (defaults to order currency).
90 */
91 public static function record_order_settlement( $order, float $captured_amount, string $currency = '' ): void {
92 if ( ! $order || is_wp_error( $order ) ) {
93 return;
94 }
95
96 if ( ! $currency ) {
97 $currency = $order->get_currency();
98 }
99
100 $order->update_meta_data( self::META_GATEWAY_CAPTURED_AMOUNT, $captured_amount );
101 $order->update_meta_data( self::META_GATEWAY_CAPTURED_CURRENCY, $currency );
102
103 // Tolerance = one smallest currency unit, so sub-cent rounding never flags.
104 $epsilon = 1 / pow( 10, max( 0, Formatting::get_price_decimals() ) );
105 $mismatch = abs( $captured_amount - (float) $order->get_total() ) >= $epsilon;
106
107 if ( $mismatch ) {
108 $order->update_meta_data( self::META_INVOICE_TAX_MISMATCH, '1' );
109 } else {
110 $order->delete_meta_data( self::META_INVOICE_TAX_MISMATCH );
111 }
112
113 $order->save();
114
115 /**
116 * Fires after a gateway settlement is recorded against an order.
117 *
118 * @param \StoreEngine\Classes\Order|mixed $order
119 * @param float $captured_amount
120 * @param bool $mismatch Whether the captured amount diverged from the order total.
121 */
122 do_action( 'storeengine/order/settlement_recorded', $order, $captured_amount, $mismatch );
123 }
124
125 public static function is_fse_theme() {
126 if ( function_exists( 'wp_is_block_theme' ) ) {
127 return wp_is_block_theme();
128 }
129 if ( function_exists( 'gutenberg_is_fse_theme' ) ) {
130 return \gutenberg_is_fse_theme();
131 }
132
133 return false;
134 }
135
136 public static function remove_line_break( string $content ): string {
137 $content = preg_replace( '/\s+/', ' ', $content );
138
139 return trim( $content );
140 }
141
142 public static function remove_tag_space( string $content ): string {
143 return preg_replace( '/>\s+</', '><', $content );
144 }
145
146 public static function add_string_quote( $value ) {
147 if ( in_array( gettype( $value ), array( 'integer', 'double', 'float' ), true ) ) {
148 return $value;
149 } elseif ( 'boolean' === gettype( $value ) ) {
150 return (int) $value;
151 }
152
153 return "'" . $value . "'";
154 }
155
156 public static function array_diff_recursive( $array1, $array2 ): array {
157 $difference = [];
158
159 foreach ( $array1 as $key => $value ) {
160 if ( is_array( $value ) ) {
161 if ( ! isset( $array2[ $key ] ) || ! is_array( $array2[ $key ] ) ) {
162 $difference[ $key ] = $value;
163 } else {
164 $newDiff = self::array_diff_recursive( $value, $array2[ $key ] );
165 if ( ! empty( $newDiff ) ) {
166 $difference[ $key ] = $newDiff;
167 }
168 }
169 } elseif ( ! array_key_exists( $key, $array2 ) || ( $array2[ $key ] !== $value ) ) {
170 $difference[ $key ] = $value;
171 }
172 }
173
174 return $difference;
175 }
176
177 public static function get_country_name( string $key ) {
178 $countries = Countries::init()->get_countries();
179
180 return $countries[ $key ] ?? '';
181 }
182
183 public static function cart(): ?\StoreEngine\Classes\Cart {
184 // Prefer the fully-initialized cart held by the main plugin instance.
185 // Fall back to the live singleton for the window during Cart::init()
186 // where the cart is being calculated (auto-coupon validation runs here)
187 // but StoreEngine->cart has not yet received init()'s return value.
188 return StoreEngine::init()->get_cart() ?? \StoreEngine\Classes\Cart::get_instance();
189 }
190
191 public static function get_price_duration( $price, $duration, $duration_type ): string {
192 if ( 1 === $duration ) {
193 /* translators: 1. Price 2, duration */
194 return sprintf( __( '%1$s Every %2$s', 'storeengine' ), Formatting::price( $price ), ucfirst( $duration_type ) );
195 } else {
196 return ( Formatting::price( $price ) . ' / ' . $duration . '-' . $duration_type . 's' );
197 }
198 }
199
200 /**
201 * @deprecated
202 */
203 public static function get_enabled_payment_methods(): array {
204 $payments_settings = self::get_payments_settings();
205 $enabled_payment_methods = [];
206 if ( is_array( $payments_settings ) ) {
207 foreach ( $payments_settings as $payment_settings ) {
208 if ( $payment_settings['is_enabled'] ) {
209 $enabled_payment_methods[] = array(
210 'type' => $payment_settings['type'],
211 'title' => $payment_settings['title'] ?? $payment_settings['type'],
212 'instructions' => $payment_settings['instructions'] ?? null,
213 );
214 }
215 }
216 }
217
218 return $enabled_payment_methods;
219 }
220
221 /**
222 * @deprecated
223 */
224 public static function get_payments_settings( $payment_method = '', $default = null ) {
225 $payments_settings = \StoreEngine\Admin\Settings\Payments::get_settings_saved_data();
226
227 if ( is_array( $payments_settings ) ) {
228 if ( ! $payment_method ) {
229 return $payments_settings;
230 }
231
232 foreach ( $payments_settings as $payment_settings ) {
233 if ( $payment_settings['type'] === $payment_method ) {
234 return $payment_settings;
235 }
236 }
237
238 return [];
239 }
240
241 return $default;
242 }
243
244 /**
245 * @param string $page
246 * @param ?string $fallback
247 *
248 * @return string
249 */
250 public static function get_page_permalink( string $page, string $fallback = null ): string {
251 $page_id = self::get_settings( $page );
252 $permalink = 0 < $page_id ? get_permalink( $page_id ) : '';
253
254 if ( ! $permalink ) {
255 $permalink = is_null( $fallback ) ? get_home_url() : $fallback;
256 }
257
258 $permalink = apply_filters( "storeengine/get_{$page}_permalink", $permalink, $page_id, $fallback );
259
260 return apply_filters( 'storeengine/get_page_permalink', $permalink, $page, $page_id, $fallback );
261 }
262
263 public static function get_dashboard_url(): string {
264 return self::get_page_permalink( 'dashboard_page' );
265 }
266
267 public static function get_preloader_html() {
268 ob_start();
269 ?>
270 <div class="storeengine-initial-preloader"><?php esc_html_e( 'Loading...', 'storeengine' ); ?></div>
271 <?php
272 return ob_get_clean();
273 }
274
275
276 /**
277 * Get endpoint URL.
278 *
279 * Gets the URL for an endpoint, which varies depending on permalink settings.
280 *
281 * @param string $endpoint
282 * @param string|int|float $value
283 * @param string|false $permalink
284 *
285 * @return string
286 */
287 public static function get_endpoint_url( string $endpoint, $value = '', $permalink = '' ): string {
288 global $wp_query;
289
290 if ( ! $permalink ) {
291 $permalink = get_permalink();
292 }
293
294 // Map endpoint to options.
295 $query_vars = $wp_query->query_vars;
296 $orig_endpoint = $endpoint;
297 $endpoint = ! empty( $query_vars[ $endpoint ] ) ? $query_vars[ $endpoint ] : $endpoint;
298
299 if ( get_option( 'permalink_structure' ) ) {
300 if ( strstr( $permalink, '?' ) ) {
301 $query_string = '?' . wp_parse_url( $permalink, PHP_URL_QUERY );
302 $permalink = current( explode( '?', $permalink ) );
303 } else {
304 $query_string = '';
305 }
306
307 // Cleanup trailing slash.
308 $url = trailingslashit( untrailingslashit( $permalink ) );
309
310 if ( $value ) {
311 $url .= trailingslashit( untrailingslashit( $endpoint ) ) . user_trailingslashit( $value );
312 } else {
313 $url .= user_trailingslashit( $endpoint );
314 }
315
316 $url .= $query_string;
317 } elseif ( 'order-pay' === $orig_endpoint ) {
318 // The registered query vars for this endpoint are `order_pay` +
319 // `order_id` (see PermalinkRewrite::register_query_vars()), not a
320 // single `order-pay=<id>` pair — plain permalinks need both set
321 // explicitly or nothing on the page (is_valid_order_pay_page(),
322 // is_available(), etc.) will ever recognise the order-pay context.
323 $url = add_query_arg( [
324 'order_pay' => 'true',
325 'order_id' => $value,
326 ], $permalink );
327 } else {
328 $url = add_query_arg( $endpoint, $value, $permalink );
329 }
330
331 $url = apply_filters( "storeengine/get_{$endpoint}_endpoint_url", $url, $value, $permalink );
332
333 return apply_filters( 'storeengine/get_endpoint_url', $url, $endpoint, $value, $permalink );
334 }
335
336 /**
337 * @return ?string
338 */
339 public static function get_current_dashboard_endpoint(): ?string {
340 global $wp;
341
342 return $wp->query_vars['storeengine_dashboard_page'] ?? null;
343 }
344
345 public static function get_logout_redirect_url(): string {
346 return apply_filters( 'storeengine/logout_default_redirect_url', Helper::get_dashboard_url() );
347 }
348
349 public static function get_logout_url( string $redirect = '' ): string {
350 $redirect = $redirect ?: self::get_logout_redirect_url();
351 $args = [
352 'redirect_to' => $redirect,
353 'action' => 'logout',
354 ];
355 $logout_url = self::get_endpoint_url( 'customer-logout', '', self::get_dashboard_url() );
356 $logout_url = wp_nonce_url( add_query_arg( $args, $logout_url ), 'customer-logout' );
357
358 return apply_filters( 'storeengine/logout_url', $logout_url, $redirect );
359 }
360
361 /**
362 * Get account endpoint URL.
363 *
364 * @param string $endpoint Endpoint.
365 *
366 * @return string
367 */
368 public static function get_account_endpoint_url( string $endpoint, $value = '' ): ?string {
369 if ( 'dashboard' === $endpoint || 'myaccount' === $endpoint || 'index' === $endpoint ) {
370 return self::get_dashboard_url();
371 }
372
373 if ( 'customer-logout' === $endpoint ) {
374 return self::get_logout_url();
375 }
376
377 $url = self::get_endpoint_url( $endpoint, $value, self::get_dashboard_url() );
378
379 $url = apply_filters( "storeengine/dashboard/get_{$endpoint}_endpoint_url", $url, $value );
380
381 return apply_filters( 'storeengine/dashboard/get_endpoint_url', $url, $endpoint, $value );
382 }
383
384 public static function get_current_dashboard_endpoint_url( string $endpoint = null, $value = '' ): string {
385 return self::get_account_endpoint_url( $endpoint ?? self::get_current_dashboard_endpoint() ?? '', $value );
386 }
387
388 /**
389 * Get the link to the edit account details page.
390 *
391 * @return string
392 */
393 public static function customer_edit_account_url(): string {
394 $edit_account_url = self::get_endpoint_url( 'edit-account', '', self::get_dashboard_url() );
395
396 return apply_filters( 'storeengine/customer/edit_account_url', $edit_account_url );
397 }
398
399 /**
400 * add-filter to lostpassword_url
401 *
402 * @param $default_url
403 * @param $redirect
404 *
405 * @return mixed|string
406 */
407 public static function get_lost_password_url( $default_url = '', $redirect = '' ) {
408 // Avoid loading too early.
409 if ( ! did_action( 'init' ) ) {
410 return $default_url;
411 }
412
413 // Don't change the admin form.
414 if ( did_action( 'login_form_login' ) ) {
415 return $default_url;
416 }
417
418 // Don't redirect to the StoreEngine endpoint on global network admin lost passwords.
419 if ( is_multisite() && isset( $_GET['redirect_to'] ) && false !== strpos( wp_unslash( $_GET['redirect_to'] ), network_admin_url() ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
420 return $default_url;
421 }
422
423 $permalink = self::get_page_permalink( 'password_reset_page' );
424
425 if ( ! $permalink ) {
426 return $default_url;
427 }
428
429 if ( ! empty( $redirect ) ) {
430 return add_query_arg( [ 'redirect_to' => rawurlencode( $redirect ) ], $permalink );
431 }
432
433 return $permalink;
434 }
435
436 public static function get_cart_url() {
437 return apply_filters( 'storeengine/get_cart_url', self::maybe_force_ssl_protocol( self::get_page_permalink( 'cart_page' ) ) );
438 }
439
440 public static function get_checkout_url() {
441 return apply_filters( 'storeengine/checkout/get_checkout_url', self::maybe_force_ssl_protocol( self::get_page_permalink( 'checkout_page' ) ) );
442 }
443
444 public static function get_thankyou_page_url() {
445 return apply_filters( 'storeengine/checkout/thankyou_page_url', self::maybe_force_ssl_protocol(self::get_page_permalink( 'thankyou_page' )) );
446 }
447
448 public static function get_terms_page_url() {
449 return apply_filters( 'storeengine/terms_page_url', self::maybe_force_ssl_protocol( self::get_page_permalink( 'terms_page' ) ) );
450 }
451
452 public static function get_privacy_page_url() {
453 return apply_filters( 'storeengine/privacy_page_url', self::maybe_force_ssl_protocol(self::get_page_permalink( 'privacy_page' )) );
454 }
455
456 public static function get_shop_url() {
457 return apply_filters( 'storeengine/get_shop_url', self::maybe_force_ssl_protocol( self::get_page_permalink( 'shop_page' ) ) );
458 }
459
460 public static function maybe_force_ssl_protocol( $url ) {
461 if ( $url ) {
462 // Force SSL if needed.
463 if ( is_ssl() || self::get_settings( 'force_ssl_checkout' ) ) {
464 $url = str_replace( 'http:', 'https:', $url );
465 }
466 }
467
468 return $url;
469 }
470
471 public static function get_settings( $key, $default = null ) {
472 global $storeengine_settings;
473
474 $value = $storeengine_settings->{$key} ?? $default;
475 $value = apply_filters( "storeengine/get_{$key}_settings", $value, $key );
476
477 return apply_filters( 'storeengine/get_settings', $value, $key );
478 }
479
480 /**
481 * Resolve a per-user notification preference for the email-sending code.
482 *
483 * Stored as user_meta `_storeengine_notif_{key}`. Default is opt-in
484 * (returns true when the meta key has never been set), so existing
485 * customers keep receiving emails after this feature ships.
486 *
487 * Order-status emails are transactional (receipts, shipping updates) and
488 * always return true — the Notifications UI greys that toggle out, but
489 * defence-in-depth here in case anyone POSTs the form directly.
490 *
491 * @param int $user_id WP user id.
492 * @param string $key Notification key, e.g. `marketing`, `vendor_new_order`.
493 */
494 public static function should_send_notification( int $user_id, string $key ): bool {
495 if ( 'order_status' === $key ) {
496 return true;
497 }
498 if ( ! $user_id ) {
499 return true;
500 }
501 $meta = get_user_meta( $user_id, '_storeengine_notif_' . $key, true );
502 // Default-on: empty string means the user has never toggled it.
503 return '' === $meta || '1' === $meta;
504 }
505
506 public static function get_shop_address(): string {
507 global $storeengine_settings;
508
509 return Countries::init()->get_formatted_address( [
510 'address_1' => $storeengine_settings->store_address_1,
511 'address_2' => $storeengine_settings->store_address_2,
512 'city' => $storeengine_settings->store_city,
513 'state' => $storeengine_settings->store_state,
514 'postcode' => $storeengine_settings->store_postcode,
515 'country' => $storeengine_settings->store_country,
516 ] );
517 }
518
519 public static function get_addon_active_status( $addon_name, $is_pro = false ): bool {
520 global $storeengine_addons;
521 if ( $is_pro && ! self::is_active_storeengine_pro() ) {
522 return false;
523 }
524 if ( isset( $storeengine_addons->{$addon_name} ) ) {
525 return (bool) $storeengine_addons->{$addon_name};
526 }
527
528 return false;
529 }
530
531 public static function dif_from_human( $date ) {
532 $now = time();
533 if ( ! is_numeric( $date ) ) {
534 $date = strtotime( $date );
535 }
536 $diff = $now - $date;
537 if ( $diff < 60 ) {
538 /* translators: %s is the number of seconds */
539 return sprintf( __( '%s seconds ago', 'storeengine' ), $diff );
540 }
541 if ( $diff < 3600 ) {
542 /* translators: %s is the number of minutes */
543 return sprintf( __( '%s minutes ago', 'storeengine' ), round( $diff / 60 ) );
544 }
545 if ( $diff < 86400 ) {
546 /* translators: %s is the number of hours */
547 return sprintf( __( '%s hours ago', 'storeengine' ), round( $diff / 3600 ) );
548 }
549 if ( $diff < 604800 ) {
550 /* translators: %s is the number of days */
551 return sprintf( __( '%s days ago', 'storeengine' ), round( $diff / 86400 ) );
552 }
553 if ( $diff < 2419200 ) {
554 /* translators: %s is the number of weeks */
555 return sprintf( __( '%s weeks ago', 'storeengine' ), round( $diff / 604800 ) );
556 }
557 if ( $diff < 29030400 ) {
558 /* translators: %s is the number of months */
559 return sprintf( __( '%s months ago', 'storeengine' ), round( $diff / 2419200 ) );
560 }
561
562 /* translators: %s is the number of years */
563 return sprintf( __( '%s years ago', 'storeengine' ), round( $diff / 29030400 ) );
564 }
565
566 public static function get_tax_rate( string $postcode ): ?float {
567 $tax_rates = [
568 '1000' => 5.5,
569 '2000' => 6.3,
570 '3000' => 7.1,
571 '1206' => 7,
572 '1207' => 7,
573 '7300' => 7,
574 ];
575
576 return $tax_rates[ $postcode ] ?? null;
577 }
578
579 /**
580 * Group definitions for the frontend dashboard sidebar.
581 *
582 * Each group renders a non-clickable header before its first visible
583 * member. Items set `'group' => '<key>'` to opt in; ungrouped items
584 * sort by their own priority and render without a header.
585 *
586 * Group priority controls inter-group ordering; item priority controls
587 * order within a group.
588 *
589 * @return array<string,array{label:string,priority:int}>
590 */
591 public static function get_frontend_dashboard_menu_groups(): array {
592 return apply_filters( 'storeengine/frontend_dashboard_menu_groups', [
593 'orders' => [ 'label' => __( 'My orders', 'storeengine' ), 'priority' => 30 ],
594 'earnings' => [ 'label' => __( 'Earnings', 'storeengine' ), 'priority' => 60 ],
595 'account' => [ 'label' => __( 'Account', 'storeengine' ), 'priority' => 100 ],
596 ] );
597 }
598
599 /**
600 * Sort menu items by (group, item priority) and inject a separator entry
601 * before the first visible member of each labelled group. Headers are
602 * skipped for groups that have no visible items, and for items already
603 * acting as a manual separator (e.g. the vendor section break).
604 *
605 * This is invoked once by the sidebar renderer — other consumers
606 * (rewrite rules, request handler, REST exposure) keep working with
607 * the raw item list.
608 *
609 * @param array $items
610 * @return array
611 */
612 public static function apply_frontend_dashboard_menu_groups( array $items ): array {
613 $groups = self::get_frontend_dashboard_menu_groups();
614
615 $tagged = [];
616 foreach ( $items as $key => $item ) {
617 $group = $item['group'] ?? '';
618 $item_priority = (int) ( $item['priority'] ?? 0 );
619 // Ungrouped items use their own priority as the inter-group sort key
620 // so they slot wherever their priority places them (Dashboard at
621 // the top, Log out at the bottom, vendor separators inline).
622 $effective = isset( $groups[ $group ] )
623 ? (int) $groups[ $group ]['priority']
624 : $item_priority;
625
626 $tagged[ $key ] = [
627 'item' => $item,
628 'group' => $group,
629 'effective' => $effective,
630 'priority' => $item_priority,
631 ];
632 }
633
634 uasort( $tagged, static function ( $a, $b ) {
635 if ( $a['effective'] !== $b['effective'] ) {
636 return $a['effective'] <=> $b['effective'];
637 }
638 return $a['priority'] <=> $b['priority'];
639 } );
640
641 $output = [];
642 $last_emitted_group = null;
643 foreach ( $tagged as $key => $row ) {
644 $group = $row['group'];
645 $item = $row['item'];
646
647 $is_visible = ! empty( $item['public'] ) && empty( $item['hide_from_nav'] );
648
649 // Inject the header only when:
650 // - the item is visible,
651 // - the item isn't itself a manual separator,
652 // - the item belongs to a registered group with a non-empty label,
653 // - and we haven't already emitted that group's header.
654 //
655 // $last_emitted_group tracks the last header we emitted — NOT the
656 // last item's group. Third-party items that omit `group` (or use
657 // an unknown key) flow through unchanged: they appear in the
658 // sidebar at their own priority, get no header, and don't reset
659 // the tracker — so a labelled group split by such an item still
660 // renders its header exactly once.
661 if (
662 $is_visible
663 && empty( $item['is_separator'] )
664 && isset( $groups[ $group ]['label'] )
665 && '' !== $groups[ $group ]['label']
666 && $group !== $last_emitted_group
667 ) {
668 $output[ '__group_' . $group ] = [
669 'label' => $groups[ $group ]['label'],
670 'public' => true,
671 'priority' => $row['effective'] - 1,
672 'is_separator' => true,
673 ];
674 $last_emitted_group = $group;
675 }
676
677 $output[ $key ] = $item;
678 }
679
680 return $output;
681 }
682
683 /**
684 * @return array<array{
685 * label: string,
686 * icon: string,
687 * public: bool,
688 * priority: int|float,
689 * group?: string,
690 * children:array<{label: string, icon: string, public: bool, priority: int|float}>,
691 * }>
692 */
693 public static function get_frontend_dashboard_menu_items(): array {
694 $items = [
695 'index' => [
696 'label' => __( 'Dashboard', 'storeengine' ),
697 'icon' => 'storeengine-icon storeengine-icon--layout',
698 'public' => true,
699 'priority' => - 1,
700 ],
701 'orders' => [
702 'label' => __( 'Orders', 'storeengine' ),
703 'icon' => 'storeengine-icon storeengine-icon--box',
704 'public' => true,
705 'priority' => 30,
706 'group' => 'orders',
707 ],
708 'downloads' => [
709 'label' => __( 'Downloads', 'storeengine' ),
710 'icon' => 'storeengine-icon storeengine-icon--brand-style',
711 'public' => true,
712 'priority' => 70,
713 'group' => 'orders',
714 ],
715 'reviews' => [
716 'label' => __( 'Reviews', 'storeengine' ),
717 'icon' => 'storeengine-icon storeengine-icon--star-fill',
718 'public' => true,
719 'priority' => 75,
720 'group' => 'orders',
721 ],
722 'payment-methods' => [
723 'label' => __( 'Payment methods', 'storeengine' ),
724 'icon' => 'storeengine-icon storeengine-icon--payment',
725 'public' => true,
726 'priority' => 90,
727 'group' => 'account',
728 ],
729 'add-payment-method' => [
730 'label' => __( 'Add Payment method', 'storeengine' ),
731 'public' => false,
732 'priority' => 91,
733 ],
734 'delete-payment-method' => [
735 'label' => __( 'Delete Payment method', 'storeengine' ),
736 'public' => false,
737 'priority' => 92,
738 ],
739 'set-default-payment-method' => [
740 'label' => __( 'Set Default Payment method', 'storeengine' ),
741 'public' => false,
742 'priority' => 93,
743 ],
744 'edit-address' => [
745 'label' => __( 'Addresses', 'storeengine' ),
746 'icon' => 'storeengine-icon storeengine-icon--edit',
747 'public' => true,
748 'priority' => 110,
749 'group' => 'account',
750 'children' => [
751 'billing' => [
752 'label' => __( 'Edit Billing Address', 'storeengine' ),
753 'public' => false,
754 'priority' => 10,
755 ],
756 'shipping' => [
757 'label' => __( 'Edit Shipping Address', 'storeengine' ),
758 'public' => false,
759 'priority' => 20,
760 ],
761 ],
762 ],
763 'edit-account' => [
764 'label' => __( 'Account', 'storeengine' ),
765 'icon' => 'storeengine-icon storeengine-icon--build',
766 'public' => true,
767 'priority' => 130,
768 'group' => 'account',
769 'children' => [
770 // `account` is the default sub-tab when no sub_page is
771 // requested — same content as the legacy edit-account page
772 // (email, name, password). Notifications + Privacy are new.
773 'account' => [
774 'label' => __( 'Account', 'storeengine' ),
775 'public' => true,
776 'priority' => 10,
777 ],
778 'notifications' => [
779 'label' => __( 'Notifications', 'storeengine' ),
780 'public' => true,
781 'priority' => 20,
782 ],
783 'privacy' => [
784 'label' => __( 'Privacy', 'storeengine' ),
785 'public' => true,
786 'priority' => 30,
787 ],
788 ],
789 ],
790 'customer-logout' => [
791 'label' => __( 'Log out', 'storeengine' ),
792 'icon' => 'storeengine-icon storeengine-icon--logout',
793 'public' => true,
794 'priority' => 999,
795 ],
796 'forgot-password' => [
797 // public=true so the FrontendRequestHandler doesn't gate the
798 // page on login (logged-out users need to reach it), and so
799 // PermalinkRewrite generates the /dashboard/forgot-password/
800 // rewrite rule automatically.
801 //
802 // hide_from_nav keeps it out of the sidebar — it's a
803 // contextual entry point reached from the login form's "Lost
804 // your password?" link and from the reset email, not a
805 // destination customers navigate to.
806 //
807 // guest_accessible tells the [storeengine_dashboard] shortcode
808 // to route to this endpoint's content for logged-out visitors
809 // instead of falling back to the login form, which is what it
810 // does for every other endpoint.
811 'label' => __( 'Reset password', 'storeengine' ),
812 'public' => true,
813 'hide_from_nav' => true,
814 'guest_accessible' => true,
815 'priority' => 1000,
816 ],
817 'register' => [
818 // Same flag combination as forgot-password — public so the
819 // rewrite rule generates, hidden from nav (entry point is the
820 // login form's "Register" link), guest_accessible so the
821 // dashboard shortcode renders the form for logged-out visitors
822 // instead of redirecting them through the login flow.
823 'label' => __( 'Register', 'storeengine' ),
824 'public' => true,
825 'hide_from_nav' => true,
826 'guest_accessible' => true,
827 'priority' => 1001,
828 ],
829 ];
830
831 $support_payment_methods = false;
832 foreach ( self::get_payment_gateways()->get_available_payment_gateways() as $gateway ) {
833 if ( $gateway->supports( 'add_payment_method' ) || $gateway->supports( 'tokenization' ) ) {
834 $support_payment_methods = true;
835 break;
836 }
837 }
838
839 if ( ! $support_payment_methods ) {
840 unset( $items['payment-methods'] );
841 }
842
843 return apply_filters( 'storeengine/frontend_dashboard_menu_items', $items );
844 }
845
846 public static function get_frontend_dashboard_page_title( $path, $sub_path = '' ) {
847 $menu = self::get_frontend_dashboard_menu_items();
848
849 if ( empty( $menu[ $path ] ) ) {
850 return '';
851 }
852
853 if ( $sub_path ) {
854 if ( ! empty( $menu[ $path ]['children'][ $sub_path ] ) ) {
855 return $menu[ $path ]['children'][ $sub_path ]['label'];
856 }
857 }
858
859 return $menu[ $path ]['label'];
860 }
861
862 public static function round( $val, int $precision = 0, int $mode = PHP_ROUND_HALF_UP ): float {
863 if ( ! is_numeric( $val ) ) {
864 $val = floatval( $val );
865 }
866
867 return round( $val, $precision, $mode );
868 }
869
870 public static function meta_parser( $meta ) {
871 return array_map( function ( $i ) {
872 return $i[0];
873 }, $meta );
874 }
875
876 public static function get_cart_hash(): ?string {
877 $cart_hash = self::get_cart_hash_from_cookie();
878 if ( $cart_hash ) {
879 return $cart_hash;
880 }
881
882 return Cart::get_cart_hash_by_user_id( get_current_user_id() );
883 }
884
885 public static function get_cart_hash_from_cookie(): string {
886 return isset( $_COOKIE['storeengine_cart_hash'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['storeengine_cart_hash'] ) ) : '';
887 }
888
889 /**
890 * Set cart has in cookie.
891 *
892 * @param string $cart_hash
893 *
894 * @return void
895 * @deprecated
896 */
897 public static function set_cart_hash_in_cookie( string $cart_hash ): void {
898 setcookie( 'storeengine_cart_hash', $cart_hash, [
899 'expires' => time() + YEAR_IN_SECONDS,
900 'path' => defined( 'COOKIEPATH' ) ? COOKIEPATH : '/',
901 'domain' => defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '',
902 'secure' => is_ssl(),
903 'httponly' => true,
904 'samesite' => 'Strict',
905 ] );
906 }
907
908 /**
909 * Unset Cart hash cookie.
910 *
911 * @return void
912 * @deprecated
913 */
914 public static function unset_cart_hash_in_cookie(): void {
915 if ( headers_sent() ) {
916 return;
917 }
918
919 // @TODO delete cache on hash changes.
920 // wp_cache_delete 'order:draft:' . Helper::get_cart_hash_from_cookie(), 'storeengine_orders' ;
921 header( 'Set-Cookie: storeengine_cart_hash=; Path=/; HttpOnly; Max-Age=-1', false );
922 setcookie( 'storeengine_cart_hash', '', [
923 'expires' => - 1,
924 'path' => defined( 'COOKIEPATH' ) ? COOKIEPATH : '/',
925 'domain' => defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '',
926 'secure' => is_ssl(),
927 'httponly' => true,
928 'samesite' => 'Strict',
929 ] );
930 }
931
932 public static function sanitize_referer_url( string $referer_url ): string {
933 $parse_url = wp_parse_url( $referer_url );
934
935 if ( isset( $parse_url['query'] ) ) {
936 // Parse query parameters
937 parse_str( $parse_url['query'], $query_params );
938 if ( ! empty( $query_params['redirect_to'] ) ) {
939 $referer_url = $query_params['redirect_to'];
940 }
941 if ( ! empty( $query_params['redirect_url'] ) ) {
942 $referer_url = $query_params['redirect_url'];
943 }
944 }
945
946 // Sanitize the input URL
947 $referer_url = esc_url_raw( $referer_url );
948 if ( filter_var( $referer_url, FILTER_VALIDATE_URL ) !== false && wp_http_validate_url( $referer_url ) && strpos( $referer_url, home_url() ) === 0 ) {
949 return esc_url( $referer_url );
950 } elseif ( ! empty( $parse_url['path'] ) ) {
951 return esc_url( home_url( sanitize_text_field( $parse_url['path'] ) ) );
952 }
953
954 return esc_url( home_url( '/' ) );
955 }
956
957 public static function asort_by_locale( &$data, $locale = '' ) {
958 // Use Collator if PHP Internationalization Functions (php-intl) is available.
959 if ( class_exists( 'Collator' ) ) {
960 try {
961 $locale = $locale ? $locale : get_locale();
962 $collator = new \Collator( $locale );
963 $collator->asort( $data, \Collator::SORT_STRING );
964
965 return $data;
966 } catch ( \Throwable $e ) {
967 Helper::log_error(
968 sprintf(
969 'An unexpected error occurred while trying to use PHP Intl Collator class, it may be caused by an incorrect installation of PHP Intl and ICU, and could be fixed by reinstalling PHP Intl, see more details about PHP Intl installation: %1$s. Error message: %2$s',
970 'https://www.php.net/manual/en/intl.installation.php',
971 $e->getMessage()
972 )
973 );
974 }
975 }
976
977 // Keep a reference to original data before removing accent marks
978 // as strcmp works better without accent marks and add the value back
979 // to the sorted array from this reference.
980 $raw_data = $data;
981
982 array_walk( $data, function ( &$value ) {
983 $value = remove_accents( html_entity_decode( $value ) );
984 } );
985
986 uasort( $data, 'strcmp' );
987
988 foreach ( $data as $key => $val ) {
989 $data[ $key ] = $raw_data[ $key ];
990 }
991
992 return $data;
993 }
994
995 public static function get_all_roles(): array {
996 global $wp_roles;
997
998 if ( ! class_exists( '\WP_Roles' ) ) {
999 return [];
1000 }
1001
1002 if ( ! isset( $wp_roles ) ) {
1003 $wp_roles = new \WP_Roles(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
1004 }
1005
1006 $roles_array = [];
1007
1008 foreach ( $wp_roles->roles as $role_id => $role ) {
1009 $roles_array[] = [
1010 'role_id' => $role_id,
1011 'role_name' => $role['name'],
1012 ];
1013 }
1014
1015 return $roles_array;
1016 }
1017
1018 public static function get_sample_permalink_args( $id, $new_title = null, $new_slug = null ) {
1019 $post = get_post( $id );
1020 if ( ! $post ) {
1021 return '';
1022 }
1023
1024 list( $permalink, $post_name ) = get_sample_permalink( $post->ID, $new_title, $new_slug );
1025 $view_link = false;
1026
1027 if ( current_user_can( 'read_post', $post->ID ) ) {
1028 if ( 'draft' === $post->post_status || empty( $post->post_name ) ) {
1029 $view_link = get_preview_post_link( $post );
1030 } elseif ( 'publish' === $post->post_status || 'storeengine_product' === $post->post_type ) {
1031 $view_link = get_permalink( $post );
1032 } else {
1033 $view_link = str_replace( [ '%pagename%', '%postname%' ], $post->post_name, $permalink );
1034 }
1035 }
1036
1037 return [
1038 'view_link' => $view_link ? esc_url( $view_link ) : null,
1039 'editable_postname' => $post_name,
1040 'display_link' => rtrim( str_replace( '%pagename%', $post_name, $permalink ) ),
1041 'post_name' => $post_name,
1042 ];
1043 }
1044
1045 /**
1046 * Get Page by title.
1047 *
1048 * @param string $page_title
1049 * @param string $post_type
1050 *
1051 * @return WP_Post|null
1052 */
1053 public static function get_page_by_title( string $page_title, string $post_type = 'page' ): ?WP_Post {
1054 global $wpdb;
1055
1056 $page = wp_cache_get( 'storeengine:get_page_by_title:' . sanitize_title( $page_title ), $post_type );
1057
1058 if ( false === $page ) {
1059 $page = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1060 $wpdb->prepare(
1061 "SELECT ID FROM $wpdb->posts WHERE post_title = %s AND post_type = %s;",
1062 $page_title,
1063 $post_type
1064 )
1065 );
1066
1067 wp_cache_set( 'storeengine:get_page_by_title:' . sanitize_title( $page_title ), $page, $post_type );
1068 }
1069
1070 if ( $page ) {
1071 $page = get_post( $page, OBJECT );
1072
1073 if ( ! $page ) {
1074 wp_cache_delete( 'storeengine:get_page_by_title:' . sanitize_title( $page_title ), $post_type );
1075 }
1076
1077 return $page;
1078 }
1079
1080 return null;
1081 }
1082
1083 /**
1084 * @return string
1085 *
1086 * @deprecated 1.5.6
1087 * @see Geolocation::get_user_ip()
1088 */
1089 public static function get_user_ip(): string {
1090 return Geolocation::get_user_ip();
1091 }
1092
1093 /**
1094 * Get user agent string.
1095 *
1096 * @return string
1097 */
1098 public static function get_user_agent(): string {
1099 return isset( $_SERVER['HTTP_USER_AGENT'] ) ? Formatting::clean( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized inside clean method.
1100 }
1101
1102 public static function is_bot(): bool {
1103 $ua = self::get_user_agent();
1104
1105 // Common bots by user agent.
1106 return apply_filters(
1107 'storeengine/is_bot',
1108 stripos( $ua, 'bot' ) !== false || stripos( $ua, 'spider' ) !== false || stripos( $ua, 'crawl' ) !== false,
1109 $ua
1110 );
1111 }
1112
1113 public static function get_email_template_name( string $template_name, ?string $template_sub_name = null ): string {
1114 if ( $template_sub_name ) {
1115 return str_replace( '_', '-', $template_name ) . '-' . $template_sub_name . '.php';
1116 }
1117
1118 return str_replace( '_', '-', $template_name ) . '.php';
1119 }
1120
1121 /**
1122 * Schedule rewrite rule flushing on next reload.
1123 *
1124 * @return void
1125 * @since 0.0.4
1126 */
1127 public static function flush_rewire_rules() {
1128 update_option( 'storeengine_required_rewrite_flush', 'yes' );
1129 }
1130
1131 /**
1132 * Checks whether the content passed contains a specific short code.
1133 *
1134 * @param string $tag Shortcode tag to check.
1135 *
1136 * @return bool
1137 */
1138 public static function post_content_has_shortcode( string $tag = '' ): bool {
1139 global $post;
1140
1141 return is_singular() && is_a( $post, 'WP_Post' ) && has_shortcode( $post->post_content, $tag );
1142 }
1143
1144 public static function is_storeengine(): bool {
1145 return apply_filters( 'storeengine/is_storeengine', self::is_shop() || self::is_product_taxonomy() || self::is_product() );
1146 }
1147
1148 public static function is_shop(): bool {
1149 return ( is_post_type_archive( self::PRODUCT_POST_TYPE ) || is_page( self::get_settings( 'shop_page' ) ) );
1150 }
1151
1152 public static function is_product(): bool {
1153 return is_singular( [ self::PRODUCT_POST_TYPE ] );
1154 }
1155
1156 public static function is_product_taxonomy(): bool {
1157 return is_tax( get_object_taxonomies( self::PRODUCT_POST_TYPE ) );
1158 }
1159
1160 public static function is_product_category( $term = '' ): bool {
1161 return is_tax( self::PRODUCT_CATEGORY_TAXONOMY, $term );
1162 }
1163
1164 public static function is_product_tag( $term = '' ): bool {
1165 return is_tax( self::PRODUCT_TAG_TAXONOMY, $term );
1166 }
1167
1168 public static function is_cart(): bool {
1169 $page_id = self::get_settings( 'cart_page' );
1170
1171 return ( $page_id && is_page( $page_id ) ) || defined( 'STOREENGINE_CART' ) || self::post_content_has_shortcode( 'storeengine_cart' );
1172 }
1173
1174 public static function is_checkout(): bool {
1175 $page_id = self::get_settings( 'checkout_page' );
1176
1177 return ( $page_id && is_page( $page_id ) ) || self::post_content_has_shortcode( 'storeengine_checkout' ) || apply_filters( 'storeengine_is_checkout', false ) || defined( 'STOREENGINE_CART' ) || defined( 'STOREENGINE_CHECKOUT' );
1178 }
1179
1180 public static function is_thank_you(): bool {
1181 $page_id = self::get_settings( 'thankyou_page' );
1182
1183 return ( $page_id && is_page( $page_id ) ) || apply_filters( 'storeengine_is_thankyou', false );
1184 }
1185
1186 public static function is_dashboard(): bool {
1187 $page_id = self::get_settings( 'dashboard_page' );
1188
1189 return ( $page_id && is_page( $page_id ) ) || self::post_content_has_shortcode( 'storeengine_dashboard' ) || apply_filters( 'storeengine_is_dashboard_page', false );
1190 }
1191
1192 /**
1193 * Check if current page is dashboard index or endpoint page.
1194 *
1195 * @return bool|int Returns zero (0) if called outside of dashboard/endpoint page. Returns (bool) true on
1196 * dashboard index or (bool) false on endpoint page.
1197 */
1198 public static function is_dashboard_index() {
1199 if ( null === self::$dashboard_index ) {
1200 self::$dashboard_index = self::is_dashboard() && null === self::get_current_dashboard_endpoint();
1201 }
1202
1203 return self::$dashboard_index;
1204 }
1205
1206 public static function is_account_page(): bool {
1207 return self::is_dashboard();
1208 }
1209
1210 public static function get_all_product_category_lists() {
1211 $categories = get_terms(
1212 array(
1213 'taxonomy' => 'storeengine_product_category',
1214 'hide_empty' => true,
1215 )
1216 );
1217
1218 return self::prepare_category_results( $categories );
1219 }
1220
1221 public static function prepare_category_results( $terms, $parent_id = 0 ) {
1222 $category = array();
1223 foreach ( $terms as $term ) {
1224 if ( $term->parent === $parent_id ) {
1225 $term->children = self::prepare_category_results( $terms, $term->term_id );
1226 $category[] = $term;
1227 }
1228 }
1229
1230 return $category;
1231 }
1232
1233 public static function is_endpoint( $endpoint = null ): bool {
1234 global $wp_query;
1235
1236 if ( empty( $wp_query->query['storeengine_dashboard_page'] ) ) {
1237 return false;
1238 }
1239
1240 if ( $endpoint ) {
1241 return $wp_query->query['storeengine_dashboard_page'] === $endpoint;
1242 }
1243
1244 return true;
1245 }
1246
1247 public static function is_add_payment_method_page(): bool {
1248 return self::is_dashboard() && self::is_endpoint( 'add-payment-method' );
1249 }
1250
1251 public static function is_payment_method_list_page(): bool {
1252 return self::is_dashboard() && self::is_endpoint( 'payment-methods' );
1253 }
1254
1255 public static function is_edit_address_page(): bool {
1256 return self::is_dashboard() && ( self::is_endpoint( 'edit-address' ) && ! empty( get_query_var( 'storeengine_dashboard_sub_page' ) ) );
1257 }
1258
1259 /**
1260 * Rest api permission check.
1261 *
1262 * @param string $capability
1263 * @param string|null $response
1264 *
1265 * @return WP_Error|bool
1266 */
1267 public static function check_rest_user_cap( string $capability, ?string $response = null ) {
1268 $permission = true;
1269 if ( ! is_user_logged_in() || ! current_user_can( $capability ) ) {
1270 $permission = new WP_Error(
1271 'storeengine_rest_forbidden_context',
1272 $response ? esc_html( $response ) : esc_html__( 'Sorry, insufficient permission.', 'storeengine' ),
1273 [ 'status' => rest_authorization_required_code() ]
1274 );
1275 }
1276
1277 return apply_filters( 'storeengine/rest_user_capability', $permission, $capability );
1278 }
1279
1280 public static function prepare_product_search_query_args( $data ) {
1281 $defaults = array(
1282 'search' => '',
1283 'category' => [],
1284 'tags' => [],
1285 'paged' => 1,
1286 'posts_per_page' => 12,
1287 );
1288 $data = wp_parse_args( $data, $defaults );
1289
1290 // base
1291 $args = array(
1292 //'post_type' => apply_filters( 'storeengine/get_product_archive_post_types', array( 'storeengine_product' ) ),
1293 'post_type' => 'storeengine_product', // Archive query compatibility. if WP_Query post-type is array then it won't marked as archive query (required for ajax filter on archive page).
1294 'post_status' => 'publish',
1295 'posts_per_page' => $data['posts_per_page'],
1296 'paged' => $data['paged'],
1297 );
1298
1299 // taxonomy
1300 $tax_query = array();
1301 if ( count( $data['category'] ) > 0 ) {
1302 $tax_query[] = array(
1303 'taxonomy' => 'storeengine_product_category',
1304 'field' => 'slug',
1305 'terms' => $data['category'],
1306 );
1307 }
1308 if ( count( $data['tags'] ) > 0 ) {
1309 $tax_query[] = array(
1310 'taxonomy' => 'storeengine_product_tag',
1311 'field' => 'slug',
1312 'terms' => $data['tags'],
1313 );
1314 }
1315 if ( count( $tax_query ) > 0 ) {
1316 $tax_query['relation'] = 'AND';
1317 $args['tax_query'] = $tax_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
1318 }
1319
1320 // search
1321 if ( ! empty( $data['search'] ) ) {
1322 $args['s'] = $data['search'];
1323 }
1324
1325 // order by
1326 if ( isset( $data['orderby'] ) ) {
1327 switch ( $data['orderby'] ) {
1328 case 'name':
1329 case 'title':
1330 $args['orderby'] = 'post_title';
1331 $args['order'] = 'asc';
1332 break;
1333 case 'date':
1334 $args['orderby'] = 'publish_date';
1335 $args['order'] = 'desc';
1336 break;
1337 case 'modified':
1338 $args['orderby'] = 'modified';
1339 $args['order'] = 'desc';
1340 break;
1341 case 'menu_order':
1342 $args['orderby'] = 'menu_order';
1343 $args['order'] = 'desc';
1344 break;
1345 default:
1346 $args['orderby'] = 'ID';
1347 $args['order'] = 'desc';
1348 }//end switch
1349 }//end if
1350 return apply_filters( 'storeengine/get_product_archive_search_query_args', $args, $data );
1351 }
1352
1353 public static function get_responsive_column( $columns ): string {
1354 if ( is_array( $columns ) ) {
1355 $device = [
1356 'desktop' => 'lg',
1357 'tablet' => 'md',
1358 'mobile' => 'sm',
1359 ];
1360 $classes = '';
1361 foreach ( $columns as $mode => $column ) {
1362 if ( $column ) {
1363 $classes .= ' storeengine-col-' . $device[ $mode ] . '-' . ceil( 12 / $column );
1364 }
1365 }
1366
1367 return ltrim( $classes );
1368 }
1369
1370 return '';
1371 }
1372
1373 public static function get_permalink_structure() {
1374 $saved_permalinks = (array) get_option( 'storeengine_permalinks', array() );
1375 $permalinks = wp_parse_args(
1376 array_filter( $saved_permalinks ),
1377 array(
1378 'product_base' => _x( 'product', 'slug', 'storeengine' ),
1379 'category_base' => _x( 'product-category', 'slug', 'storeengine' ),
1380 'tag_base' => _x( 'product-tag', 'slug', 'storeengine' ),
1381 'use_verbose_page_rules' => false,
1382 )
1383 );
1384
1385 if ( $saved_permalinks !== $permalinks ) {
1386 update_option( 'storeengine_permalinks', $permalinks );
1387 }
1388
1389 $permalinks['product_rewrite_slug'] = untrailingslashit( $permalinks['product_base'] );
1390 $permalinks['category_rewrite_slug'] = untrailingslashit( $permalinks['category_base'] );
1391 $permalinks['tag_rewrite_slug'] = untrailingslashit( $permalinks['tag_base'] );
1392
1393 return $permalinks;
1394 }
1395
1396 /**
1397 * Switch plugin to site language.
1398 *
1399 * @return void
1400 */
1401 public static function switch_to_site_locale() {
1402 self::switch_to_locale( get_locale() );
1403 }
1404
1405 /**
1406 * Switch plugin to site language.
1407 *
1408 * @return void
1409 */
1410 public static function switch_to_locale( string $locale = 'en_US' ) {
1411 global $wp_locale_switcher;
1412
1413 if ( function_exists( 'switch_to_locale' ) && isset( $wp_locale_switcher ) ) {
1414 switch_to_locale( $locale );
1415
1416 // Filter on plugin_locale so load_plugin_textdomain loads the correct locale.
1417 add_filter( 'plugin_locale', 'get_locale' );
1418 }
1419 }
1420
1421 /**
1422 * Switch plugin language to original.
1423 *
1424 * @return void
1425 */
1426 public static function restore_locale() {
1427 global $wp_locale_switcher;
1428
1429 if ( function_exists( 'restore_previous_locale' ) && isset( $wp_locale_switcher ) ) {
1430 restore_previous_locale();
1431
1432 // Remove filter.
1433 remove_filter( 'plugin_locale', 'get_locale' );
1434 }
1435 }
1436
1437 /**
1438 * Simple check for validating a URL, it must start with http:// or https://.
1439 * and pass FILTER_VALIDATE_URL validation.
1440 *
1441 * @param string $url to check.
1442 *
1443 * @return bool
1444 */
1445 public static function is_valid_url( string $url ): bool {
1446
1447 // Must start with http:// or https://.
1448 /** @noinspection HttpUrlsUsage */
1449 if ( 0 !== strpos( $url, 'http://' ) && 0 !== strpos( $url, 'https://' ) ) {
1450 return false;
1451 }
1452
1453 // Must pass validation.
1454 if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
1455 return false;
1456 }
1457
1458 return true;
1459 }
1460
1461 /**
1462 * Alias for is_valid_url()
1463 *
1464 * @param string $url
1465 *
1466 * @return bool
1467 * @see is_valid_url()
1468 */
1469 public static function is_url( string $url ): bool {
1470 return self::is_valid_url( $url );
1471 }
1472
1473 public static function is_valid_site_url( string $url ): bool {
1474 return str_starts_with( $url, get_option( 'siteurl' ) );
1475 }
1476
1477 public static function get_reveiw_survey_form_radios( $slug ) {
1478 $html = '';
1479 for ( $counter = 1; $counter <= 5; $counter ++ ) {
1480 $html .= sprintf(
1481 "<td><input type='radio' name='%s-rating' value='%s' class='storeengine-radio' /></td>",
1482 $slug,
1483 $counter
1484 );
1485 }
1486
1487 return $html;
1488 }
1489
1490 public static function get_date_format() {
1491 $date_format = get_option( 'date_format' );
1492 if ( empty( $date_format ) ) {
1493 // Return default date format if the option is empty.
1494 $date_format = 'F j, Y';
1495 }
1496
1497 return apply_filters( 'storeengine/date_format', $date_format );
1498 }
1499
1500 public static function single_star_rating_generator( $current_rating = 0.00 ) {
1501 $output = '<span class="storeengine-group-star">';
1502 if ( 5 < $current_rating && 0 > $current_rating ) {
1503 $output .= '<i class="storeengine-icon storeengine-icon--star-fill"></i>';
1504 } elseif ( 0 === $current_rating ) {
1505 $output .= '<i class="storeengine-icon storeengine-icon--star-fill"></i>';
1506 } else {
1507 $output .= '<i class="storeengine-icon storeengine-icon--star-fill"></i>';
1508 }
1509 $output .= '</span>';
1510
1511 return $output;
1512 }
1513
1514 public static function star_rating_generator( $current_rating = 0.00 ) {
1515 $output = '<span class="storeengine-group-star">';
1516
1517 for ( $i = 1; $i <= 5; $i ++ ) {
1518 $intRating = (int) $current_rating;
1519
1520 if ( $intRating >= $i ) {
1521 $output .= '<i class="storeengine-icon storeengine-icon--star-fill" data-rating-value="' . $i . '"></i>';
1522 } else {
1523 if ( ( $current_rating - $i ) === - 0.5 ) {
1524 $output .= '<i class="storeengine-icon storeengine-icon--star-half" data-rating-value="' . $i . '"></i>';
1525 } else {
1526 $output .= '<i class="storeengine-icon storeengine-icon--star-line" data-rating-value="' . $i . '"></i>';
1527 }
1528 }
1529 }
1530
1531 $output .= '</span>';
1532
1533 return $output;
1534 }
1535
1536 /**
1537 * @param $product_id
1538 * @param $user_id
1539 *
1540 * @return string|null
1541 *
1542 * Mirrors the standard "has the customer bought this product" check.
1543 */
1544 public static function is_purchase_the_product( $product_id, $user_id = 0 ): ?string {
1545 global $wpdb;
1546
1547 if ( ! $user_id ) {
1548 $user_id = get_current_user_id();
1549 }
1550
1551 // @TODO cache user's purchase story for 30 days.
1552 // See the standard "has the customer bought this product" check for details.
1553
1554 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1555 return $wpdb->get_var(
1556 $wpdb->prepare(
1557 "SELECT o.id
1558 FROM {$wpdb->prefix}storeengine_orders o
1559 JOIN {$wpdb->prefix}storeengine_order_product_lookup op ON o.id = op.order_id
1560 WHERE o.customer_id = %d
1561 AND op.product_id = %d
1562 AND o.status = 'completed'
1563 LIMIT 1;",
1564 $user_id,
1565 $product_id
1566 )
1567 );
1568 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1569 }
1570
1571 public static function is_purchase_the_membership( $product_id, $price_id, $customer_id = 0, $order_status = Constants::ORDER_STATUS_COMPLETED ) {
1572 if ( ! is_user_logged_in() ) {
1573 return false;
1574 }
1575
1576 global $wpdb;
1577
1578 if ( ! $customer_id ) {
1579 $customer_id = get_current_user_id();
1580 }
1581
1582 $user_meta = get_user_meta( $customer_id, '_storeengine_memberships', true );
1583
1584 if ( is_array( $user_meta ) ) {
1585 $result = false;
1586 foreach ( $user_meta as $u_meta ) {
1587 if ( $price_id === $u_meta['price_id'] && Constants::ORDER_STATUS_COMPLETED === $u_meta['order_status'] ) {
1588 $result = true;
1589 break;
1590 }
1591 }
1592
1593 return $result;
1594 }
1595
1596 $user_meta = [];
1597 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- query result cached in user meta
1598 $count = (int) $wpdb->get_var( $wpdb->prepare(
1599 "SELECT COUNT(*) FROM {$wpdb->prefix}storeengine_order_product_lookup op
1600 JOIN {$wpdb->prefix}storeengine_orders o ON op.order_id = o.id
1601 WHERE op.product_id = %d
1602 AND op.price_id = %d
1603 AND o.customer_id = %d
1604 AND o.status = %s",
1605 $product_id,
1606 $price_id,
1607 $customer_id,
1608 $order_status
1609 ) );
1610 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- query result cached in user meta
1611
1612 if ( $count ) {
1613 $user_meta[] = compact( 'customer_id', 'price_id', 'order_status' );
1614 }
1615 update_user_meta( $customer_id, '_storeengine_memberships', $user_meta );
1616
1617 return $count;
1618 }
1619
1620 /**
1621 * Used to sort shipping zone methods with uasort.
1622 *
1623 * @param ShippingMethod|\stdClass $a First shipping zone method to compare.
1624 * @param ShippingMethod|\stdClass $b Second shipping zone method to compare.
1625 *
1626 * @return int
1627 */
1628 public static function shipping_zone_method_order_uasort_comparison( $a, $b ): int {
1629 return self::uasort_comparison( $a->method_order, $b->method_order );
1630 }
1631
1632 /**
1633 * User to sort checkout fields based on priority with uasort.
1634 *
1635 * @param array $a First field to compare.
1636 * @param array $b Second field to compare.
1637 *
1638 * @return int
1639 */
1640 public static function checkout_fields_uasort_comparison( array $a, array $b ): int {
1641 /*
1642 * We are not guaranteed to get a priority
1643 * setting. So don't compare if they don't
1644 * exist.
1645 */
1646 if ( ! isset( $a['priority'], $b['priority'] ) ) {
1647 return 0;
1648 }
1649
1650 return self::uasort_comparison( $a['priority'], $b['priority'] );
1651 }
1652
1653 /**
1654 * User to sort two values with uasort.
1655 *
1656 * @param int $a First value to compare.
1657 * @param int $b Second value to compare.
1658 *
1659 * @return int
1660 */
1661 public static function uasort_comparison( int $a, int $b ): int {
1662 if ( $a === $b ) {
1663 return 0;
1664 }
1665
1666 return ( $a < $b ) ? - 1 : 1;
1667 }
1668
1669 /**
1670 * Merge two arrays.
1671 *
1672 * @param array $a1 First array to merge.
1673 * @param array $a2 Second array to merge.
1674 *
1675 * @return array
1676 */
1677 public static function array_overlay( array $a1, array $a2 ): array {
1678 foreach ( $a1 as $k => $v ) {
1679 if ( ! array_key_exists( $k, $a2 ) ) {
1680 continue;
1681 }
1682 if ( is_array( $v ) && is_array( $a2[ $k ] ) ) {
1683 $a1[ $k ] = self::array_overlay( $v, $a2[ $k ] );
1684 } else {
1685 $a1[ $k ] = $a2[ $k ];
1686 }
1687 }
1688
1689 return $a1;
1690 }
1691
1692 /**
1693 * Set a cookie - wrapper for setcookie using WP constants.
1694 *
1695 * @param string $name Name of the cookie being set.
1696 * @param string|int|float $value Value of the cookie.
1697 * @param integer $expire Expiry of the cookie.
1698 * @param bool $secure Whether the cookie should be served only over https.
1699 * @param bool $httponly Whether the cookie is only accessible over HTTP, not scripting languages like JavaScript.
1700 */
1701 public static function setcookie( string $name, $value, int $expire = 0, bool $secure = false, bool $httponly = false ): void {
1702 /**
1703 * Controls whether the cookie should be set.
1704 *
1705 * @param bool $set_cookie_enabled If the cookie should be set.
1706 * @param string $name Cookie name.
1707 * @param string $value Cookie value.
1708 * @param integer $expire When the cookie should expire.
1709 * @param bool $secure If the cookie should only be served over HTTPS.
1710 */
1711 if ( ! apply_filters( 'storeengine/set_cookie_enabled', true, $name, $value, $expire, $secure ) ) {
1712 return;
1713 }
1714
1715 if ( ! headers_sent() ) {
1716 /**
1717 * Controls the options to be specified when setting the cookie.
1718 *
1719 * @see https://www.php.net/manual/en/function.setcookie.php
1720 *
1721 * @param array $cookie_options Cookie options.
1722 * @param string $name Cookie name.
1723 * @param string $value Cookie value.
1724 */
1725 $options = apply_filters(
1726 'storeengine/set_cookie_options',
1727 [
1728 'expires' => $expire,
1729 'secure' => $secure,
1730 'path' => COOKIEPATH ? COOKIEPATH : '/',
1731 'domain' => COOKIE_DOMAIN,
1732 /**
1733 * Controls whether the cookie should only be accessible via the HTTP protocol, or if it should also be
1734 * accessible to Javascript.
1735 *
1736 * @see https://www.php.net/manual/en/function.setcookie.php
1737 *
1738 * @param bool $httponly If the cookie should only be accessible via the HTTP protocol.
1739 * @param string $name Cookie name.
1740 * @param string $value Cookie value.
1741 * @param int $expire When the cookie should expire.
1742 * @param bool $secure If the cookie should only be served over HTTPS.
1743 */
1744 'httponly' => apply_filters( 'storeengine/cookie_httponly', $httponly, $name, $value, $expire, $secure ),
1745 ],
1746 $name,
1747 $value
1748 );
1749
1750 setcookie( $name, $value, $options );
1751 } elseif ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1752 headers_sent( $file, $line );
1753 trigger_error( esc_html( "{$name} cookie cannot be set - headers already sent by {$file} on line {$line}" ), E_USER_NOTICE ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error, WordPress.Security.EscapeOutput.OutputNotEscaped
1754 }
1755 }
1756
1757 /**
1758 * What type of request is this?
1759 *
1760 * @param string $type admin, ajax, cron or frontend.
1761 *
1762 * @return bool
1763 */
1764 public static function is_request( string $type ): bool {
1765 switch ( $type ) {
1766 case 'ref-admin':
1767 return self::is_admin_request();
1768 case 'ref-frontend':
1769 return ! self::is_admin_request();
1770 case 'admin':
1771 return is_admin();
1772 case 'ajax':
1773 // self::is_request( 'admin' ) is always true here.
1774 // should be paired with ref-admin check to diff between admin/frontend ajax.
1775 return defined( 'DOING_AJAX' );
1776 case 'cron':
1777 return defined( 'DOING_CRON' );
1778 case 'frontend':
1779 return ( ! is_admin() || defined( 'DOING_AJAX' ) ) && ! defined( 'DOING_CRON' ) && ! self::is_rest_api_request();
1780 case 'rest':
1781 case 'restapi':
1782 return self::is_rest_api_request();
1783 default:
1784 return false;
1785 }
1786 }
1787
1788 public static function is_admin_request(): bool {
1789 if( defined( 'STOREENGINE_DOING_ADMIN_REFERER_REQUEST' ) ) {
1790 return STOREENGINE_DOING_ADMIN_REFERER_REQUEST;
1791 }
1792 if ( ! function_exists( 'wp_validate_redirect' ) ) {
1793 require_once ABSPATH . WPINC . '/pluggable.php';
1794 }
1795
1796 $is_ref_admin = str_starts_with( strtolower( wp_get_referer() ), strtolower( admin_url() ) );
1797
1798 define( 'STOREENGINE_DOING_ADMIN_REFERER_REQUEST', $is_ref_admin );
1799
1800 return $is_ref_admin;
1801 }
1802
1803 /**
1804 * Returns true if the request is a non-legacy REST API request.
1805 *
1806 * Legacy REST requests should still run some extra code for backwards compatibility.
1807 *
1808 * @todo: replace this function once core WP function is available: https://core.trac.wordpress.org/ticket/42061.
1809 *
1810 * @return bool
1811 */
1812 public static function is_rest_api_request(): bool {
1813 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
1814 return true;
1815 }
1816
1817 if ( empty( $_SERVER['REQUEST_URI'] ) ) {
1818 return false;
1819 }
1820
1821 $rest_prefix = trailingslashit( rest_get_url_prefix() );
1822 $is_rest_api_request = ( false !== strpos( $_SERVER['REQUEST_URI'], $rest_prefix ) ); // phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1823
1824 /**
1825 * Whether this is a REST API request.
1826 */
1827 return apply_filters( 'storeengine/is_rest_api_request', $is_rest_api_request );
1828 }
1829
1830 /**
1831 * Wrapper for set_time_limit to see if it is enabled.
1832 *
1833 * @param int $limit Time limit.
1834 */
1835 public static function set_time_limit( int $limit = 0 ) {
1836 if ( function_exists( 'set_time_limit' ) && false === strpos( ini_get( 'disable_functions' ), 'set_time_limit' ) && ! ini_get( 'safe_mode' ) ) { // phpcs:ignore PHPCompatibility.IniDirectives.RemovedIniDirectives.safe_modeDeprecatedRemoved
1837 @set_time_limit( $limit ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged, WordPress.PHP.NoSilencedErrors.Discouraged -- server may choose to disable this function.
1838 }
1839 }
1840
1841 public static function get_product_term_ids( $product_id, $taxonomy ) {
1842 $terms = get_the_terms( $product_id, $taxonomy );
1843
1844 return ( empty( $terms ) || is_wp_error( $terms ) ) ? array() : wp_list_pluck( $terms, 'term_id' );
1845 }
1846
1847 /**
1848 * Get all product cats for a product by ID, including hierarchy
1849 *
1850 * @param int $product_id Product ID.
1851 *
1852 * @return array
1853 */
1854 public static function get_product_cat_ids( int $product_id ): array {
1855 $product_cats = self::get_product_term_ids( $product_id, self::PRODUCT_CATEGORY_TAXONOMY );
1856
1857 foreach ( $product_cats as $product_cat ) {
1858 $product_cats = array_merge( $product_cats, get_ancestors( $product_cat, self::PRODUCT_CATEGORY_TAXONOMY, 'taxonomy' ) );
1859 }
1860
1861 return $product_cats;
1862 }
1863
1864 public static function get_coupon_types(): array {
1865 return (array) apply_filters( 'storeengine/product_coupon_types', [ 'percentage', 'fixedAmount' ] );
1866 }
1867
1868 /**
1869 * Return a list of potential postcodes for wildcard searching.
1870 *
1871 * @param string $postcode Postcode.
1872 * @param string $country Country to format postcode for matching.
1873 *
1874 * @return string[]
1875 */
1876 public static function get_wildcard_postcodes( $postcode, $country = '' ) {
1877 $formatted_postcode = Formatting::format_postcode( $postcode, $country );
1878 $length = function_exists( 'mb_strlen' ) ? mb_strlen( $formatted_postcode ) : strlen( $formatted_postcode );
1879 $postcodes = [
1880 $postcode,
1881 $formatted_postcode,
1882 $formatted_postcode . '*',
1883 ];
1884
1885 for ( $i = 0; $i < $length; $i ++ ) {
1886 $postcodes[] = ( function_exists( 'mb_substr' ) ? mb_substr( $formatted_postcode, 0, ( $i + 1 ) * - 1 ) : substr( $formatted_postcode, 0, ( $i + 1 ) * - 1 ) ) . '*';
1887 }
1888
1889 return $postcodes;
1890 }
1891
1892 /**
1893 * Used by shipping zones and taxes to compare a given $postcode to stored
1894 * postcodes to find matches for numerical ranges, and wildcards.
1895 *
1896 * @param string $postcode Postcode you want to match against stored postcodes.
1897 * @param array $objects Array of postcode objects from Database.
1898 * @param string $object_id_key DB column name for the ID.
1899 * @param string $object_compare_key DB column name for the value.
1900 * @param string $country Country from which this postcode belongs. Allows for formatting.
1901 *
1902 * @return array Array of matching object ID and matching values.
1903 */
1904 public static function postcode_location_matcher( $postcode, $objects, $object_id_key, $object_compare_key, $country = '' ) {
1905 $postcode = Formatting::normalize_postcode( $postcode );
1906 $wildcard_postcodes = array_map( [
1907 Formatting::class,
1908 'clean',
1909 ], self::get_wildcard_postcodes( $postcode, $country ) );
1910 $matches = [];
1911
1912 foreach ( $objects as $object ) {
1913 $object_id = $object->$object_id_key;
1914 $compare_against = $object->$object_compare_key;
1915
1916 // Handle postcodes containing ranges.
1917 if ( strstr( $compare_against, '...' ) ) {
1918 $range = array_map( 'trim', explode( '...', $compare_against ) );
1919
1920 if ( 2 !== count( $range ) ) {
1921 continue;
1922 }
1923
1924 list( $min, $max ) = $range;
1925
1926 // If the postcode is non-numeric, make it numeric.
1927 if ( ! is_numeric( $min ) || ! is_numeric( $max ) ) {
1928 $compare = Formatting::make_numeric_postcode( $postcode );
1929 $min = str_pad( Formatting::make_numeric_postcode( $min ), strlen( $compare ), '0' );
1930 $max = str_pad( Formatting::make_numeric_postcode( $max ), strlen( $compare ), '0' );
1931 } else {
1932 $compare = $postcode;
1933 }
1934
1935 if ( $compare >= $min && $compare <= $max ) {
1936 $matches[ $object_id ] = $matches[ $object_id ] ?? [];
1937 $matches[ $object_id ][] = $compare_against;
1938 }
1939 } elseif ( in_array( $compare_against, $wildcard_postcodes, true ) ) {
1940 // Wildcard and standard comparison.
1941 $matches[ $object_id ] = $matches[ $object_id ] ?? [];
1942 $matches[ $object_id ][] = $compare_against;
1943 }
1944 }
1945
1946 return $matches;
1947 }
1948
1949 /**
1950 * Based on wp_list_pluck, this calls a method instead of returning a property.
1951 *
1952 * @param array $list List of objects or arrays.
1953 * @param int|string $callback_or_field Callback method from the object to place instead of the entire object.
1954 * @param int|string $index_key Optional. Field from the object to use as keys for the new array.
1955 * Default null.
1956 *
1957 * @return array Array of values.
1958 */
1959 public static function list_pluck( array $list, $callback_or_field, $index_key = null ): array {
1960 // Use wp_list_pluck if this isn't a callback.
1961 $first_el = current( $list );
1962 if ( ! is_object( $first_el ) || ! is_callable( [ $first_el, $callback_or_field ] ) ) {
1963 return wp_list_pluck( $list, $callback_or_field, $index_key );
1964 }
1965 if ( ! $index_key ) {
1966 /*
1967 * This is simple. Could at some point wrap array_column()
1968 * if we knew we had an array of arrays.
1969 */
1970 foreach ( $list as $key => $value ) {
1971 $list[ $key ] = $value->{$callback_or_field}();
1972 }
1973
1974 return $list;
1975 }
1976
1977 /*
1978 * When index_key is not set for a particular item, push the value
1979 * to the end of the stack. This is how array_column() behaves.
1980 */
1981 $newlist = [];
1982 foreach ( $list as $value ) {
1983 // Get index. @since 3.2.0 this supports a callback.
1984 if ( is_callable( array( $value, $index_key ) ) ) {
1985 $newlist[ $value->{$index_key}() ] = $value->{$callback_or_field}();
1986 } elseif ( isset( $value->$index_key ) ) {
1987 $newlist[ $value->$index_key ] = $value->{$callback_or_field}();
1988 } else {
1989 $newlist[] = $value->{$callback_or_field}();
1990 }
1991 }
1992
1993 return $newlist;
1994 }
1995
1996 /**
1997 * Get an item of post data if set, otherwise return a default value.
1998 *
1999 * @param string $key Meta key.
2000 * @param mixed $default Default value.
2001 *
2002 * @return mixed Value sanitized by Formatting::clean.
2003 */
2004 public static function get_post_data_by_key( string $key, $default = '' ) {
2005 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput, WordPress.Security.NonceVerification.Missing
2006 return Formatting::clean( wp_unslash( self::get_var( $_POST[ $key ], $default ) ) );
2007 }
2008
2009 /**
2010 * Get data if set, otherwise return a default value or null. Prevents notices when data is not set.
2011 *
2012 * @param mixed $var Variable.
2013 * @param mixed $default Default value.
2014 *
2015 * @return mixed
2016 */
2017 public static function get_var( &$var, $default = null ) {
2018 return isset( $var ) ? $var : $default;
2019 }
2020
2021 public static function is_storeengine_page( int $post_id = 0 ): bool {
2022 if ( ! $post_id ) {
2023 $post_id = get_the_ID();
2024 }
2025
2026 return in_array( (int) $post_id, self::get_storeengine_page_ids(), true );
2027 }
2028
2029 public static function get_storeengine_page_ids(): array {
2030 $settings_keys = [
2031 'checkout_page',
2032 'shop_page',
2033 'store_shop',
2034 'cart_page',
2035 'thankyou_page',
2036 'dashboard_page',
2037 'membership_pricing_page',
2038 'affiliate_registration_page',
2039 ];
2040
2041 $page_ids = array_map( fn( $key ) => (int) self::get_settings( $key ), $settings_keys );
2042
2043 return array_filter( $page_ids );
2044 }
2045
2046 /**
2047 * Is registration required to checkout?
2048 *
2049 * @return boolean
2050 */
2051 public static function is_registration_required(): bool {
2052 /**
2053 * Controls if registration is required in order for checkout to be completed.
2054 *
2055 * @param bool $checkout_registration_required If customers must be registered to checkout.
2056 */
2057 return apply_filters( 'storeengine/checkout/registration_required', ! self::get_settings( 'enable_guest_checkout', true ) );
2058 }
2059
2060 /**
2061 * Define a constant if it is not already defined.
2062 *
2063 * @param string $name Constant name.
2064 * @param mixed $value Value.
2065 */
2066 public static function maybe_define_constant( string $name, $value ) {
2067 if ( ! defined( $name ) ) {
2068 define( $name, $value ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.VariableConstantNameFound
2069 }
2070 }
2071
2072 public static function get_assets_url( string $path = '' ): string {
2073 return STOREENGINE_ASSETS_URI . ltrim( $path, '/\\' );
2074 }
2075
2076 public static function get_plugin_url( string $path = '' ): string {
2077 return STOREENGINE_PLUGIN_ROOT_URI . ltrim( $path, '/\\' );
2078 }
2079
2080 public static function get_addons_url( string $addon, string $path = '' ): string {
2081 return self::get_plugin_url( 'addons/' . ltrim( rtrim( $addon, '/\\' ), '/\\' ) . '/' . ltrim( $path, '/\\' ) );
2082 }
2083
2084 public static function get_upload_dir(): string {
2085 $upload = wp_upload_dir();
2086
2087 return $upload['basedir'] . '/storeengine_uploads';
2088 }
2089
2090 /**
2091 * @param array $arr
2092 * @param callable $predicate
2093 *
2094 * @return bool
2095 * @deprecated 1.8.0
2096 * @see ArrayUtil::every()
2097 */
2098 public static function array_every( array $arr, callable $predicate ): bool {
2099 return ArrayUtil::every( $arr, $predicate );
2100 }
2101
2102 /**
2103 * @param array $arr
2104 * @param callable $predicate
2105 *
2106 * @return bool
2107 * @deprecated 1.8.0
2108 * @see ArrayUtil::any()
2109 */
2110 public static function array_any( array $arr, callable $predicate ): bool {
2111 return ArrayUtil::any( $arr, $predicate );
2112 }
2113
2114 public static function masked_key_preview( string $key, string $name = null, $args = [] ) {
2115 $args = wp_parse_args( $args, [
2116 'start' => 8,
2117 'end' => 2,
2118 'mask' => '',
2119 'size' => 5,
2120 'unmask' => true,
2121 'copy' => true,
2122 ] );
2123
2124 if ( empty( $name ) ) {
2125 $name = __( 'Key', 'storeengine' );
2126 }
2127
2128 $end = absint( $args['end'] );
2129 $masked = substr( $key, 0, absint( $args['start'] ) );
2130 $masked .= str_repeat( $args['mask'], absint( $args['size'] ) );
2131 if ( $end ) {
2132 $masked .= substr( $key, - 1 * $end );
2133 }
2134 ?>
2135 <div class="masked-key-preview storeengine-flex storeengine-flex-align-center">
2136 <?php if ( $args['unmask'] ) { ?>
2137 <button
2138 class="toggle-key-mask storeengine-btn storeengine-btn--md storeengine-btn--preset-transparent"
2139 style="--icon-size:1.1em;padding:10px" type="button"
2140 data-key-name="<?php echo esc_attr( $name ); ?>"
2141 aria-label="<?php printf(
2142 // translators: %s Masked key name.
2143 esc_attr__( 'Show %s', 'storeengine' ),
2144 esc_attr( $name ),
2145 ); ?>">
2146 <span class="storeengine-icon storeengine-icon--eye-alt" aria-hidden="true"></span>
2147 </button>
2148 <?php } ?>
2149 <span class="preview-masked"
2150 style="font-size:0.84em;font-weight:600;border: 1px solid transparent;outline:none;padding:5px;border-radius:4px;"><?php echo esc_html( $masked ); ?></span>
2151 <?php if ( $args['unmask'] ) { ?>
2152 <input class="preview-unmasked" type="text" value="<?php echo esc_attr( $key ); ?>"
2153 onclick="select(this)" readonly aria-label="<?php echo esc_attr( $name ); ?>"
2154 style="font-size:0.84em;font-weight:600;border: 1px solid var(--storeengine-border-color);outline:none;padding:5px;border-radius:4px;display:none;"/>
2155 <?php } ?>
2156 <?php if ( $args['copy'] ) { ?>
2157 <button
2158 class="copy-to-clipboard storeengine-btn storeengine-btn--md storeengine-btn--preset-transparent"
2159 style="--icon-size:1.1em;padding:10px" type="button"
2160 data-content="<?php echo esc_attr( $key ); ?>"
2161 data-content-name="<?php echo esc_attr( $name ); ?>"
2162 aria-label="<?php printf(
2163 // translators: %s Masked key name.
2164 esc_attr__( 'Copy %s', 'storeengine' ),
2165 esc_attr( $name ),
2166 ); ?>">
2167 <span class="storeengine-icon storeengine-icon--duplicate" aria-hidden="true"></span>
2168 </button>
2169 <?php } ?>
2170 </div>
2171 <?php
2172 }
2173
2174 /**
2175 * Implodes an array into a human-readable string with a localized conjunction before the last item.
2176 *
2177 * Examples:
2178 * - Helper::implode_with( ['a'] ); // Output: "a"
2179 * - Helper::implode_with( ['a', 'b'] ); // Output: "a or b"
2180 * - Helper::implode_with( ['a', 'b', 'c'] ); // Output: "a, b or c"
2181 * - Helper::implode_with( ['a', 'b', 'c'], 'and' ); // Output: "a, b and c"
2182 * - Helper::implode_with( ['a', 'b', 'c'], '', '-' ); // Output: "a-b or c"
2183 * - Helper::implode_with( ['a', 'b', 'c'], 'and', ' - ' ); // Output: "a - b and c"
2184 *
2185 * @param array $items The list of strings to join.
2186 * @param string $conjunction Optional. The conjunction to use before the last item (e.g. 'or', 'and').
2187 * If empty, defaults to localized 'or'.
2188 * @param string $glue Optional. Glue (separator) for joining the list of words.
2189 * Default to localized space after comma `, `.
2190 *
2191 * @return string The imploded string.
2192 */
2193 public static function implode_with( array $items, string $conjunction = '', string $glue = '' ): string {
2194 $count = count( $items );
2195
2196 if ( '' === $conjunction ) {
2197 $conjunction = _x( 'or', 'Conjunction before last word of an array.', 'storeengine' );
2198 }
2199
2200 if ( '' === $glue ) {
2201 $glue = _x( ', ', 'Glue/separator for joining word of an array (except last word).', 'storeengine' );
2202 }
2203
2204 if ( $count === 0 ) {
2205 return '';
2206 }
2207
2208 if ( $count === 1 ) {
2209 return $items[0];
2210 }
2211
2212 if ( $count === 2 ) {
2213 return $items[0] . " $conjunction " . $items[1];
2214 }
2215
2216 $last = array_pop( $items );
2217
2218 return implode( $glue, $items ) . ", $conjunction " . $last;
2219 }
2220
2221 public static function rename_array_keys( array $data, array $mapping ): array {
2222 $remapped = [];
2223
2224 foreach ( $data as $key => $value ) {
2225 $newKey = $mapping[ $key ] ?? $key; // fallback to original if not mapped
2226 $remapped[ $newKey ] = $value;
2227 }
2228
2229 return $remapped;
2230 }
2231
2232 public static function get_filename_without_extension( string $filename ) {
2233 return pathinfo( $filename, PATHINFO_FILENAME );
2234 }
2235
2236 /**
2237 * Log critical errors to the StoreEngine Logger.
2238 *
2239 * @param string|\Throwable $exception Exception object or string message.
2240 * @param bool $trace Whether to include the stack trace.
2241 */
2242 public static function log_error( $exception, bool $trace = true ) {
2243 $message = is_string( $exception ) ? $exception : $exception->getMessage();
2244
2245 $title = 'Error';
2246 $log_data = [ 'message' => $message ];
2247 $status = Logger::WARNING;
2248
2249 $context = [];
2250
2251 if ( $exception instanceof StoreEngineException ) {
2252 if ( $trace ) {
2253 $context = $exception->to_array( StoreEngineException::WITH_PREVIOUS & StoreEngineException::WITH_WP_TRACE );
2254 } else {
2255 $context = $exception->to_array( StoreEngineException::WITH_PREVIOUS );
2256 }
2257 } else {
2258 // If it is a Throwable/Exception object, add the code, file, and line number to the log.
2259 if ( $exception instanceof \Throwable ) {
2260 $title = 'Critical Error';
2261 $status = Logger::CRITICAL;
2262 $log_data['code'] = $exception->getCode();
2263 $log_data['file'] = $exception->getFile();
2264 $log_data['line'] = $exception->getLine();
2265 }
2266
2267 if ( $trace ) {
2268 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_wp_debug_backtrace_summary -- Capturing a backtrace to persist into the StoreEngine Logger context, not debug output.
2269 $context['backtrace'] = wp_debug_backtrace_summary( self::class, 0, false );
2270 }
2271 }
2272
2273
2274
2275 if ( $context ) {
2276 $log_data['context'] = $context;
2277 }
2278
2279 // Save to the StoreEngine Logger (legacy error_log implementation has been removed).
2280 Logger::log( $title, $log_data, $status, 'system' );
2281 }
2282
2283 /**
2284 * Sanitise + normalise an email body for output inside a StoreEngine email
2285 * shell (`templates/email/*.php`).
2286 *
2287 * Every email template echoes its body through this instead of a bare
2288 * `wp_kses_post()`. It handles the block-email builder that replaced the old
2289 * Quill editor: the builder (Easy Mail Builder, `renderMode="fragment"`)
2290 * still emits its OWN centred 600px "card" plus Outlook/IE conditional
2291 * ("ghost") tables around the content. Dropped verbatim into our shell that
2292 * produced two visible problems:
2293 *
2294 * 1. `wp_kses_post()` entity-encodes the `>`/`<` inside `<!--[if mso | IE]>`
2295 * … `<![endif]-->`, so mail clients that don't strip the now-malformed
2296 * comment render it as literal "<!--[if mso | IE]>" text.
2297 * 2. The builder's card nested inside our own `.container/.content/.wrapper`
2298 * card — a second box offset inside the first ("body overlapping").
2299 *
2300 * StoreEngine already wraps every body in its own centred, CSS-inlined shell,
2301 * so the builder's outer wrapper is redundant here. We strip the ghost tables
2302 * and unwrap the outer card so the builder's inner blocks flow directly into
2303 * our content area. Plain HTML bodies (the Quill-era defaults, plain-text
2304 * mode, hand-written templates) never carry the ghost marker and pass through
2305 * untouched.
2306 *
2307 * @param string|null $content Raw email body HTML.
2308 * @return string Sanitised, shell-ready HTML.
2309 */
2310 public static function render_email_content( $content ): string {
2311 return wp_kses_post( self::normalize_email_content( (string) $content ) );
2312 }
2313
2314 /**
2315 * Strip the Easy Mail Builder's redundant outer wrapper (Outlook ghost
2316 * tables + the centred 600px card) so its inner blocks land cleanly inside
2317 * StoreEngine's email shell. A no-op for any body that isn't builder output.
2318 *
2319 * @param string $content
2320 * @return string
2321 */
2322 public static function normalize_email_content( string $content ): string {
2323 // Only the block builder emits Outlook conditional ("ghost") wrappers;
2324 // plain HTML bodies never do, so leave them entirely untouched.
2325 if ( false === strpos( $content, '[if' ) ) {
2326 return $content;
2327 }
2328
2329 // 1. Remove the Outlook/IE conditional ghost tables. Left in place they
2330 // both nest a redundant card and — once wp_kses_post mangles them —
2331 // leak as literal "<!--[if mso | IE]>" text.
2332 //
2333 // The markers may arrive RAW (`]>` … `<![endif]`) straight from the
2334 // builder, OR already entity-encoded (`]&gt;` … `&lt;![endif]`) because
2335 // the save path runs the body through wp_kses_post before storing, and
2336 // wp_kses_post encodes the `>`/`<` inside conditional comments. Match
2337 // both shapes so old raw saves and current encoded saves both strip.
2338 $stripped = preg_replace( '/<!--\[if[^\]]*\](?:>|&gt;).*?(?:<|&lt;)!\[endif\]-->/is', '', $content );
2339 if ( null === $stripped ) {
2340 // PCRE failure (e.g. backtrack limit) — fall back to the raw content
2341 // rather than returning null.
2342 return $content;
2343 }
2344 $content = trim( $stripped );
2345
2346 // 2. Unwrap the builder's outer 600px "card" table. Only the builder
2347 // emits a top-level <table> as the very first node; plain bodies open
2348 // with <p>/<h*>. DOMDocument keeps the (possibly nested) inner block
2349 // tables intact while lifting out just the card's single cell.
2350 if ( class_exists( 'DOMDocument' ) && 0 === stripos( $content, '<table' ) ) {
2351 $dom = new \DOMDocument();
2352 $libxml_previous = libxml_use_internal_errors( true );
2353 // The XML prolog forces UTF-8; the wrapper div gives us a stable
2354 // query root. NOIMPLIED/NODEFDTD stop DOMDocument injecting its own
2355 // <html><body> chrome around our fragment.
2356 $dom->loadHTML(
2357 '<?xml encoding="UTF-8"><div id="storeengine-email-root">' . $content . '</div>',
2358 LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
2359 );
2360 libxml_clear_errors();
2361 libxml_use_internal_errors( $libxml_previous );
2362
2363 $xpath = new \DOMXPath( $dom );
2364 // DOMDocument auto-inserts <tbody>; match both shapes.
2365 $cells = $xpath->query( "//div[@id='storeengine-email-root']/table[1]/tbody/tr/td | //div[@id='storeengine-email-root']/table[1]/tr/td" );
2366 if ( $cells && $cells->length ) {
2367 $inner = '';
2368 foreach ( $cells->item( 0 )->childNodes as $child ) {
2369 $inner .= $dom->saveHTML( $child );
2370 }
2371 $content = trim( $inner );
2372 }
2373 }
2374
2375 return $content;
2376 }
2377 }
2378