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 / frontend / functions.php

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

4,374 lines 151.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Frontend helper functions.
4 */
5
6 use StoreEngine\Addons\Subscription\Hooks;
7 use StoreEngine\Classes\AbstractCollection;
8 use StoreEngine\Classes\AbstractOrder;
9 use StoreEngine\Classes\Attributes;
10 use StoreEngine\Classes\Cart;
11 use StoreEngine\Classes\Countries;
12 use StoreEngine\Classes\Exceptions\StoreEngineException;
13 use StoreEngine\Classes\Exceptions\StoreEngineNotFoundException;
14 use StoreEngine\Classes\Order;
15 use StoreEngine\Classes\Order\OrderItemProduct;
16 use StoreEngine\Classes\OrderStatus\OrderStatus;
17 use StoreEngine\Classes\Price;
18 use StoreEngine\Classes\Product\SimpleProduct;
19 use StoreEngine\Classes\Product\VariableProduct;
20 use StoreEngine\Classes\ProductFactory;
21 use StoreEngine\Classes\StoreengineDatetime;
22 use StoreEngine\Utils\ArrayUtil;
23 use StoreEngine\Utils\Formatting;
24 use StoreEngine\Utils\Helper;
25 use StoreEngine\Utils\ShippingUtils;
26 use StoreEngine\Utils\Template;
27
28 if ( ! defined( 'ABSPATH' ) ) {
29 exit;
30 }
31
32 /**
33 * Handle redirects before content is output - hooked into template_redirect so is_page works.
34 *
35 * @return void
36 *
37 * Mirrors the standard template-redirect handler.
38 */
39 function storeengine_template_redirect(): void {
40 global $wp_query, $wp;
41
42 if ( ! is_user_logged_in() && Helper::is_dashboard() ) {
43 $auth_redirect_type = Helper::get_settings( 'auth_redirect_type', 'storeengine' );
44 if ( 'storeengine' !== $auth_redirect_type ) {
45 wp_safe_redirect( storeengine_login_url( Helper::get_dashboard_url() ) );
46 die();
47 }
48 }
49
50 // phpcs:disable WordPress.Security.NonceVerification.Recommended
51 // When default permalinks are enabled, redirect shop page to post type archive url.
52 if ( ! empty( $_GET['page_id'] ) && '' === get_option( 'permalink_structure' ) && Helper::get_settings( 'shop_page' ) === absint( $_GET['page_id'] ) && get_post_type_archive_link( Helper::PRODUCT_POST_TYPE ) ) {
53 wp_safe_redirect( get_post_type_archive_link( Helper::PRODUCT_POST_TYPE ) );
54 exit;
55 }
56
57 // phpcs:enable WordPress.Security.NonceVerification.Recommended
58
59 // When on the checkout with an empty cart, redirect to cart page.
60 if (
61 is_page( Helper::get_settings( 'checkout_page' ) ) &&
62 Helper::get_settings( 'checkout_page' ) !== Helper::get_settings( 'cart_page' ) &&
63 Helper::cart()->is_cart_empty() &&
64 empty( $wp->query_vars['order-pay'] ) &&
65 ! isset( $wp->query_vars['order-received'] ) &&
66 ! isset( $wp->query_vars['order_pay'] ) &&
67 ! is_customize_preview() &&
68 apply_filters( 'storeengine/checkout_redirect_empty_cart', true )
69 ) {
70 wp_safe_redirect( Helper::get_page_permalink( 'cart_page' ) );
71 exit;
72 }
73
74 // phpcs:disable WordPress.Security.NonceVerification.Recommended
75 // Logout endpoint under My Account page. Logging out requires a valid nonce.
76 if ( Helper::is_endpoint( 'customer-logout' ) ) {
77 if ( ! empty( $_REQUEST['_wpnonce'] ) && wp_verify_nonce( sanitize_key( $_REQUEST['_wpnonce'] ), 'customer-logout' ) ) {
78 wp_logout();
79 if ( ! empty( $_REQUEST['redirect_to'] ) ) {
80 $redirect_to = esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) );
81 } else {
82 $redirect_to = storeengine_get_logout_redirect_url();
83 }
84
85 wp_safe_redirect( wp_validate_redirect( $redirect_to ) );
86 exit;
87 }
88
89 /** @noinspection HtmlUnknownTarget */
90 wp_die(
91 /* translators: %s: logout url */
92 sprintf( wp_kses_post( __( 'Are you sure you want to log out? <a href="%s">Confirm and log out</a>', 'storeengine' ) ), esc_url( storeengine_logout_url() ) ),
93 esc_html__( 'Are you sure you want to log out?', 'storeengine' ),
94 [ 'back_link' => true ]
95 );
96 }
97
98 // If user landed from another page with redirect-to param.
99 if ( is_user_logged_in() && ! empty( $_REQUEST['redirect_to'] ) ) {
100 $redirect_to = wp_validate_redirect( esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) );
101 if ( $redirect_to ) {
102 wp_safe_redirect( $redirect_to );
103 } else {
104 wp_safe_redirect( remove_query_arg( 'redirect_to' ) );
105 }
106
107 exit;
108 }
109 // phpcs:enable WordPress.Security.NonceVerification.Recommended
110
111 // Trigger 404 if trying to access an endpoint on wrong page.
112 if ( Helper::is_endpoint() && ! Helper::is_dashboard() && ! Helper::is_checkout() && apply_filters( 'storeengine/dashboard/endpoint_page_not_found', true ) ) {
113 $wp_query->set_404();
114 status_header( 404 );
115 include get_query_template( '404' );
116 exit;
117 }
118
119 // Redirect to the product page if we have a single product.
120 if ( is_search() && is_post_type_archive( Helper::PRODUCT_POST_TYPE ) && apply_filters( 'storeengine/redirect/single_search_result', true ) && 1 === absint( $wp_query->found_posts ) ) {
121 $product = storeengine_get_product( $wp_query->post->ID );
122 if ( $product && $product->is_visible() ) {
123 wp_safe_redirect( get_permalink( $product->get_id() ) );
124 exit;
125 }
126 }
127 }
128
129 /**
130 * SE Login URL
131 *
132 * Do not hook this function into login_url ever.
133 *
134 * @param string $redirect Path to redirect to on log in.
135 * @param bool $force_reauth Whether to force reauthorization, even if a cookie is present.
136 * Default false.
137 *
138 * @return string The login URL. Not HTML-encoded.
139 */
140 function storeengine_login_url( string $redirect = '', bool $force_reauth = false ): string {
141 $auth_redirect_type = Helper::get_settings( 'auth_redirect_type', 'storeengine' );
142
143 $login_url = wp_login_url( $redirect, $force_reauth );
144
145 if ( 'default' === $auth_redirect_type || is_admin() ) {
146 return wp_login_url( $redirect, $force_reauth );
147 }
148
149 if ( 'custom' === $auth_redirect_type && Helper::get_settings( 'auth_redirect_url' ) ) {
150 $login_url = add_query_arg( 'redirect_to', urlencode( $redirect ), Helper::get_settings( 'auth_redirect_url' ) );
151 } elseif ( 'storeengine' === $auth_redirect_type && Helper::get_settings( 'dashboard_page' ) ) {
152 $login_url = add_query_arg( 'redirect_to', urlencode( $redirect ), Helper::get_dashboard_url() );
153 }
154
155 if ( $force_reauth ) {
156 $login_url = add_query_arg( 'reauth', '1', $login_url );
157 }
158
159 return apply_filters( 'storeengine/login_url', $login_url, $redirect, $force_reauth );
160 }
161
162 /**
163 * Login URL tailored for the checkout "email already registered" flow.
164 *
165 * Returns the shopper back to checkout after a successful login and pre-fills
166 * the email (via the `se_email` query arg the login template reads) so they
167 * don't have to retype it. Shared by BOTH the inline contact pre-check and the
168 * Place-Order 409, so the "Log in to continue" call-to-action is identical on
169 * every surface that can surface it.
170 *
171 * @param string $email Email to pre-fill on the login form.
172 * @param string $redirect Where to send the shopper after login. Defaults to
173 * the current checkout URL.
174 *
175 * @return string
176 */
177 function storeengine_checkout_login_url( string $email = '', string $redirect = '' ): string {
178 if ( '' === $redirect ) {
179 $redirect = Helper::get_checkout_url();
180 }
181
182 $url = storeengine_login_url( $redirect );
183
184 // add_query_arg() url-encodes the value itself — pass the raw address.
185 if ( '' !== $email && is_email( $email ) ) {
186 $url = add_query_arg( 'se_email', $email, $url );
187 }
188
189 return apply_filters( 'storeengine/checkout/contact_login_url', $url, $email, $redirect );
190 }
191
192 /**
193 * Prevent any user who cannot 'edit_posts' (subscribers, customers etc.) from seeing the admin bar.
194 *
195 * @param bool $show_admin_bar If site should display admin bar.
196 *
197 * @return bool
198 */
199 function storeengine_disable_admin_bar( bool $show_admin_bar ): bool {
200 /**
201 * Controls whether the StoreEngine admin bar should be disabled.
202 *
203 * @param bool $enabled
204 */
205 if ( apply_filters( 'storeengine/disable_admin_bar', true ) && ! ( current_user_can( 'edit_posts' ) || current_user_can( 'manage_storeengine' ) ) ) {
206 $show_admin_bar = false;
207 }
208
209 return $show_admin_bar;
210 }
211
212 /**
213 * Initialize global product for current request.
214 * This setup the global before the loop for current page only.
215 *
216 * @return void
217 */
218 function storeengine_init_global_product_early() {
219 if ( empty( $GLOBALS['post'] ) ) {
220 return;
221 }
222
223 if ( empty( $GLOBALS['post']->post_type ) || $GLOBALS['post']->post_type !== 'storeengine_product' ) {
224 return;
225 }
226
227 $GLOBALS['product'] = storeengine_get_product( $GLOBALS['post']->ID ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
228 }
229
230 /**
231 * Initialize global product inside the loop.
232 *
233 * @param $post
234 *
235 * @return false|SimpleProduct|VariableProduct|void
236 */
237 function storeengine_initialize_product_data( $post ) {
238 if ( is_int( $post ) ) {
239 $post = get_post( $post );
240 }
241
242 if ( empty( $post->post_type ) || $post->post_type !== 'storeengine_product' ) {
243 return;
244 }
245
246 $GLOBALS['the_original_product'] = $GLOBALS['product'] ?? null; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Compat global name mirroring the $product loop global preserved above.
247
248 $GLOBALS['product'] = storeengine_get_product( $post->ID ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
249
250 return $GLOBALS['product'];
251 }
252
253 function storeengine_archive_title_parts( array $title ): array {
254 global $wp_query;
255 if ( Helper::is_shop() ) {
256 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only archive sort label derived from the public query var; no state change.
257 if ( ! empty( $_REQUEST['orderby'] ) && $wp_query->get( 'orderby' ) && $wp_query->get( 'order' ) ) {
258 $sort = strtolower( $wp_query->get( 'orderby' ) . ':' . $wp_query->get( 'order' ) );
259 switch ( $sort ) {
260 case 'post_title:asc':
261 $label = __( 'Alphabetical (A–Z)', 'storeengine' );
262 break;
263 case 'post_title:desc':
264 $label = __( 'Alphabetical (Z–A)', 'storeengine' );
265 break;
266
267 case 'publish_date:desc':
268 $label = __( 'Newest first', 'storeengine' );
269 break;
270 case 'publish_date:asc':
271 $label = __( 'Oldest first', 'storeengine' );
272 break;
273
274 case 'modified:desc':
275 $label = __( 'Recently updated', 'storeengine' );
276 break;
277 case 'modified:asc':
278 $label = __( 'Least recently updated', 'storeengine' );
279 break;
280
281 case 'menu_order:asc':
282 $label = __( 'Custom order (ascending)', 'storeengine' );
283 break;
284 case 'menu_order:desc':
285 $label = __( 'Custom order (descending)', 'storeengine' );
286 break;
287
288 case 'id:asc':
289 $label = __( 'ID (ascending)', 'storeengine' );
290 break;
291 case 'id:desc':
292 $label = __( 'ID (descending)', 'storeengine' );
293 break;
294 default:
295 $label = str_ends_with( $sort, ':desc' ) ? __( 'Sort (descending)', 'storeengine' ) : __( 'Sort (ascending)', 'storeengine' );
296 break;
297 }
298
299 if ( $label ) {
300 $page = $title['page'] ?? null;
301 $site = $title['site'] ?? null;
302 unset( $title['page'], $title['site'] );
303
304 $title['sort'] = $label;
305
306 if ( $page ) {
307 $title['page'] = $page;
308 }
309 if ( $site ) {
310 $title['site'] = $site;
311 }
312 }
313 }
314 }
315
316 return $title;
317 }
318
319 add_filter( 'document_title_parts', 'storeengine_archive_title_parts' );
320
321 /**
322 * @param int $id
323 *
324 * @return AbstractOrder|Order|WP_Error
325 * @throws StoreEngineException
326 */
327 function storeengine_get_order( int $id ) {
328 $order = Helper::get_order( $id );
329 if ( is_wp_error( $order ) ) {
330 throw StoreEngineException::from_wp_error( $order ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
331 }
332
333 return $order;
334 }
335
336 function storeengine_cart(): Cart {
337 return StoreEngine::init()->get_cart();
338 }
339
340 if ( ! function_exists( 'storeengine_get_header' ) ) {
341 function storeengine_get_header( $header_name = 'product' ) {
342 if ( Helper::is_fse_theme() ) {
343 ?>
344 <!doctype html>
345 <html <?php language_attributes(); ?>>
346 <head>
347 <meta charset="<?php bloginfo( 'charset' ); ?>">
348 <?php wp_head(); ?>
349 </head>
350
351 <body <?php body_class(); ?>>
352 <?php wp_body_open(); ?>
353 <div class="wp-site-blocks">
354 <?php
355 if ( apply_filters( 'storeengine/templates/is_allow_block_theme_header', true ) ) :
356 ?>
357 <header class="wp-block-template-part site-header">
358 <?php block_header_area(); ?>
359 </header>
360 <?php
361 endif;
362 ?>
363 <?php
364 } else {
365 get_header( $header_name );
366 }
367 }
368 }
369
370 if ( ! function_exists( 'storeengine_get_footer' ) ) {
371 function storeengine_get_footer( $footer_name = 'product' ) {
372 if ( Helper::is_fse_theme() ) {
373 if ( apply_filters( 'storeengine/templates/is_allow_block_theme_footer', true ) ) :
374 ?>
375 <footer class="wp-block-template-part site-footer">
376 <?php block_footer_area(); ?>
377 </footer>
378 <?php endif; ?>
379 </div>
380 <?php wp_footer(); ?>
381 </body>
382 </html>
383 <?php } else {
384 get_footer( $footer_name );
385 }
386 }
387 }
388
389 if ( ! function_exists( 'storeengine_single_product_header' ) ) {
390 function storeengine_single_product_header() {
391 Template::get_template(
392 'single-product/header.php',
393 );
394 }
395 }
396
397 if ( ! function_exists( 'storeengine_product_bundle_info' ) ) {
398 function storeengine_product_bundle_info( $bundles = null ) {
399 global $product;
400 if ( ! $bundles && $product && $product->is_type( 'bundled' ) && 'storeengine/templates/single-product/header_right_content' === current_action() ) {
401 $bundles = $product->get_bundles();
402 }
403
404 if ( empty( $bundles ) || ! is_array( $bundles ) ) {
405 return;
406 }
407
408 Template::get_template( 'global/bundle-info.php', [ 'bundles' => $bundles ] );
409 }
410 }
411
412 if ( ! function_exists( 'storeengine_single_product_add_to_cart' ) ) {
413 function storeengine_single_product_add_to_cart() {
414 Template::get_template( 'single-product/add-to-cart.php' );
415 }
416 }
417
418 if ( ! function_exists( 'storeengine_single_product_footer' ) ) {
419 function storeengine_single_product_footer() {
420 Template::get_template(
421 'single-product/footer.php',
422 );
423 }
424 }
425
426 if ( ! function_exists( 'storeengine_single_product_description' ) ) {
427 function storeengine_single_product_description() {
428 Template::get_template(
429 'single-product/description.php',
430 );
431 }
432 }
433
434 if ( ! function_exists( 'storeengine_add_to_cart_form' ) ) {
435 function storeengine_add_to_cart_form() {
436 $cart_page_permalink = Helper::get_page_permalink( 'cart_page' );
437 $has_view_cart = true;
438 Template::get_template(
439 'single-product/add-to-cart.php',
440 [
441 'cart_page_permalink' => $cart_page_permalink,
442 'has_view_cart' => $has_view_cart,
443 ]
444 );
445 }
446 }
447
448 function storeengine_placeholder_image_src( $size = 'storeengine_thumbnail' ) {
449 // @TODO allow admin to change placeholder image.
450 // @TODO implement size args.
451
452 return apply_filters( 'storeengine/placeholder/image_src', STOREENGINE_ASSETS_URI . 'images/thumbnail-placeholder.png', $size );
453 }
454
455 /**
456 * Get the placeholder image.
457 *
458 * @param string $size
459 * @param string|array $attr
460 *
461 * @return string
462 *
463 * Mirrors the standard option-seeding on install.
464 *
465 * @see add_image_size()
466 */
467 function storeengine_placeholder_image( string $size = 'storeengine_thumbnail', $attr = '' ): string {
468 // @TODO add thumbnail & single product image size
469 // @TODO use wp_get_attachment_image
470
471 $dimensions = [
472 'width' => 600,
473 'height' => 600,
474 ];
475 $default_attr = [
476 'class' => 'storeengine-placeholder wp-post-image',
477 'alt' => __( 'Placeholder', 'storeengine' ),
478 ];
479 $attr = wp_parse_args( $attr, $default_attr );
480 $image = storeengine_placeholder_image_src( $size );
481 $hwstring = image_hwstring( $dimensions['width'], $dimensions['height'] );
482 $attributes = [];
483
484 foreach ( $attr as $name => $value ) {
485 $attributes[] = esc_attr( $name ) . '="' . esc_attr( $value ) . '"';
486 }
487
488 $image_html = '<img src="' . esc_url( $image ) . '" ' . $hwstring . implode( ' ', $attributes ) . '/>'; // phpcs:ignore PluginCheck.CodeAnalysis.ImageFunctions.NonEnqueuedImage
489
490 return apply_filters( 'storeengine/placeholder/image', $image_html, $size, $dimensions );
491 }
492
493 function storeengine_has_product_thumbnail( ?int $product_id = null ): bool {
494 if ( ! $product_id ) {
495 $product_id = get_the_ID();
496 }
497
498 return (bool) apply_filters( 'storeengine/has_post_thumbnail', has_post_thumbnail( $product_id ), $product_id );
499 }
500
501 function storeengine_has_product_gallery( ?int $product_id = null ): bool {
502 if ( ! $product_id ) {
503 $product_id = get_the_ID();
504 }
505
506 // Show the carousel when there's more than one item, or any video (even a
507 // single video still needs a player rather than the plain-image layout).
508 $items = storeengine_get_product_gallery_items( $product_id );
509 $has_video = false;
510 foreach ( $items as $item ) {
511 if ( 'video' === $item['type'] ) {
512 $has_video = true;
513 break;
514 }
515 }
516 $has_gallery = count( $items ) > 1 || $has_video;
517
518 return apply_filters( 'storeengine/has_product_gallery', $has_gallery, $product_id );
519 }
520
521 /**
522 * Resolve the single-product gallery layout (global setting): carousel |
523 * stacked | grid. Defaults to carousel.
524 */
525 function storeengine_get_product_gallery_layout(): string {
526 $layout = \StoreEngine\Utils\Helper::get_settings( 'single_product_gallery_layout', 'carousel' );
527 $allowed = [ 'carousel', 'stacked', 'grid' ];
528 if ( ! in_array( $layout, $allowed, true ) ) {
529 $layout = 'carousel';
530 }
531
532 return apply_filters( 'storeengine/product/gallery_layout', $layout );
533 }
534
535 /**
536 * Conditionally enqueue the custom (vanilla-JS, no jQuery/Flickity) product
537 * gallery bundle — only when the product actually has gallery media, so the
538 * carousel/stacked/grid + lightbox/zoom code loads on demand.
539 *
540 * @param int|null $product_id
541 */
542 function storeengine_enqueue_product_gallery_assets( ?int $product_id = null ) {
543 if ( ! $product_id ) {
544 $product_id = get_the_ID();
545 }
546 if ( ! $product_id || ! storeengine_has_product_gallery( $product_id ) ) {
547 return;
548 }
549
550 $asset_file = STOREENGINE_ASSETS_DIR_PATH . 'build/product-gallery.' . STOREENGINE_VERSION . '.asset.php';
551 $asset = file_exists( $asset_file )
552 ? include $asset_file
553 : [
554 'dependencies' => [],
555 'version' => STOREENGINE_VERSION,
556 ];
557
558 wp_enqueue_style(
559 'storeengine-product-gallery',
560 STOREENGINE_ASSETS_URI . 'build/product-gallery.css',
561 [],
562 $asset['version']
563 );
564 wp_enqueue_script(
565 'storeengine-product-gallery',
566 STOREENGINE_ASSETS_URI . 'build/product-gallery.' . STOREENGINE_VERSION . '.js',
567 $asset['dependencies'],
568 $asset['version'],
569 true
570 );
571 wp_localize_script( 'storeengine-product-gallery', 'StoreEngineGalleryL10n', [
572 'close' => __( 'Close', 'storeengine' ),
573 'next' => __( 'Next image', 'storeengine' ),
574 'prev' => __( 'Previous image', 'storeengine' ),
575 'zoomIn' => __( 'Zoom in', 'storeengine' ),
576 'zoomOut' => __( 'Zoom out', 'storeengine' ),
577 'resetZoom' => __( 'Reset zoom', 'storeengine' ),
578 'playVideo' => __( 'Play video', 'storeengine' ),
579 /* translators: %1$s current index, %2$s total. */
580 'counter' => __( '%1$s of %2$s', 'storeengine' ),
581 ] );
582 }
583
584 function storeengine_get_product_image( $size = 'storeengine_thumbnail', $product_id = null, $attr = '', $placeholder = true ) {
585 $image = '';
586 $thumbnail_id = 0;
587
588 if ( ! $product_id ) {
589 $product_id = get_the_ID();
590 }
591
592 if ( $product_id ) {
593 $thumbnail_id = (int) get_post_thumbnail_id( $product_id );
594 }
595
596 if ( ! $thumbnail_id ) {
597 // Fallback to first item on the gallery.
598 $ids = get_post_meta( $product_id, '_storeengine_product_gallery_ids', true );
599 if ( $ids && is_array( $ids ) ) {
600 $ids = array_unique( array_filter( $ids ) );
601 $thumbnail_id = reset( $ids );
602 }
603 }
604
605 if ( $thumbnail_id ) {
606 $image = wp_get_attachment_image( $thumbnail_id, $size, false, $attr );
607 }
608
609 if ( ! $image && $placeholder ) {
610 $image = storeengine_placeholder_image( $size, $attr );
611 }
612
613 return apply_filters( 'storeengine/product/image_html', $image, $product_id, $size, $attr, $placeholder );
614 }
615
616 function storeengine_product_image( $size = 'storeengine_thumbnail', $product_id = null, $attr = '', $placeholder = true ) {
617 // Output from wp image html.
618 // wp_kses-post remove srcset, sizes etc attributes
619 echo storeengine_get_product_image( $size, $product_id, $attr, $placeholder ); // phpcs:ignore
620 }
621
622 function storeengine_product_gallery( $product_id = null, $size = 'storeengine_thumbnail', $attr = '' ) {
623 if ( ! $product_id ) {
624 $product_id = get_the_ID();
625 }
626
627 if ( $product_id ) {
628 // @TODO allow admin to choose a featured image too..
629 // @XXX backend allows admin to add same image multiple time.
630 $ids = get_post_meta( $product_id, '_storeengine_product_gallery_ids', true );
631 if ( ! is_array( $ids ) ) {
632 $ids = [];
633 }
634
635 $ids = array_unique( array_filter( $ids ) );
636 if ( ! Helper::is_product() && count( $ids ) > 1 ) {
637 $ids = [ reset( $ids ) ];
638 $ids = array_filter( $ids );
639 }
640
641 foreach ( $ids as $id ) {
642 $image = wp_get_attachment_image_src( $id, $size );
643 if ( ! $image ) {
644 continue;
645 }
646
647 printf(
648 '<span class="carousel-cell">%1$s</span>',
649 wp_get_attachment_image( $id, $size, false, $attr )
650 );
651 }
652 }
653 }
654
655 /**
656 * Normalized list of gallery videos for a product.
657 *
658 * @param int|null $product_id
659 *
660 * @return array<int, array{url:string, poster_id:int}>
661 */
662 function storeengine_get_product_gallery_videos( ?int $product_id = null ): array {
663 if ( ! $product_id ) {
664 $product_id = get_the_ID();
665 }
666
667 $videos = $product_id ? get_post_meta( $product_id, '_storeengine_product_gallery_videos', true ) : [];
668 if ( ! is_array( $videos ) ) {
669 $videos = [];
670 }
671
672 $out = [];
673 foreach ( $videos as $video ) {
674 $url = is_array( $video ) ? ( $video['url'] ?? '' ) : (string) $video;
675 $url = trim( (string) $url );
676 if ( '' === $url ) {
677 continue;
678 }
679 $out[] = [
680 'url' => $url,
681 'poster_id' => is_array( $video ) ? absint( $video['poster_id'] ?? 0 ) : 0,
682 ];
683 }
684
685 return apply_filters( 'storeengine/product/gallery_videos', $out, $product_id );
686 }
687
688 /**
689 * Extract a YouTube video id from common URL shapes (watch, youtu.be, embed,
690 * shorts). Returns '' when the url is not a recognizable YouTube link.
691 */
692 function storeengine_extract_youtube_id( string $url ): string {
693 if ( preg_match( '~(?:youtube\.com/(?:watch\?(?:.*&)?v=|embed/|shorts/|v/)|youtu\.be/)([A-Za-z0-9_-]{11})~i', $url, $m ) ) {
694 return $m[1];
695 }
696
697 return '';
698 }
699
700 /**
701 * Whether a URL points to a directly-playable video file (uploaded / media
702 * library video, or any .mp4/.webm/... link).
703 */
704 function storeengine_is_video_file_url( string $url ): bool {
705 $path = wp_parse_url( $url, PHP_URL_PATH );
706 $ext = $path ? strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ) : '';
707
708 return in_array( $ext, [ 'mp4', 'm4v', 'webm', 'ogv', 'ogg', 'mov' ], true );
709 }
710
711 /**
712 * Sanitise an oEmbed / iframe embed fragment down to a safe, iframe-allowing
713 * allow-list with wp_kses (mirrors WordPress core's oEmbed sanitisation).
714 * Strips scripts, event handlers and unsafe protocols while keeping provider
715 * iframes intact.
716 *
717 * Used as defence-in-depth: the JS injects this HTML via innerHTML, so we do
718 * not want to rely solely on core's `oembed_result` filter still being
719 * attached (another plugin could remove it) when a lower-privileged editor /
720 * marketplace vendor — who may lack `unfiltered_html` — supplied the video URL.
721 *
722 * @param string $html
723 * @return string
724 */
725 function storeengine_kses_gallery_embed( string $html ): string {
726 $allowed = [
727 'a' => [
728 'href' => true,
729 'title' => true,
730 ],
731 'blockquote' => [ 'class' => true ],
732 'iframe' => [
733 'src' => true,
734 'width' => true,
735 'height' => true,
736 'frameborder' => true,
737 'marginwidth' => true,
738 'marginheight' => true,
739 'scrolling' => true,
740 'title' => true,
741 'class' => true,
742 'name' => true,
743 'allow' => true,
744 'allowfullscreen' => true,
745 'loading' => true,
746 'referrerpolicy' => true,
747 'sandbox' => true,
748 'style' => true,
749 ],
750 ];
751
752 return wp_kses( $html, $allowed );
753 }
754
755 /**
756 * Resolve a video URL to sanitised, cached oEmbed HTML.
757 *
758 * `wp_oembed_get()` performs an outbound HTTP request (plus provider discovery
759 * for non-whitelisted hosts) on every call, so the result is cached in a
760 * transient keyed by URL + width to avoid a fetch on every product-page view.
761 * The HTML is additionally run through {@see storeengine_kses_gallery_embed()}.
762 *
763 * @param string $url
764 * @param int $width
765 * @return string Sanitised embed HTML, or '' if the URL isn't embeddable.
766 */
767 function storeengine_get_gallery_oembed_html( string $url, int $width = 1200 ): string {
768 $cache_key = 'se_gallery_oembed_' . md5( $url . '|' . $width );
769 $cached = get_transient( $cache_key );
770 if ( false !== $cached ) {
771 return (string) $cached;
772 }
773
774 $embed = wp_oembed_get( $url, [ 'width' => $width ] );
775 $html = $embed ? storeengine_kses_gallery_embed( (string) $embed ) : '';
776
777 // Cache successes for a day; cache misses only briefly so a transient
778 // provider hiccup doesn't suppress a valid embed for long.
779 set_transient( $cache_key, $html, $html ? DAY_IN_SECONDS : 5 * MINUTE_IN_SECONDS );
780
781 return $html;
782 }
783
784 /**
785 * Markup for a single gallery video's main (player) cell.
786 *
787 * @param array{url:string, poster_id:int} $video
788 */
789 function storeengine_get_gallery_video_html( array $video ): string {
790 $url = $video['url'];
791
792 // Direct video file (uploaded / media library / any .mp4 etc.). Wrap in the
793 // fixed-aspect container so the carousel cell has a stable height at init
794 // (a bare <video> reports height 0 until metadata loads and collapses the
795 // Flickity cell).
796 if ( storeengine_is_video_file_url( $url ) ) {
797 $poster = ! empty( $video['poster_id'] ) ? wp_get_attachment_image_url( (int) $video['poster_id'], 'large' ) : '';
798
799 return sprintf(
800 '<div class="storeengine-gallery__video-embed"><video class="storeengine-gallery__video-player" controls preload="metadata" playsinline%1$s src="%2$s"></video></div>',
801 $poster ? ' poster="' . esc_url( $poster ) . '"' : '',
802 esc_url( $url )
803 );
804 }
805
806 // Embeddable providers (YouTube, Vimeo, …). Cached + kses-sanitised.
807 $embed = storeengine_get_gallery_oembed_html( $url, 900 );
808 if ( $embed ) {
809 return '<div class="storeengine-gallery__video-embed">' . $embed . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- kses-sanitised in helper.
810 }
811
812 // Last resort: iframe the URL so at least something renders.
813 return sprintf(
814 '<div class="storeengine-gallery__video-embed"><iframe src="%1$s" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen loading="lazy" title="%2$s"></iframe></div>',
815 esc_url( $url ),
816 esc_attr__( 'Product video', 'storeengine' )
817 );
818 }
819
820 /**
821 * Markup for a single gallery video's thumbnail-nav cell (poster image with a
822 * play badge). Falls back to a derived YouTube thumbnail, then a plain badge.
823 *
824 * @param array{url:string, poster_id:int} $video
825 */
826 function storeengine_get_gallery_video_poster_html( array $video ): string {
827 $badge = '<span class="storeengine-gallery__play-badge" aria-hidden="true"></span>';
828
829 if ( ! empty( $video['poster_id'] ) ) {
830 $img = wp_get_attachment_image( (int) $video['poster_id'], 'storeengine_thumbnail' );
831 if ( $img ) {
832 return $img . $badge;
833 }
834 }
835
836 $youtube_id = storeengine_extract_youtube_id( $video['url'] );
837 if ( $youtube_id ) {
838 return sprintf(
839 '<img src="%1$s" alt="" loading="lazy" />%2$s',
840 esc_url( 'https://img.youtube.com/vi/' . $youtube_id . '/hqdefault.jpg' ),
841 $badge
842 );
843 }
844
845 return '<span class="storeengine-gallery__video-thumb-placeholder">' . $badge . '</span>';
846 }
847
848 /**
849 * Unified, ordered gallery items (images + videos) for a product.
850 *
851 * Prefers the `_storeengine_product_gallery` meta (source of truth for the
852 * editor order). Falls back to the legacy featured image + gallery ids +
853 * gallery videos so products saved before the unified gallery still render.
854 *
855 * @param int|null $product_id
856 *
857 * @return array<int, array{type:string, id?:int, url?:string, poster_id?:int}>
858 */
859 function storeengine_get_product_gallery_items( ?int $product_id = null ): array {
860 if ( ! $product_id ) {
861 $product_id = get_the_ID();
862 }
863 if ( ! $product_id ) {
864 return [];
865 }
866
867 $items = [];
868 $seen_images = [];
869
870 $unified = get_post_meta( $product_id, '_storeengine_product_gallery', true );
871
872 if ( is_array( $unified ) && ! empty( $unified ) ) {
873 foreach ( $unified as $item ) {
874 if ( ! is_array( $item ) ) {
875 continue;
876 }
877 $type = $item['type'] ?? '';
878 if ( 'image' === $type && ! empty( $item['id'] ) ) {
879 $id = (int) $item['id'];
880 if ( isset( $seen_images[ $id ] ) ) {
881 continue;
882 }
883 $seen_images[ $id ] = true;
884 $items[] = [
885 'type' => 'image',
886 'id' => $id,
887 ];
888 } elseif ( 'video' === $type && ! empty( $item['url'] ) ) {
889 $items[] = [
890 'type' => 'video',
891 'url' => (string) $item['url'],
892 'poster_id' => absint( $item['poster_id'] ?? 0 ),
893 ];
894 }
895 }
896 } else {
897 // Legacy fallback: featured image, then gallery image ids, then videos.
898 $featured = (int) get_post_thumbnail_id( $product_id );
899 if ( $featured ) {
900 $items[] = [
901 'type' => 'image',
902 'id' => $featured,
903 ];
904 $seen_images[ $featured ] = true;
905 }
906
907 $ids = get_post_meta( $product_id, '_storeengine_product_gallery_ids', true );
908 if ( is_array( $ids ) ) {
909 foreach ( array_unique( array_filter( $ids ) ) as $id ) {
910 $id = (int) $id;
911 if ( isset( $seen_images[ $id ] ) ) {
912 continue;
913 }
914 $seen_images[ $id ] = true;
915 $items[] = [
916 'type' => 'image',
917 'id' => $id,
918 ];
919 }
920 }
921
922 foreach ( storeengine_get_product_gallery_videos( $product_id ) as $video ) {
923 $items[] = [
924 'type' => 'video',
925 'url' => $video['url'],
926 'poster_id' => $video['poster_id'],
927 ];
928 }
929 }
930
931 return apply_filters( 'storeengine/product/gallery_items', $items, $product_id );
932 }
933
934 /**
935 * Build the markup for a single gallery item (image or video) consumed by the
936 * custom vanilla-JS gallery. Everything the JS needs (full-size url, poster,
937 * embed html) rides on data-* attributes so no separate JSON blob is needed.
938 *
939 * @param array $item { type, id | url, poster_id }
940 * @param int $index Zero-based position.
941 *
942 * @return string
943 */
944 function storeengine_get_product_gallery_item_html( array $item, int $index ): string {
945 if ( 'image' === $item['type'] ) {
946 $id = (int) $item['id'];
947 $thumb = wp_get_attachment_image_url( $id, 'storeengine_thumbnail' );
948 $medium = wp_get_attachment_image_url( $id, 'large' );
949 $full = wp_get_attachment_image_url( $id, 'full' );
950 $medium = $medium ?: ( $full ?: $thumb );
951 $full = $full ?: $medium;
952 $thumb = $thumb ?: $medium;
953 if ( ! $medium ) {
954 return '';
955 }
956 $alt = trim( (string) get_post_meta( $id, '_wp_attachment_image_alt', true ) );
957
958 return sprintf(
959 '<figure class="storeengine-gallery__item" data-index="%1$d" data-type="image" data-full="%2$s" data-thumb="%3$s"><img class="storeengine-gallery__img" src="%4$s" alt="%5$s" loading="%6$s" decoding="async" /></figure>',
960 $index,
961 esc_url( $full ),
962 esc_url( $thumb ),
963 esc_url( $medium ),
964 esc_attr( $alt ),
965 0 === $index ? 'eager' : 'lazy'
966 );
967 }
968
969 // Video item.
970 $url = (string) $item['url'];
971 $poster_id = (int) ( $item['poster_id'] ?? 0 );
972 $youtube = storeengine_extract_youtube_id( $url );
973 $is_file = storeengine_is_video_file_url( $url );
974 $provider = $is_file ? 'file' : ( $youtube ? 'youtube' : ( false !== stripos( $url, 'vimeo.com' ) ? 'vimeo' : 'embed' ) );
975
976 $poster = '';
977 $thumb = '';
978 if ( $poster_id ) {
979 $poster = wp_get_attachment_image_url( $poster_id, 'large' );
980 $thumb = wp_get_attachment_image_url( $poster_id, 'storeengine_thumbnail' );
981 } elseif ( $youtube ) {
982 $poster = 'https://img.youtube.com/vi/' . $youtube . '/hqdefault.jpg';
983 $thumb = 'https://img.youtube.com/vi/' . $youtube . '/mqdefault.jpg';
984 }
985
986 // Non-file providers ship their embed html (base64 in a data attr) so the JS
987 // can inject it on demand without another network round-trip.
988 $embed_attr = '';
989 if ( ! $is_file ) {
990 $embed = storeengine_get_gallery_oembed_html( $url, 1200 );
991 if ( ! $embed ) {
992 $embed = sprintf(
993 '<iframe src="%s" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen loading="lazy"></iframe>',
994 esc_url( $url )
995 );
996 }
997 $embed_attr = ' data-embed="' . esc_attr( base64_encode( $embed ) ) . '"'; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- transport only.
998 }
999
1000 if ( $thumb ) {
1001 $thumb_html = sprintf( '<img class="storeengine-gallery__img" src="%s" alt="" loading="lazy" decoding="async" />', esc_url( $thumb ) );
1002 } elseif ( $is_file ) {
1003 $thumb_html = sprintf( '<video class="storeengine-gallery__img" src="%s#t=0.1" muted preload="metadata" playsinline></video>', esc_url( $url ) );
1004 } else {
1005 $thumb_html = '<span class="storeengine-gallery__video-empty"></span>';
1006 }
1007
1008 return sprintf(
1009 '<figure class="storeengine-gallery__item storeengine-gallery__item--video" data-index="%1$d" data-type="video" data-provider="%2$s" data-url="%3$s"%4$s data-poster="%5$s">%6$s<span class="storeengine-gallery__play" aria-hidden="true"></span></figure>',
1010 $index,
1011 esc_attr( $provider ),
1012 esc_url( $url ),
1013 $embed_attr, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped above.
1014 esc_url( $poster ),
1015 $thumb_html // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built with esc_* above.
1016 );
1017 }
1018
1019 /**
1020 * Render the full custom gallery root (SSR markup the vanilla JS enhances).
1021 * Falls back to a single image when there is no gallery.
1022 *
1023 * @param int|null $product_id
1024 */
1025 function storeengine_render_product_gallery( $product_id = null ) {
1026 if ( ! $product_id ) {
1027 $product_id = get_the_ID();
1028 }
1029
1030 $items = storeengine_get_product_gallery_items( $product_id );
1031
1032 if ( empty( $items ) ) {
1033 echo '<div class="storeengine-gallery-root storeengine-gallery-root--single">';
1034 storeengine_product_image( 'large', $product_id );
1035 echo '</div>';
1036 return;
1037 }
1038
1039 $layout = storeengine_get_product_gallery_layout();
1040 $html = '';
1041 foreach ( $items as $i => $item ) {
1042 $html .= storeengine_get_product_gallery_item_html( $item, (int) $i );
1043 }
1044
1045 printf(
1046 '<div class="storeengine-gallery-root storeengine-gallery-root--%1$s" data-se-gallery data-layout="%1$s"><div class="storeengine-gallery__items">%2$s</div></div>',
1047 esc_attr( $layout ),
1048 $html // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- each item built with esc_* in storeengine_get_product_gallery_item_html().
1049 );
1050 }
1051
1052 /**
1053 * Output the unified gallery carousel cells (images + videos, in order) for the
1054 * given carousel `$context` ('main' player cells or 'nav' thumbnail cells).
1055 *
1056 * @param int|null $product_id
1057 * @param string $context
1058 * @param string $size
1059 * @param string $attr
1060 */
1061 function storeengine_product_gallery_render( $product_id = null, string $context = 'main', string $size = 'storeengine_thumbnail', string $attr = '' ) {
1062 if ( ! $product_id ) {
1063 $product_id = get_the_ID();
1064 }
1065
1066 foreach ( storeengine_get_product_gallery_items( $product_id ) as $item ) {
1067 if ( 'image' === $item['type'] ) {
1068 $image = wp_get_attachment_image( $item['id'], $size, false, $attr );
1069 if ( ! $image ) {
1070 continue;
1071 }
1072 printf( '<span class="carousel-cell">%1$s</span>', $image ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1073 } elseif ( 'nav' === $context ) {
1074 printf(
1075 '<span class="carousel-cell storeengine-gallery__video-nav">%1$s</span>',
1076 storeengine_get_gallery_video_poster_html( $item ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1077 );
1078 } else {
1079 printf(
1080 '<span class="carousel-cell storeengine-gallery__video-cell">%1$s</span>',
1081 storeengine_get_gallery_video_html( $item ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1082 );
1083 }
1084 }
1085 }
1086
1087 /**
1088 * Output gallery-video carousel cells. `$context` is 'main' (player) or 'nav'
1089 * (poster thumbnail). Videos render only on the single-product page.
1090 *
1091 * @param int|null $product_id
1092 * @param string $context
1093 */
1094 function storeengine_product_gallery_videos( $product_id = null, string $context = 'main' ) {
1095 if ( ! $product_id ) {
1096 $product_id = get_the_ID();
1097 }
1098
1099 // Videos are a single-product experience; skip in archive/loop where only
1100 // the featured image is shown.
1101 if ( ! Helper::is_product() ) {
1102 return;
1103 }
1104
1105 foreach ( storeengine_get_product_gallery_videos( $product_id ) as $video ) {
1106 if ( 'nav' === $context ) {
1107 printf(
1108 '<span class="carousel-cell storeengine-gallery__video-nav">%1$s</span>',
1109 storeengine_get_gallery_video_poster_html( $video ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1110 );
1111 } else {
1112 printf(
1113 '<span class="carousel-cell storeengine-gallery__video-cell">%1$s</span>',
1114 storeengine_get_gallery_video_html( $video ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1115 );
1116 }
1117 }
1118 }
1119
1120 if ( ! function_exists( 'storeengine_price' ) ) {
1121 function storeengine_price() {
1122 Template::get_template(
1123 'single-product/price.php'
1124 );
1125 }
1126 }
1127
1128 if ( ! function_exists( 'storeengine_single_view_cart' ) ) {
1129 function storeengine_single_view_cart() {
1130 $cart_item = storeengine_cart()->get_cart_items_by_product( get_the_ID() );
1131
1132 if ( $cart_item ) {
1133 $total_quantity_in_cart = array_sum( array_column( $cart_item, 'quantity' ) );
1134 $num_prices_in_cart = count( $cart_item );
1135 Template::get_template( 'notice/view-cart.php', [
1136 'total_quantity_in_cart' => $total_quantity_in_cart,
1137 'num_prices_in_cart' => $num_prices_in_cart,
1138 ] );
1139 }
1140 }
1141 }
1142
1143 if ( ! function_exists( 'storeengine_product_loop_header' ) ) {
1144 function storeengine_product_loop_header() {
1145 storeengine_enqueue_product_archive_assets();
1146 Template::get_template(
1147 'loop/header.php',
1148 );
1149 }
1150 }
1151
1152 // Cards are not only rendered server-side on the shop archive — Recently Viewed
1153 // (product pages) and the wishlist page inject them after load, and those cards
1154 // are built inside a REST request where wp_enqueue_style() can't reach the page
1155 // that will show them. Enqueueing only from the card template therefore left
1156 // injected cards with no Quick View styles and no `position: relative` on the
1157 // card header. Register during the normal asset phase instead; the function is
1158 // already settings-gated and idempotent, so this is cheap.
1159 add_action( 'wp_enqueue_scripts', 'storeengine_enqueue_product_archive_assets' );
1160
1161 if ( ! function_exists( 'storeengine_product_archive_features' ) ) {
1162 /**
1163 * Which shop-card enhancements are enabled (card carousel, variant swatches,
1164 * quick view).
1165 *
1166 * @return array{carousel:bool, swatches:bool, quick_view:bool}
1167 */
1168 function storeengine_product_archive_features(): array {
1169 return [
1170 'carousel' => (bool) \StoreEngine\Utils\Helper::get_settings( 'product_archive_card_carousel', false ),
1171 'swatches' => (bool) \StoreEngine\Utils\Helper::get_settings( 'product_archive_card_swatches', false ),
1172 'quick_view' => (bool) \StoreEngine\Utils\Helper::get_settings( 'product_archive_quick_view', false ),
1173 ];
1174 }
1175 }
1176
1177 if ( ! function_exists( 'storeengine_enqueue_product_archive_assets' ) ) {
1178 /**
1179 * Enqueue the archive-card bundle (and, for quick view, the gallery lib) —
1180 * once per request, only when at least one card enhancement is on.
1181 */
1182 function storeengine_enqueue_product_archive_assets() {
1183 static $done = false;
1184 if ( $done ) {
1185 return;
1186 }
1187 $features = storeengine_product_archive_features();
1188 if ( ! array_filter( $features ) ) {
1189 return;
1190 }
1191 $done = true;
1192
1193 $asset_file = STOREENGINE_ASSETS_DIR_PATH . 'build/product-archive.' . STOREENGINE_VERSION . '.asset.php';
1194 $asset = file_exists( $asset_file ) ? include $asset_file : [ 'dependencies' => [], 'version' => STOREENGINE_VERSION ];
1195
1196 wp_enqueue_style( 'storeengine-product-archive', STOREENGINE_ASSETS_URI . 'build/product-archive.css', [], $asset['version'] );
1197 wp_enqueue_script( 'storeengine-product-archive', STOREENGINE_ASSETS_URI . 'build/product-archive.' . STOREENGINE_VERSION . '.js', $asset['dependencies'], $asset['version'], true );
1198
1199 // Quick View reuses the single-product gallery lib inside the modal.
1200 if ( $features['quick_view'] ) {
1201 $g_asset_file = STOREENGINE_ASSETS_DIR_PATH . 'build/product-gallery.' . STOREENGINE_VERSION . '.asset.php';
1202 $g_asset = file_exists( $g_asset_file ) ? include $g_asset_file : [ 'dependencies' => [], 'version' => STOREENGINE_VERSION ];
1203 wp_enqueue_style( 'storeengine-product-gallery', STOREENGINE_ASSETS_URI . 'build/product-gallery.css', [], $g_asset['version'] );
1204 wp_enqueue_script( 'storeengine-product-gallery', STOREENGINE_ASSETS_URI . 'build/product-gallery.' . STOREENGINE_VERSION . '.js', $g_asset['dependencies'], $g_asset['version'], true );
1205 wp_localize_script( 'storeengine-product-gallery', 'StoreEngineGalleryL10n', [
1206 'close' => __( 'Close', 'storeengine' ),
1207 'next' => __( 'Next image', 'storeengine' ),
1208 'prev' => __( 'Previous image', 'storeengine' ),
1209 'counter' => __( '%1$s of %2$s', 'storeengine' ),
1210 ] );
1211 }
1212
1213 $qv_position = (string) \StoreEngine\Utils\Helper::get_settings( 'quick_view_position', 'center' );
1214 if ( ! in_array( $qv_position, [ 'center', 'left', 'right' ], true ) ) {
1215 $qv_position = 'center';
1216 }
1217 $qv_animation = (string) \StoreEngine\Utils\Helper::get_settings( 'quick_view_animation', 'fade' );
1218 if ( ! in_array( $qv_animation, [ 'fade', 'zoom', 'slide', 'none' ], true ) ) {
1219 $qv_animation = 'fade';
1220 }
1221
1222 wp_localize_script( 'storeengine-product-archive', 'StoreEngineArchive', [
1223 'features' => $features,
1224 'position' => $qv_position,
1225 'animation' => $qv_animation,
1226 'restUrl' => esc_url_raw( rest_url( 'storeengine/v1/products/' ) ),
1227 'nonce' => wp_create_nonce( 'wp_rest' ),
1228 'i18n' => [
1229 'quickView' => __( 'Quick View', 'storeengine' ),
1230 'close' => __( 'Close', 'storeengine' ),
1231 'loading' => __( 'Loading…', 'storeengine' ),
1232 'error' => __( 'Could not load the product.', 'storeengine' ),
1233 'prevProduct' => __( 'Previous product', 'storeengine' ),
1234 'nextProduct' => __( 'Next product', 'storeengine' ),
1235 ],
1236 ] );
1237 }
1238 }
1239
1240 if ( ! function_exists( 'storeengine_render_sticky_add_to_cart' ) ) {
1241 /**
1242 * Sticky mobile Add-to-Cart bar. Rendered in the footer on single product
1243 * pages; the frontend script reveals it once the real Add-to-Cart button
1244 * scrolls out of view and proxies clicks back to the real form (so the
1245 * existing delegated cart handler + variation/price/qty state are reused).
1246 */
1247 function storeengine_render_sticky_add_to_cart() {
1248 if ( ! \StoreEngine\Utils\Helper::is_product() || ! \StoreEngine\Utils\Helper::get_settings( 'sticky_add_to_cart', true ) ) {
1249 return;
1250 }
1251
1252 global $product;
1253 if ( ! $product instanceof \StoreEngine\Classes\AbstractProduct ) {
1254 $product = \StoreEngine\Utils\Helper::get_product( get_the_ID() );
1255 }
1256 if ( ! $product ) {
1257 return;
1258 }
1259
1260 // Already in the cart — the bar exists to nudge an add, so there is
1261 // nothing left for it to do. Matches on product id, so any variation
1262 // of a variable product counts. The frontend script does the same on a
1263 // successful add, for the case where the cart changes without a reload.
1264 if ( ! empty( storeengine_cart()->get_cart_items_by_product( $product->get_id() ) ) ) {
1265 return;
1266 }
1267
1268 // Not get_the_post_thumbnail(): that returns '' for a product with no
1269 // featured image, which left a gap where the thumbnail should be. This
1270 // helper falls back to the first gallery image and then to the
1271 // placeholder, so the bar always has something to show.
1272 $thumb = storeengine_get_product_image( 'thumbnail', $product->get_id(), [
1273 'class' => 'storeengine-sticky-atc__img',
1274 'alt' => '',
1275 ] );
1276 ?>
1277 <div class="storeengine-sticky-atc" data-storeengine-sticky-atc data-product-id="<?php echo (int) $product->get_id(); ?>" hidden>
1278 <?php // Inner wrapper so the bar can span the viewport while its content stays aligned with the page on wide screens. ?>
1279 <div class="storeengine-sticky-atc__inner">
1280 <div class="storeengine-sticky-atc__info">
1281 <?php if ( $thumb ) : ?>
1282 <span class="storeengine-sticky-atc__thumb"><?php echo $thumb; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- core-generated <img>. ?></span>
1283 <?php endif; ?>
1284 <span class="storeengine-sticky-atc__meta">
1285 <span class="storeengine-sticky-atc__name"><?php echo esc_html( $product->get_name() ); ?></span>
1286 <span class="storeengine-sticky-atc__price" data-storeengine-sticky-price></span>
1287 </span>
1288 </div>
1289 <button type="button" class="storeengine-btn storeengine-btn--preset-blue storeengine-sticky-atc__btn" data-storeengine-sticky-atc-btn>
1290 <?php esc_html_e( 'Add to Cart', 'storeengine' ); ?>
1291 </button>
1292 </div>
1293 </div>
1294 <?php
1295 }
1296 }
1297 add_action( 'wp_footer', 'storeengine_render_sticky_add_to_cart' );
1298
1299 if ( ! function_exists( 'storeengine_render_recently_viewed' ) ) {
1300 /**
1301 * "Recently viewed" strip on single product pages. The list of ids lives in
1302 * the shopper's browser (localStorage); this only prints an empty container
1303 * carrying the REST endpoint + nonce, which the frontend script fills.
1304 */
1305 function storeengine_render_recently_viewed() {
1306 if ( ! \StoreEngine\Utils\Helper::is_product() || ! \StoreEngine\Utils\Helper::get_settings( 'enable_recently_viewed', true ) ) {
1307 return;
1308 }
1309 ?>
1310 <section
1311 class="storeengine-recently-viewed storeengine-container"
1312 data-storeengine-recently-viewed
1313 data-rest="<?php echo esc_url( rest_url( 'storeengine/v1/products/recently-viewed' ) ); ?>"
1314 data-nonce="<?php echo esc_attr( wp_create_nonce( 'wp_rest' ) ); ?>"
1315 data-exclude="<?php echo (int) get_the_ID(); ?>"
1316 hidden
1317 >
1318 <div class="storeengine-product-single__content-item">
1319 <h2 class="storeengine-recently-viewed__title"><?php esc_html_e( 'Recently viewed', 'storeengine' ); ?></h2>
1320 <div class="storeengine-products storeengine-products--grid">
1321 <div class="storeengine-products__body">
1322 <div class="storeengine-row" data-storeengine-recently-viewed-grid></div>
1323 </div>
1324 </div>
1325 </div>
1326 </section>
1327 <?php
1328 }
1329 }
1330 // wp_footer (always fires, incl. block themes where after_main_content doesn't);
1331 // the frontend script relocates the strip into the main content area.
1332 add_action( 'wp_footer', 'storeengine_render_recently_viewed', 20 );
1333
1334 if ( ! function_exists( 'storeengine_render_size_guide_trigger' ) ) {
1335 /**
1336 * "Size guide" trigger shown in the product summary (single product page and
1337 * Quick View, since both fire header_right_content).
1338 *
1339 * Resolves the product's size chart from the admin-managed library (see
1340 * {@see \StoreEngine\Classes\SizeChart}) and inlines it as JSON, so the modal
1341 * opens instantly with no REST round-trip — and works inside Quick View,
1342 * which is itself injected over AJAX.
1343 *
1344 * No chart matched means no trigger: a product with nothing to show should
1345 * not advertise a size guide.
1346 */
1347 function storeengine_render_size_guide_trigger() {
1348 if ( ! \StoreEngine\Utils\Helper::get_settings( 'enable_size_guide', false ) ) {
1349 return;
1350 }
1351
1352 global $product;
1353 $product_id = ( $product && method_exists( $product, 'get_id' ) ) ? (int) $product->get_id() : (int) get_the_ID();
1354
1355 $chart = $product_id ? \StoreEngine\Classes\SizeChart::get_product_chart( $product_id ) : [];
1356
1357 if ( empty( $chart ) ) {
1358 return;
1359 }
1360
1361 echo '<div class="storeengine-size-guide">';
1362 printf(
1363 '<button type="button" class="storeengine-size-guide-trigger" data-storeengine-size-guide><span class="storeengine-size-guide-trigger__icon" aria-hidden="true"></span>%s</button>',
1364 esc_html__( 'Size guide', 'storeengine' )
1365 );
1366 printf(
1367 '<script type="application/json" class="storeengine-size-guide-data">%s</script>',
1368 wp_json_encode( $chart, JSON_HEX_TAG | JSON_HEX_AMP ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON-encoded with tag/amp escaping.
1369 );
1370 echo '</div>';
1371 }
1372 }
1373 add_action( 'storeengine/templates/single-product/header_right_content', 'storeengine_render_size_guide_trigger', 35 );
1374
1375 /* -------------------------------------------------------------------------- *
1376 * Wishlist (core). Logged-in shoppers → user meta; guests → browser
1377 * localStorage (client side). Heart buttons on cards / product page / Quick
1378 * View, a [storeengine_wishlist] page and a [storeengine_wishlist_count] badge.
1379 * -------------------------------------------------------------------------- */
1380 if ( ! function_exists( 'storeengine_wishlist_enabled' ) ) {
1381 function storeengine_wishlist_enabled(): bool {
1382 return (bool) \StoreEngine\Utils\Helper::get_settings( 'enable_wishlist', false );
1383 }
1384 }
1385
1386 if ( ! function_exists( 'storeengine_get_user_wishlist' ) ) {
1387 function storeengine_get_user_wishlist( int $user_id = 0 ): array {
1388 $user_id = $user_id ?: get_current_user_id();
1389 if ( ! $user_id ) {
1390 return [];
1391 }
1392 $ids = get_user_meta( $user_id, 'storeengine_wishlist', true );
1393
1394 return is_array( $ids ) ? array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) ) : [];
1395 }
1396 }
1397
1398 if ( ! function_exists( 'storeengine_set_user_wishlist' ) ) {
1399 function storeengine_set_user_wishlist( array $ids, int $user_id = 0 ): array {
1400 $user_id = $user_id ?: get_current_user_id();
1401 $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) );
1402 if ( $user_id ) {
1403 update_user_meta( $user_id, 'storeengine_wishlist', $ids );
1404 }
1405
1406 return $ids;
1407 }
1408 }
1409
1410 if ( ! function_exists( 'storeengine_wishlist_button' ) ) {
1411 /**
1412 * Render a wishlist (heart) toggle for the current loop / product. The active
1413 * state is applied client-side (per shopper), so the markup is state-neutral.
1414 */
1415 function storeengine_wishlist_button() {
1416 if ( ! storeengine_wishlist_enabled() ) {
1417 return;
1418 }
1419 $product_id = get_the_ID();
1420 if ( ! $product_id ) {
1421 return;
1422 }
1423 printf(
1424 '<button type="button" class="storeengine-wishlist-btn" data-storeengine-wishlist data-product-id="%1$d" aria-pressed="false" aria-label="%2$s" title="%2$s"><span class="storeengine-wishlist-btn__icon" aria-hidden="true"></span></button>',
1425 (int) $product_id,
1426 esc_attr__( 'Add to wishlist', 'storeengine' )
1427 );
1428 }
1429 }
1430 add_action( 'storeengine/templates/after_product_loop_header_inner', 'storeengine_wishlist_button', 15 );
1431 add_action( 'storeengine/templates/single-product/header_right_content', 'storeengine_wishlist_button', 6 );
1432
1433 if ( ! function_exists( 'storeengine_wishlist_page_shortcode' ) ) {
1434 /**
1435 * `[storeengine_wishlist]` — the wishlist page. Prints an empty grid carrying
1436 * the cards REST endpoint; the frontend script fills it from the shopper's
1437 * ids (account for logged-in, browser for guests).
1438 */
1439 function storeengine_wishlist_page_shortcode(): string {
1440 if ( ! storeengine_wishlist_enabled() ) {
1441 return '';
1442 }
1443
1444 return sprintf(
1445 '<div class="storeengine-wishlist-page" data-storeengine-wishlist-page data-cards="%1$s" data-nonce="%2$s">'
1446 . '<p class="storeengine-wishlist-page__empty" hidden>%3$s</p>'
1447 . '<div class="storeengine-products storeengine-products--grid"><div class="storeengine-products__body">'
1448 . '<div class="storeengine-row" data-storeengine-wishlist-grid></div></div></div></div>',
1449 esc_url( rest_url( 'storeengine/v1/wishlist/cards' ) ),
1450 esc_attr( wp_create_nonce( 'wp_rest' ) ),
1451 esc_html__( 'Your wishlist is empty.', 'storeengine' )
1452 );
1453 }
1454 }
1455 add_shortcode( 'storeengine_wishlist', 'storeengine_wishlist_page_shortcode' );
1456
1457 if ( ! function_exists( 'storeengine_wishlist_count_shortcode' ) ) {
1458 /** `[storeengine_wishlist_count]` — a live count badge (updated client-side). */
1459 function storeengine_wishlist_count_shortcode(): string {
1460 if ( ! storeengine_wishlist_enabled() ) {
1461 return '';
1462 }
1463
1464 return '<span class="storeengine-wishlist-count" data-storeengine-wishlist-count>0</span>';
1465 }
1466 }
1467 add_shortcode( 'storeengine_wishlist_count', 'storeengine_wishlist_count_shortcode' );
1468
1469 /* -------------------------------------------------------------------------- *
1470 * Product compare (core). Same storage split as the wishlist above:
1471 * logged-in shoppers → user meta, guests → browser localStorage. Toggle
1472 * buttons on cards / product page / Quick View, a docked tray that appears once
1473 * something is selected, and a [storeengine_compare] page holding the
1474 * side-by-side table (built by \StoreEngine\Classes\ProductCompare).
1475 * -------------------------------------------------------------------------- */
1476 if ( ! function_exists( 'storeengine_compare_enabled' ) ) {
1477 function storeengine_compare_enabled(): bool {
1478 return (bool) \StoreEngine\Utils\Helper::get_settings( 'enable_product_compare', false );
1479 }
1480 }
1481
1482 if ( ! function_exists( 'storeengine_get_user_compare' ) ) {
1483 function storeengine_get_user_compare( int $user_id = 0 ): array {
1484 $user_id = $user_id ?: get_current_user_id();
1485 if ( ! $user_id ) {
1486 return [];
1487 }
1488 $ids = get_user_meta( $user_id, 'storeengine_compare', true );
1489
1490 return is_array( $ids ) ? array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) ) : [];
1491 }
1492 }
1493
1494 if ( ! function_exists( 'storeengine_set_user_compare' ) ) {
1495 function storeengine_set_user_compare( array $ids, int $user_id = 0 ): array {
1496 $user_id = $user_id ?: get_current_user_id();
1497 $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) );
1498 // Trim from the front, so the most recent pick always survives the cap.
1499 $max = \StoreEngine\Classes\ProductCompare::max();
1500 if ( count( $ids ) > $max ) {
1501 $ids = array_slice( $ids, -$max );
1502 }
1503 if ( $user_id ) {
1504 update_user_meta( $user_id, 'storeengine_compare', $ids );
1505 }
1506
1507 return $ids;
1508 }
1509 }
1510
1511 if ( ! function_exists( 'storeengine_compare_button' ) ) {
1512 /**
1513 * Render a compare toggle for the current loop / product. Like the wishlist
1514 * heart, the active state is per-shopper and applied client-side, so the
1515 * server markup is state-neutral and stays cacheable.
1516 */
1517 function storeengine_compare_button() {
1518 if ( ! storeengine_compare_enabled() ) {
1519 return;
1520 }
1521 $product_id = get_the_ID();
1522 if ( ! $product_id ) {
1523 return;
1524 }
1525 printf(
1526 '<button type="button" class="storeengine-compare-btn" data-storeengine-compare data-product-id="%1$d" aria-pressed="false">'
1527 . '<span class="storeengine-compare-btn__icon" aria-hidden="true"></span>'
1528 . '<span class="storeengine-compare-btn__label">%2$s</span></button>',
1529 (int) $product_id,
1530 esc_html__( 'Compare', 'storeengine' )
1531 );
1532 }
1533 }
1534 // Card: floats over the thumbnail, directly under the wishlist heart. It must
1535 // stay absolutely positioned (see the CSS) — the header is the positioning
1536 // context for the Quick View overlay, so any in-flow content here would inflate
1537 // it and push that button down onto the card body.
1538 add_action( 'storeengine/templates/after_product_loop_header_inner', 'storeengine_compare_button', 16 );
1539 add_action( 'storeengine/templates/single-product/header_right_content', 'storeengine_compare_button', 36 );
1540
1541 if ( ! function_exists( 'storeengine_render_compare_tray' ) ) {
1542 /**
1543 * The docked compare tray. Server-rendered empty and hidden; the frontend
1544 * script fills in the thumbnails and reveals it once something is selected.
1545 *
1546 * On wp_footer (like the recently-viewed strip) so it exists on every page,
1547 * including block themes where after_main_content never fires.
1548 */
1549 function storeengine_render_compare_tray() {
1550 if ( ! storeengine_compare_enabled() ) {
1551 return;
1552 }
1553
1554 printf(
1555 '<div class="storeengine-compare-tray" data-storeengine-compare-tray hidden data-table="%1$s" data-nonce="%2$s">'
1556 . '<div class="storeengine-compare-tray__items" data-storeengine-compare-tray-items></div>'
1557 . '<div class="storeengine-compare-tray__actions">'
1558 . '<button type="button" class="storeengine-compare-tray__clear" data-storeengine-compare-clear>%3$s</button>'
1559 . '<button type="button" class="storeengine-btn storeengine-btn--preset-blue storeengine-compare-tray__cta" data-storeengine-compare-open>%4$s</button>'
1560 . '</div></div>',
1561 esc_url( rest_url( 'storeengine/v1/compare/table' ) ),
1562 esc_attr( wp_create_nonce( 'wp_rest' ) ),
1563 esc_html__( 'Clear', 'storeengine' ),
1564 esc_html__( 'Compare', 'storeengine' )
1565 );
1566 }
1567 }
1568 add_action( 'wp_footer', 'storeengine_render_compare_tray', 21 );
1569
1570 if ( ! function_exists( 'storeengine_compare_page_shortcode' ) ) {
1571 /**
1572 * `[storeengine_compare]` — the comparison page. Prints an empty shell
1573 * carrying the table REST endpoint; the frontend script fills it from the
1574 * shopper's ids (account for logged-in, browser for guests).
1575 */
1576 function storeengine_compare_page_shortcode(): string {
1577 if ( ! storeengine_compare_enabled() ) {
1578 return '';
1579 }
1580
1581 return sprintf(
1582 '<div class="storeengine-compare-page" data-storeengine-compare-page data-table="%1$s" data-nonce="%2$s">'
1583 . '<p class="storeengine-compare-page__empty" hidden>%3$s</p>'
1584 . '<div data-storeengine-compare-table></div></div>',
1585 esc_url( rest_url( 'storeengine/v1/compare/table' ) ),
1586 esc_attr( wp_create_nonce( 'wp_rest' ) ),
1587 esc_html__( 'Pick a few products to compare and they will show up here.', 'storeengine' )
1588 );
1589 }
1590 }
1591 add_shortcode( 'storeengine_compare', 'storeengine_compare_page_shortcode' );
1592
1593 if ( ! function_exists( 'storeengine_compare_count_shortcode' ) ) {
1594 /** `[storeengine_compare_count]` — a live count badge (updated client-side). */
1595 function storeengine_compare_count_shortcode(): string {
1596 if ( ! storeengine_compare_enabled() ) {
1597 return '';
1598 }
1599
1600 return '<span class="storeengine-compare-count" data-storeengine-compare-count>0</span>';
1601 }
1602 }
1603 add_shortcode( 'storeengine_compare_count', 'storeengine_compare_count_shortcode' );
1604
1605 /* -------------------------------------------------------------------------- *
1606 * Shop-card variant switcher (Shopify-style). Gated by the existing
1607 * `product_archive_card_swatches` setting. Renders color/image swatches +
1608 * text pills under each variable card; the archive script resolves the picked
1609 * variant, swaps the image, updates the price, and enables add-to-cart.
1610 * -------------------------------------------------------------------------- */
1611 if ( ! function_exists( 'storeengine_card_swatches_enabled' ) ) {
1612 function storeengine_card_swatches_enabled(): bool {
1613 return (bool) \StoreEngine\Utils\Helper::get_settings( 'product_archive_card_swatches', false );
1614 }
1615 }
1616
1617 if ( ! function_exists( 'storeengine_get_card_variant_model' ) ) {
1618 /**
1619 * Build the per-card variant model for a variable product: selectable
1620 * attributes (each option carrying its swatch color / image, else a text
1621 * pill) and the full variation matrix (attributes → variation id, price_id,
1622 * thumbnail-sized image, final price + stock). Returns [] when not usable.
1623 *
1624 * @param mixed $product
1625 * @return array
1626 */
1627 function storeengine_get_card_variant_model( $product ): array {
1628 if ( ! $product instanceof \StoreEngine\Classes\Product\VariableProduct || 'variable' !== $product->get_type() ) {
1629 return [];
1630 }
1631
1632 $base = \StoreEngine\Assets::get_product_variations( $product );
1633 if ( empty( $base['variations'] ) || empty( $base['taxonomies'] ) ) {
1634 return [];
1635 }
1636
1637 // Variation matrix with thumbnail image + final price html.
1638 $variations = [];
1639 foreach ( $product->get_available_variants() as $variant ) {
1640 $attributes = [];
1641 foreach ( $variant->get_attributes() as $attribute ) {
1642 $attributes[ $attribute->taxonomy ] = $attribute->slug;
1643 }
1644 $pricing_id = (int) $variant->get_pricing_id();
1645 $base_price = isset( $base['pricing'][ $pricing_id ] ) ? (float) $base['pricing'][ $pricing_id ] : 0;
1646 $final = $base_price + (float) $variant->get_price();
1647 $thumb_id = (int) $variant->get_featured_image();
1648
1649 $variations[] = [
1650 'id' => (int) $variant->get_id(),
1651 'price_id' => $pricing_id,
1652 'attributes' => $attributes,
1653 'price' => $final,
1654 'price_html' => \StoreEngine\Utils\Formatting::price( $final ),
1655 'image' => $thumb_id ? wp_get_attachment_image_url( $thumb_id, 'storeengine_thumbnail' ) : '',
1656 'in_stock' => method_exists( $variant, 'is_in_stock' ) ? (bool) $variant->is_in_stock() : true,
1657 ];
1658 }
1659
1660 // Selectable attributes (in taxonomy order) with per-term swatch meta.
1661 $attributes = [];
1662 foreach ( $base['taxonomies'] as $taxonomy ) {
1663 $slugs = [];
1664 foreach ( $variations as $variation ) {
1665 if ( ! empty( $variation['attributes'][ $taxonomy ] ) ) {
1666 $slugs[ $variation['attributes'][ $taxonomy ] ] = true;
1667 }
1668 }
1669 $slugs = array_keys( $slugs );
1670 if ( empty( $slugs ) ) {
1671 continue;
1672 }
1673
1674 $terms = get_terms( [
1675 'taxonomy' => $taxonomy,
1676 'slug' => $slugs,
1677 'hide_empty' => false,
1678 ] );
1679 if ( is_wp_error( $terms ) || empty( $terms ) ) {
1680 continue;
1681 }
1682
1683 $options = [];
1684 foreach ( $terms as $term ) {
1685 $color = (string) get_term_meta( $term->term_id, '_storeengine_swatch_color', true );
1686 $image_id = (int) get_term_meta( $term->term_id, '_storeengine_swatch_image', true );
1687 $options[] = [
1688 'slug' => $term->slug,
1689 'name' => $term->name,
1690 'color' => $color,
1691 'image' => $image_id ? (string) wp_get_attachment_image_url( $image_id, 'storeengine_thumbnail' ) : '',
1692 ];
1693 }
1694
1695 $tax_obj = get_taxonomy( $taxonomy );
1696 $attributes[] = [
1697 'taxonomy' => $taxonomy,
1698 'label' => $tax_obj ? $tax_obj->labels->singular_name : ucwords( str_replace( [ 'se_pa_', '-', '_' ], [ '', ' ', ' ' ], $taxonomy ) ),
1699 'options' => $options,
1700 ];
1701 }
1702
1703 if ( empty( $attributes ) ) {
1704 return [];
1705 }
1706
1707 // Default price display (range across variations, or a single price).
1708 $prices = array_column( $variations, 'price' );
1709 $price_html = ( $prices && min( $prices ) !== max( $prices ) )
1710 ? \StoreEngine\Utils\Formatting::format_price_range( max( $prices ), min( $prices ) )
1711 : \StoreEngine\Utils\Formatting::price( $prices ? (float) reset( $prices ) : 0 );
1712
1713 return [
1714 'product_id' => (int) $product->get_id(),
1715 'attributes' => $attributes,
1716 'variations' => $variations,
1717 'price_html' => $price_html,
1718 ];
1719 }
1720 }
1721
1722 if ( ! function_exists( 'storeengine_product_has_card_variants' ) ) {
1723 /** Whether the card variant switcher should render for this product. */
1724 function storeengine_product_has_card_variants( $product ): bool {
1725 return storeengine_card_swatches_enabled() && ! empty( storeengine_get_card_variant_model( $product ) );
1726 }
1727 }
1728
1729 if ( ! function_exists( 'storeengine_render_card_variant_switcher' ) ) {
1730 /**
1731 * Render the swatch/pill strip + inline variation matrix under a variable
1732 * product card (hooked to before_product_loop_footer_inner).
1733 *
1734 * This belongs in the card footer, not the header: the header is the media
1735 * box and is the positioning context for the absolutely-placed Quick View /
1736 * wishlist overlays, so any in-flow content there inflates it and pushes the
1737 * bottom-anchored Quick View button down onto the swatches.
1738 */
1739 function storeengine_render_card_variant_switcher() {
1740 if ( ! storeengine_card_swatches_enabled() ) {
1741 return;
1742 }
1743 global $product;
1744 $model = storeengine_get_card_variant_model( $product );
1745 if ( empty( $model ) ) {
1746 return;
1747 }
1748
1749 echo '<div class="storeengine-card-variants" data-storeengine-card-variants>';
1750 foreach ( $model['attributes'] as $attr ) {
1751 printf(
1752 '<div class="storeengine-card-variants__group" role="radiogroup" aria-label="%1$s" data-taxonomy="%2$s">',
1753 esc_attr( $attr['label'] ),
1754 esc_attr( $attr['taxonomy'] )
1755 );
1756 foreach ( $attr['options'] as $opt ) {
1757 $type = $opt['color'] ? 'color' : ( $opt['image'] ? 'image' : 'text' );
1758 $style = $opt['color'] ? sprintf( ' style="background-color:%s"', esc_attr( $opt['color'] ) ) : '';
1759 $inner = $opt['image'] ? sprintf( '<img src="%s" alt="" loading="lazy" />', esc_url( $opt['image'] ) ) : ( 'text' === $type ? esc_html( $opt['name'] ) : '' );
1760 printf(
1761 '<button type="button" class="storeengine-card-variants__opt storeengine-card-variants__opt--%1$s" role="radio" aria-checked="false" data-slug="%2$s" title="%3$s" aria-label="%3$s"%4$s>%5$s</button>',
1762 esc_attr( $type ),
1763 esc_attr( $opt['slug'] ),
1764 esc_attr( $opt['name'] ),
1765 $style, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_attr'd above.
1766 $inner // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_url/esc_html above.
1767 );
1768 }
1769 echo '</div>';
1770 }
1771 printf(
1772 '<script type="application/json" class="storeengine-card-variants-data">%s</script>',
1773 wp_json_encode(
1774 [
1775 'product_id' => $model['product_id'],
1776 'variations' => $model['variations'],
1777 ],
1778 JSON_HEX_TAG | JSON_HEX_AMP
1779 )
1780 );
1781 echo '</div>';
1782 }
1783 }
1784 add_action( 'storeengine/templates/before_product_loop_footer_inner', 'storeengine_render_card_variant_switcher', 10 );
1785
1786 if ( ! function_exists( 'storeengine_loop_card_enhancements' ) ) {
1787 /**
1788 * Injects the card-carousel image data and the Quick View button into each
1789 * shop card (hooked to after_product_loop_header_inner).
1790 */
1791 function storeengine_loop_card_enhancements() {
1792 $features = storeengine_product_archive_features();
1793 if ( ! array_filter( $features ) ) {
1794 return;
1795 }
1796 $product_id = get_the_ID();
1797
1798 if ( $features['carousel'] ) {
1799 $images = [];
1800 foreach ( storeengine_get_product_gallery_items( $product_id ) as $item ) {
1801 if ( 'image' !== $item['type'] ) {
1802 continue;
1803 }
1804 $url = wp_get_attachment_image_url( $item['id'], 'storeengine_thumbnail' );
1805 if ( $url ) {
1806 $images[] = $url;
1807 }
1808 }
1809 if ( count( $images ) > 1 ) {
1810 printf(
1811 '<script type="application/json" class="storeengine-card-gallery-data">%s</script>',
1812 wp_json_encode( $images ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1813 );
1814 }
1815 }
1816
1817 if ( $features['quick_view'] ) {
1818 printf(
1819 '<button type="button" class="storeengine-quick-view-btn" data-product-id="%1$d" aria-label="%2$s"><span class="storeengine-quick-view-btn__icon" aria-hidden="true"></span><span class="storeengine-quick-view-btn__label">%2$s</span></button>',
1820 (int) $product_id,
1821 esc_attr__( 'Quick View', 'storeengine' )
1822 );
1823 }
1824 }
1825 }
1826
1827 if ( ! function_exists( 'storeengine_product_loop_content' ) ) {
1828 function storeengine_product_loop_content() {
1829 Template::get_template(
1830 'loop/content.php',
1831 );
1832 }
1833 }
1834
1835 if ( ! function_exists( 'storeengine_product_loop_footer' ) ) {
1836 function storeengine_product_loop_footer() {
1837 Template::get_template(
1838 'loop/footer.php',
1839 );
1840 }
1841 }
1842
1843 if ( ! function_exists( 'storeengine_product_loop_add_to_cart' ) ) {
1844 function storeengine_product_loop_add_to_cart() {
1845 Template::get_template( 'loop/add-to-cart.php' );
1846 }
1847 }
1848
1849 if ( ! function_exists( 'storeengine_get_the_product_category' ) ) {
1850 function storeengine_get_the_product_category( $ID ) {
1851 return get_the_terms( $ID, Helper::PRODUCT_CATEGORY_TAXONOMY );
1852 }
1853 }
1854
1855 if ( ! function_exists( 'storeengine_get_the_product_tag' ) ) {
1856 function storeengine_get_the_product_tag( $ID ) {
1857 return get_the_terms( $ID, Helper::PRODUCT_TAG_TAXONOMY );
1858 }
1859 }
1860
1861 if ( ! function_exists( 'storeengine_single_categories' ) ) {
1862 function storeengine_single_categories() {
1863 Template::get_template(
1864 'single-product/categories.php',
1865 );
1866 }
1867 }
1868
1869 if ( ! function_exists( 'storeengine_single_tag' ) ) {
1870 function storeengine_single_tag() {
1871 Template::get_template(
1872 'single-product/tag.php',
1873 );
1874 }
1875 }
1876
1877 if ( ! function_exists( 'storeengine_global_products' ) ) {
1878 function storeengine_global_products() {
1879 Helper::get_template( 'global/products.php' );
1880 }
1881 }
1882
1883 if ( ! function_exists( 'storeengine_no_products' ) ) {
1884 function storeengine_no_products() {
1885 Helper::get_template( 'archive/product-none.php' );
1886 }
1887 }
1888
1889 if ( ! function_exists( 'storeengine_get_product' ) ) {
1890 function storeengine_get_product( $product_id ) {
1891 $product = ( new ProductFactory() )->get_product( $product_id );
1892
1893 return $product->get_id() ? $product : false;
1894 }
1895 }
1896
1897 if ( ! function_exists( 'storeengine_attributes_generator' ) ) {
1898 function storeengine_attributes_generator(): Attributes {
1899 return new Attributes();
1900 }
1901 }
1902
1903 if ( ! function_exists( 'storeengine_get_checkout_url' ) ) {
1904 /**
1905 * @return mixed|null
1906 * @deprecated
1907 */
1908 function storeengine_get_checkout_url() {
1909 return Helper::get_checkout_url();
1910 }
1911 }
1912
1913 if ( ! function_exists( 'storeengine_get_account_menu_item' ) ) {
1914 /**
1915 * @return array
1916 * @deprecated
1917 */
1918 function storeengine_get_account_menu_items(): array {
1919 return apply_filters(
1920 'storeengine/account_menu_items',
1921 [
1922 'index' => __( 'Dashboard', 'storeengine' ),
1923 'orders' => __( 'Orders', 'storeengine' ),
1924 'plans' => __( 'Plans', 'storeengine' ),
1925 'edit-address' => _n( 'Address', 'Addresses', ( 1 + (int) ShippingUtils::is_shipping_enabled() ), 'storeengine' ),
1926 'affiliate-partner' => __( 'Affiliate', 'storeengine' ),
1927 'payment-methods' => __( 'Payment methods', 'storeengine' ),
1928 'edit-account' => __( 'Account details', 'storeengine' ),
1929 'customer-logout' => __( 'Log out', 'storeengine' ),
1930 ]
1931 );
1932 }
1933 }
1934
1935 if ( ! function_exists( 'storeengine_get_endpoint_url' ) ) {
1936 /**
1937 * @param string $endpoint
1938 * @param string|int|float $value
1939 * @param string|false $permalink
1940 *
1941 * @return string
1942 * @deprecated
1943 * @use Helper::get_endpoint_url()
1944 */
1945 function storeengine_get_endpoint_url( string $endpoint, $value = '', $permalink = '' ): string {
1946 return Helper::get_endpoint_url( $endpoint, $value, $permalink );
1947 }
1948 }
1949
1950 function storeengine_get_logout_redirect_url(): string {
1951 return Helper::get_logout_redirect_url();
1952 }
1953
1954 function storeengine_logout_url( string $redirect = '' ): string {
1955 return Helper::get_logout_url( $redirect );
1956 }
1957
1958 /**
1959 * @param string $endpoint
1960 * @param string|int|float $value $value
1961 *
1962 * @return string|null
1963 */
1964 function storeengine_get_dashboard_endpoint_url( string $endpoint, $value = '' ) {
1965 return Helper::get_account_endpoint_url( $endpoint, $value );
1966 }
1967
1968 /**
1969 * @param string $message
1970 * @param 'primary'|'secondary'|'info'|'success'|'error'|'warning'|array{
1971 * type?: 'primary'|'secondary'|'info'|'success'|'error'|'warning',
1972 * title?: string,
1973 * icon?: string,
1974 * alt?: bool|string,
1975 * dismissible?: bool,
1976 * id?: string,
1977 * buttons?: array<array{
1978 * label: string,
1979 * link?: string,
1980 * classes?: string,
1981 * target?: '_blank'|'_self'|'_parent'|'_top'|string,
1982 * icon?:string,
1983 * attrs?:array<array<string, string>>,
1984 * }>
1985 * } $args
1986 * @param string $name Optional. The name of the notice, provided for context to enable filtering
1987 *
1988 * @return void
1989 */
1990 function storeengine_show_notice( string $message, $args = [], string $name = '' ) {
1991 if ( is_string( $args ) ) {
1992 $args = [ 'type' => $args ];
1993 }
1994
1995 $args = wp_parse_args( $args, [
1996 'type' => 'info',
1997 'title' => '',
1998 'icon' => 'info',
1999 'alt' => false,
2000 'dismissible' => false,
2001 'id' => wp_unique_id( 'storeengine-notice-' ),
2002 'buttons' => [],
2003 ] );
2004
2005 if ( 'danger' === $args['type'] ) {
2006 $args['type'] = 'error';
2007 }
2008 if ( 'alert' === $args['type'] ) {
2009 $args['type'] = 'warning';
2010 }
2011
2012 $valid_notice_types = [ 'primary', 'secondary', 'info', 'success', 'error', 'warning' ];
2013
2014 if ( ! in_array( $args['type'], $valid_notice_types, true ) ) {
2015 $args['type'] = 'info';
2016 }
2017
2018 $args['message'] = $message;
2019
2020 if ( $name ) {
2021 /**
2022 * Filters notice arguments.
2023 *
2024 * If the third parameter of the storeengine_show_notice() function is present then this filter is available.
2025 * The third parameter, $name, is the name of the notice.
2026 *
2027 * @param array $args Arguments provided to the notice.
2028 * @param string $name The notice name.
2029 */
2030 $args = apply_filters( "storeengine/notice/notice_{$name}", $args, $name );
2031 }
2032
2033 Template::get_template( 'notice/notice.php', $args );
2034 }
2035
2036 if ( ! function_exists( 'storeengine_frontend_dashboard_content' ) ) {
2037 function storeengine_frontend_dashboard_content() {
2038 global $wp;
2039 if ( $wp->query_vars && ! empty( $wp->query_vars['storeengine_dashboard_page'] ) ) {
2040 $value = sanitize_key( $wp->query_vars['storeengine_dashboard_page'] );
2041
2042 if ( has_action( 'storeengine/frontend/dashboard_' . $value . '_endpoint' ) ) {
2043 /**
2044 * Hook for dynamic dashboard page contents.
2045 *
2046 * @param string $value page slug.
2047 */
2048 do_action( 'storeengine/frontend/dashboard_' . $value . '_endpoint', get_query_var( 'storeengine_dashboard_sub_page' ) );
2049
2050 return;
2051 }
2052 }
2053
2054 // No endpoint found? Default to dashboard.
2055 Template::get_template( 'frontend-dashboard/pages/dashboard.php' );
2056 }
2057 }
2058
2059 if ( ! function_exists( 'storeengine_dashboard_orders' ) ) {
2060 function storeengine_dashboard_orders() {
2061 Template::get_template( 'frontend-dashboard/partials/orders.php' );
2062 }
2063 }
2064
2065 if ( ! function_exists( 'storeengine_frontend_dashboard_orders_endpoint_content' ) ) {
2066 function storeengine_frontend_dashboard_orders_endpoint_content( $order_id ) {
2067 if ( $order_id ) {
2068 $order = Helper::get_order( absint( $order_id ) );
2069 $invalid_statuses = [ OrderStatus::DRAFT, OrderStatus::AUTO_DRAFT, OrderStatus::TRASH ];
2070
2071 if ( ! $order || is_wp_error( $order ) || ! $order->get_id() || $order->has_status( $invalid_statuses ) || get_current_user_id() !== $order->get_customer_id() ) {
2072 Template::get_template( 'frontend-dashboard/pages/partials/invalid-order.php', [ 'order' => $order_id ] );
2073
2074 return;
2075 }
2076
2077 Template::get_template( 'frontend-dashboard/pages/view-order.php', [ 'order' => $order ] );
2078 } else {
2079 Template::get_template( 'frontend-dashboard/pages/orders.php' );
2080 }
2081 }
2082 }
2083
2084 if ( ! function_exists( 'storeengine_frontend_dashboard_downloads_content' ) ) {
2085 function storeengine_frontend_dashboard_downloads_content() {
2086 Template::get_template( 'frontend-dashboard/pages/downloads.php' );
2087 }
2088 }
2089
2090 if ( ! function_exists( 'storeengine_get_customer_reviewable_products' ) ) {
2091 /**
2092 * Products the current customer has bought on completed orders, each with
2093 * whether the customer has already reviewed it.
2094 *
2095 * @return array<int,array{product:object,reviewed:bool,comment:\WP_Comment|null}>
2096 */
2097 function storeengine_get_customer_reviewable_products(): array {
2098 global $wpdb;
2099
2100 $user_id = get_current_user_id();
2101
2102 if ( ! $user_id ) {
2103 return [];
2104 }
2105
2106 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
2107 // Most recently purchased products first.
2108 $product_ids = $wpdb->get_col( $wpdb->prepare(
2109 "SELECT op.product_id
2110 FROM {$wpdb->prefix}storeengine_orders o
2111 JOIN {$wpdb->prefix}storeengine_order_product_lookup op ON o.id = op.order_id
2112 WHERE o.customer_id = %d AND o.status = 'completed'
2113 GROUP BY op.product_id
2114 ORDER BY MAX( o.date_created_gmt ) DESC, MAX( o.id ) DESC",
2115 $user_id
2116 ) );
2117 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
2118
2119 $items = [];
2120 foreach ( array_unique( array_map( 'absint', (array) $product_ids ) ) as $product_id ) {
2121 $product = Helper::get_product( $product_id );
2122
2123 if ( ! $product ) {
2124 continue;
2125 }
2126
2127 $existing = get_comments( [
2128 'user_id' => $user_id,
2129 'post_id' => $product_id,
2130 'type' => 'storeengine_product',
2131 'number' => 1,
2132 'status' => 'all',
2133 ] );
2134
2135 $items[] = [
2136 'product' => $product,
2137 'reviewed' => ! empty( $existing ),
2138 'comment' => ! empty( $existing ) ? $existing[0] : null,
2139 ];
2140 }
2141
2142 return $items;
2143 }
2144 }
2145
2146 if ( ! function_exists( 'storeengine_frontend_dashboard_reviews_content' ) ) {
2147 function storeengine_frontend_dashboard_reviews_content() {
2148 Template::get_template( 'frontend-dashboard/pages/reviews.php', [
2149 'items' => storeengine_get_customer_reviewable_products(),
2150 ] );
2151 }
2152 }
2153
2154 if ( ! function_exists( 'storeengine_frontend_dashboard_payment_methods_content' ) ) {
2155 function storeengine_frontend_dashboard_payment_methods_content() {
2156 Template::get_template( 'frontend-dashboard/pages/payment-methods.php' );
2157 }
2158 }
2159
2160 if ( ! function_exists( 'storeengine_frontend_dashboard_add_payment_method_content' ) ) {
2161 function storeengine_frontend_dashboard_add_payment_method_content() {
2162 Template::get_template( 'frontend-dashboard/pages/form-add-payment-method.php' );
2163 }
2164 }
2165
2166 if ( ! function_exists( 'storeengine_frontend_dashboard_edit_address_content' ) ) {
2167 function storeengine_frontend_dashboard_edit_address_content( string $load_address = '' ) {
2168 if ( in_array( $load_address, [ 'billing', 'shipping' ], true ) ) {
2169 $load_address = sanitize_key( $load_address );
2170 $customer = Helper::get_customer();
2171 $country = 'billing' === $load_address ? $customer->get_billing_country() : $customer->get_shipping_country();
2172
2173 if ( ! $country ) {
2174 $country = Countries::init()->get_base_country();
2175 }
2176
2177 if ( 'billing' === $load_address ) {
2178 $allowed_countries = Countries::init()->get_allowed_countries();
2179
2180 if ( ! array_key_exists( $country, $allowed_countries ) ) {
2181 $country = current( array_keys( $allowed_countries ) );
2182 }
2183 }
2184
2185 if ( 'shipping' === $load_address ) {
2186 $allowed_countries = Countries::init()->get_shipping_countries();
2187
2188 if ( ! array_key_exists( $country, $allowed_countries ) ) {
2189 $country = current( array_keys( $allowed_countries ) );
2190 }
2191 }
2192
2193 $address = Countries::init()->get_address_fields( $country, $load_address . '_' );
2194
2195 foreach ( $address as $key => $field ) {
2196 $method = 'get_' . $key;
2197 $value = '';
2198
2199 if ( method_exists( $customer, $method ) ) {
2200 $value = $customer->{'get_' . $key}();
2201 }
2202
2203 if ( ! $value && ( 'billing_email' === $key || 'shipping_email' === $key ) ) {
2204 $value = $customer->get_email();
2205 }
2206
2207 $address[ $key ]['value'] = apply_filters( 'storeengine/dashboard/edit_address/field_value', $value, $key, $load_address );
2208 }
2209
2210 $address = apply_filters( 'storeengine/dashboard/edit_address/address_to_edit', $address, $load_address );
2211
2212 Template::get_template( 'frontend-dashboard/pages/form-edit-address.php', [
2213 'load_address' => $load_address,
2214 'address' => $address,
2215 ] );
2216 } else {
2217 Template::get_template( 'frontend-dashboard/pages/edit-address.php', [
2218 'customer' => Helper::get_customer(),
2219 'countries' => Countries::init()->get_countries(),
2220 ] );
2221 }
2222 }
2223 }
2224
2225 if ( ! function_exists( 'storeengine_frontend_dashboard_payment_settings_content' ) ) {
2226 function storeengine_frontend_dashboard_payment_settings_content() {
2227 $user_id = get_current_user_id();
2228 $affiliate_settings = \StoreEngine\Addons\Affiliate\Settings\Affiliate::get_settings_saved_data();
2229 $minimum_withdraw_amount = isset( $affiliate_settings['minimum_withdraw_amount'] ) ? $affiliate_settings['minimum_withdraw_amount'] : 0;
2230 $is_enabled_paypal_withdraw = isset( $affiliate_settings['is_enabled_paypal_withdraw'] ) ? $affiliate_settings['is_enabled_paypal_withdraw'] : false;
2231 $is_enabled_echeck_withdraw = isset( $affiliate_settings['is_enabled_echeck_withdraw'] ) ? $affiliate_settings['is_enabled_echeck_withdraw'] : false;
2232 $is_enabled_bank_withdraw = isset( $affiliate_settings['is_enabled_bank_withdraw'] ) ? $affiliate_settings['is_enabled_bank_withdraw'] : false;
2233 $selected_payment_method = get_user_meta( $user_id, 'storeengine_affiliate_withdraw_method_type', true ) ?? '';
2234
2235 Template::get_template(
2236 'frontend-dashboard/pages/payments.php',
2237 [
2238 'minimum_withdraw_amount' => $minimum_withdraw_amount,
2239 'is_enabled_paypal_withdraw' => $is_enabled_paypal_withdraw,
2240 'is_enabled_echeck_withdraw' => $is_enabled_echeck_withdraw,
2241 'is_enabled_bank_withdraw' => $is_enabled_bank_withdraw,
2242 'withdraw_method_type' => $selected_payment_method,
2243 'current_user_id' => $user_id,
2244 ]
2245 );
2246 }
2247 }
2248
2249 if ( ! function_exists( 'storeengine_frontend_dashboard_edit_account_content' ) ) {
2250 function storeengine_frontend_dashboard_edit_account_content() {
2251 Template::get_template( 'frontend-dashboard/pages/edit-account.php', [ 'customer' => Helper::get_customer() ] );
2252 }
2253 }
2254
2255 if ( ! function_exists( 'storeengine_frontend_dashboard_forgot_password_content' ) ) {
2256 function storeengine_frontend_dashboard_forgot_password_content() {
2257 // Already-authenticated visitors don't need this flow. We can't
2258 // silently redirect from inside this template hook (headers are
2259 // already sent by the dashboard page), so just show a short notice
2260 // with a link back to the dashboard.
2261 if ( is_user_logged_in() ) {
2262 echo '<div class="storeengine-login-form-wrapper">';
2263 echo '<p>' . esc_html__( 'You are already signed in.', 'storeengine' ) . ' ';
2264 echo '<a href="' . esc_url( Helper::get_dashboard_url() ) . '">' . esc_html__( 'Go to your dashboard', 'storeengine' ) . '</a>.';
2265 echo '</p></div>';
2266 return;
2267 }
2268
2269 Template::get_template( 'frontend-dashboard/pages/forgot-password.php' );
2270 }
2271 }
2272
2273 if ( ! function_exists( 'storeengine_frontend_dashboard_register_content' ) ) {
2274 function storeengine_frontend_dashboard_register_content() {
2275 // Already-signed-in visitors hit /dashboard/register/ by accident
2276 // (e.g. stale bookmark) — show a short notice rather than the form.
2277 // We can't redirect here because the dashboard page has already
2278 // started outputting.
2279 if ( is_user_logged_in() ) {
2280 echo '<div class="storeengine-login-form-wrapper">';
2281 echo '<p>' . esc_html__( 'You are already signed in.', 'storeengine' ) . ' ';
2282 echo '<a href="' . esc_url( Helper::get_dashboard_url() ) . '">' . esc_html__( 'Go to your dashboard', 'storeengine' ) . '</a>.';
2283 echo '</p></div>';
2284 return;
2285 }
2286
2287 Template::get_template( 'frontend-dashboard/pages/register.php' );
2288 }
2289 }
2290
2291 // Archive
2292 if ( ! function_exists( 'storeengine_archive_product_header' ) ) {
2293 function storeengine_archive_product_header() {
2294 Template::get_template( 'archive/header.php' );
2295 }
2296 }
2297
2298 // Archive Header Filter
2299 if ( ! function_exists( 'storeengine_archive_header_filter' ) ) {
2300 function storeengine_archive_header_filter() {
2301 if ( isset( $_GET['orderby'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2302 $orderby = sanitize_text_field( wp_unslash( $_GET['orderby'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
2303 } else {
2304 $orderby = Helper::get_settings( 'product_archive_products_order', '' );
2305 }
2306
2307 $sort_options = [
2308 'menu_order' => __( 'Sort By Menu Order', 'storeengine' ),
2309 'title' => __( 'Sort By Product Name', 'storeengine' ),
2310 'date' => __( 'Sort By Publish Date', 'storeengine' ),
2311 'modified' => __( 'Sort By Modified Date', 'storeengine' ),
2312 'ID' => __( 'Sort By ID', 'storeengine' ),
2313 ];
2314
2315 ?>
2316 <div class="storeengine-products__filter">
2317 <form class="storeengine__header-ordering" method="get">
2318 <select name="orderby" class="storeengine__header-orderby"
2319 aria-label="<?php esc_attr_e( 'Sort Products', 'storeengine' ); ?>"
2320 onchange="this.form.submit()">
2321 <option
2322 value="" <?php selected( $orderby, '' ); ?>><?php esc_html_e( 'Default Sorting', 'storeengine' ); ?></option>
2323 <?php foreach ( $sort_options as $value => $label ) { ?>
2324 <option
2325 value="<?php echo esc_attr( $value ); ?>" <?php selected( $orderby, $value ); ?>><?php echo esc_html( $label ); ?></option>
2326 <?php } ?>
2327 </select>
2328 <input type="hidden" name="paged" value="1">
2329 </form>
2330 </div>
2331 <?php
2332 }
2333 }
2334
2335 // Product archive pagination
2336 if ( ! function_exists( 'storeengine_product_pagination' ) ) {
2337 function storeengine_product_pagination() {
2338 Template::get_template( 'archive/pagination.php' );
2339 }
2340 }
2341
2342 if ( ! function_exists( 'storeengine_archive_product_sidebar' ) ) {
2343 function storeengine_archive_product_sidebar() {
2344 Template::get_template( 'archive/sidebar.php' );
2345 }
2346 }
2347
2348 function storeengine_get_archive_filter_widgets() {
2349 $config = wp_parse_args( (array) Helper::get_settings( 'product_archive_filters' ), [
2350 'search' => (object) [
2351 'status' => true,
2352 'order' => 0,
2353 ],
2354 'category' => (object) [
2355 'status' => true,
2356 'order' => 1,
2357 ],
2358 'tags' => (object) [
2359 'status' => true,
2360 'order' => 2,
2361 ],
2362 ] );
2363
2364 $config = array_filter( $config, fn( $value ) => $value->status );
2365 $order = array_column( $config, 'order' );
2366
2367 array_multisort( $config, SORT_ASC, $order );
2368
2369 return apply_filters( 'storeengine/archive/product_filter_widgets', $config );
2370 }
2371
2372 if ( ! function_exists( 'storeengine_archive_header_filter_widget' ) ) {
2373 function storeengine_archive_header_filter_widget() {
2374 $filters = storeengine_get_archive_filter_widgets();
2375 foreach ( $filters as $widget => $value ) {
2376 $filter_function = 'storeengine_render_archive_product_filter_' . $widget . '_widget';
2377 if ( $value && function_exists( $filter_function ) ) {
2378 $classes = 'storeengine-archive-product-widget storeengine-archive-product-widget--' . $widget;
2379 if ( ! empty( $value->wrapper_class ) ) {
2380 $classes .= ' ' . $value->wrapper_class;
2381 }
2382 ?>
2383 <div class=" <?php echo esc_attr( trim( $classes ) ); ?>">
2384 <?php do_action( 'storeengine/archive/sidebar/filter_widget_before', $widget ); ?>
2385 <?php call_user_func( $filter_function ); ?>
2386 <?php do_action( 'storeengine/archive/sidebar/filter_widget_after', $widget ); ?>
2387 </div>
2388 <?php
2389 }
2390 }
2391 }
2392 }
2393
2394 if ( ! function_exists( 'storeengine_render_archive_product_filter_search_widget' ) ) {
2395 function storeengine_render_archive_product_filter_search_widget() {
2396 Template::get_template( 'archive/widgets/search.php', apply_filters( 'storeengine/archive/product_filter_by_search_args', [] ) );
2397 }
2398 }
2399
2400 if ( ! function_exists( 'storeengine_render_archive_product_filter_category_widget' ) ) {
2401 function storeengine_render_archive_product_filter_category_widget() {
2402 $args = apply_filters( 'storeengine/archive/product_filter_by_category_args', [ 'categories' => Helper::get_all_product_category_lists() ] );
2403 Template::get_template( 'archive/widgets/category.php', $args );
2404 }
2405 }
2406
2407 if ( ! function_exists( 'storeengine_render_archive_product_filter_tags_widget' ) ) {
2408 function storeengine_render_archive_product_filter_tags_widget() {
2409 $tags = get_terms( [
2410 'taxonomy' => 'storeengine_product_tag',
2411 'hide_empty' => true,
2412 ] );
2413 $args = apply_filters( 'storeengine/archive/product_filter_by_tags_args', [ 'tags' => $tags ] );
2414
2415 Template::get_template( 'archive/widgets/tags.php', $args );
2416 }
2417 }//end if
2418
2419 if ( ! function_exists( 'storeengine_checkout_order_details' ) ) {
2420 function storeengine_checkout_order_details() {
2421 if ( ! Formatting::string_to_bool( get_query_var( 'order_pay' ) ) ) {
2422 return;
2423 }
2424
2425 /** @var Order $order */
2426 $order = Helper::get_order( absint( get_query_var( 'order_id' ) ) );
2427
2428 if ( is_wp_error( $order ) ) {
2429 /** @var WP_Error $order */
2430 if ( StoreEngineNotFoundException::WP_ERROR_CODE === $order->get_error_code() ) {
2431 storeengine_show_notice( __( 'Order not found.', 'storeengine' ), 'error' );
2432
2433 return;
2434 }
2435 if ( in_array( $order->get_error_code(), [ 'order-not-found', 'order-class-not-found' ], true ) ) {
2436 storeengine_show_notice( __( 'Invalid Order', 'storeengine' ), 'error', 'order_pay_error' );
2437
2438 return;
2439 }
2440
2441 storeengine_show_notice( $order->get_error_message(), 'error' );
2442
2443 return;
2444 }
2445
2446 if ( ! $order->needs_payment() ) {
2447 if ( OrderStatus::CANCELLED === $order->get_status() ) {
2448 $error_message = __( 'This order has been canceled and payment is no longer possible. If you believe this is a mistake, please contact support.', 'storeengine' );
2449 } elseif ( OrderStatus::COMPLETED === $order->get_status() ) {
2450 $error_message = __( 'This order has already been completed and does not require payment. If you need help, please contact support.', 'storeengine' );
2451 } else {
2452 $error_message = __( 'This order cannot be paid at the moment. Please contact support if you need assistance.', 'storeengine' );
2453 }
2454
2455 storeengine_show_notice(
2456 $error_message,
2457 [
2458 'type' => 'warning',
2459 'title' => sprintf(
2460 // translators: %s. Order ID/Number.
2461 __( 'Order #%s', 'storeengine' ),
2462 $order->get_order_number()
2463 ),
2464 ],
2465 'order_pay_error'
2466 );
2467
2468 return;
2469 }
2470
2471 // Translators: %s Order number (id).
2472 echo '<h3>' . esc_html( sprintf( __( 'Complete Payment for Order #%s', 'storeengine' ), $order->get_id() ) ) . '</h3>';
2473
2474 if ( class_exists( Hooks::class ) ) {
2475 remove_action( 'storeengine/thankyou/after_order_details', [ Hooks::class, 'order_subscription_details' ] );
2476 }
2477
2478 Template::get_template( 'shortcode/order-details.php', [
2479 'order' => $order,
2480 'order_items' => $order->get_items(),
2481 'show_purchase_note' => false,
2482 ] );
2483
2484 echo '</br>';
2485 }
2486 }
2487
2488 if ( ! function_exists( 'storeengine_checkout_payment_method' ) ) {
2489 function storeengine_checkout_payment_method() {
2490 $needs_payment = Helper::cart()->needs_payment() || Formatting::string_to_bool( get_query_var( 'order_pay' ) );
2491 $place_order_label = __( 'Place Order', 'storeengine' );
2492 $selected = false;
2493
2494 if ( $needs_payment ) {
2495 if ( Formatting::string_to_bool( get_query_var( 'order_pay' ) ) ) {
2496 $order = Helper::get_order( absint( get_query_var( 'order_id' ) ) );
2497
2498 if ( is_wp_error( $order ) || ! $order->needs_payment() ) {
2499 return;
2500 }
2501
2502 $selected = $order->get_payment_method();
2503 $place_order_label = __( 'Pay for Order', 'storeengine' );
2504 }
2505
2506 $available_gateways = Helper::get_payment_gateways()->get_available_payment_gateways();
2507 // On the order-pay page, keep the order's existing payment method
2508 // selected (e.g. an early subscription renewal reuses the method the
2509 // customer paid with before) instead of defaulting to the first gateway.
2510 Helper::get_payment_gateways()->set_current_gateway( $available_gateways, is_string( $selected ) ? $selected : '' );
2511 }
2512
2513 $place_order_label = apply_filters( 'storeengine/checkout/place_order_button_text', $place_order_label, $needs_payment );
2514
2515 Template::get_template( 'checkout/payment.php', [
2516 'needs_payment' => $needs_payment,
2517 'selected' => $selected,
2518 'place_order_label' => $place_order_label,
2519 ] );
2520 }
2521 }
2522
2523 if ( ! function_exists( 'storeengine_checkout_form_field_user_info' ) ) {
2524 function storeengine_checkout_form_field_user_info() {
2525 if ( Formatting::string_to_bool( get_query_var( 'order_pay' ) ) ) {
2526 return;
2527 }
2528
2529 Template::get_template( 'checkout/contact-info.php', [ 'current_user_email' => StoreEngine::init()->customer->get_email() ] );
2530 }
2531 }
2532
2533 if ( ! function_exists( 'storeengine_checkout_form_field_shipping_address' ) ) {
2534 function storeengine_checkout_form_field_shipping_address() {
2535 if ( ! get_query_var( 'order_pay' ) ) {
2536 $order = Helper::get_recent_draft_order();
2537 if ( Helper::cart()->needs_shipping() ) {
2538 Template::get_template( 'checkout/shipping-address.php', [ 'order' => $order ] );
2539 }
2540 }
2541 }
2542 }
2543
2544 if ( ! function_exists( 'storeengine_checkout_form_field_billing_address' ) ) {
2545 function storeengine_checkout_form_field_billing_address() {
2546 if ( ! get_query_var( 'order_pay' ) ) {
2547 $order = Helper::get_recent_draft_order();
2548 Template::get_template( 'checkout/billing-address.php', [
2549 'order' => $order,
2550 'is_digital_cart' => ! Helper::cart()->needs_shipping(),
2551 ] );
2552 }
2553 }
2554 }
2555
2556 // Checkout Form
2557 if ( ! function_exists( 'storeengine_checkout_total' ) ) {
2558 function storeengine_checkout_total() {
2559 Template::get_template( 'checkout/checkout-total.php' );
2560 }
2561 }
2562
2563 if ( ! function_exists( 'storeengine_frontend_dashboard_menu' ) ) {
2564 function storeengine_frontend_dashboard_menu() {
2565 $menu_items = Helper::get_frontend_dashboard_menu_items();
2566
2567 // Group-aware sort: orders → earnings → account, with non-clickable
2568 // section headers injected before the first member of each group.
2569 // Ungrouped items (Dashboard, Log out, vendor section) keep their
2570 // position via their own priority.
2571 $menu_items = Helper::apply_frontend_dashboard_menu_groups( $menu_items );
2572
2573 Helper::get_template( 'frontend-dashboard/menu.php', [ 'menu_items' => $menu_items ] );
2574 }
2575 }
2576
2577 if ( ! function_exists( 'storeengine_get_the_canvas_container_class' ) ) {
2578 function storeengine_get_the_canvas_container_class() {
2579 global $post;
2580
2581 echo esc_attr( apply_filters( 'storeengine/templates/canvas_container_class', 'storeengine-container', $post->ID ) );
2582 }
2583 }
2584
2585 /**
2586 * Render frontend top-bar.
2587 *
2588 * @return void
2589 */
2590 function storeengine_frontend_dashboard_content_topbar() {
2591 $path = (string) get_query_var( 'storeengine_dashboard_page' );
2592 $path = $path ? $path : 'index';
2593 $sub_path = (string) get_query_var( 'storeengine_dashboard_sub_page' );
2594 $page_title = StoreEngine\Utils\Helper::get_frontend_dashboard_page_title( $path, $sub_path );
2595
2596 Template::get_template( 'frontend-dashboard/topbar.php', [
2597 'page_title' => $page_title,
2598 'path' => $path,
2599 'sub_path' => $sub_path,
2600 ] );
2601 }
2602
2603 /**
2604 * Per-endpoint description shown under the shared page-header title.
2605 *
2606 * @param string $description
2607 * @param string $path
2608 * @param string $sub_path
2609 *
2610 * @return string
2611 */
2612 function storeengine_frontend_dashboard_page_description( $description, $path, $sub_path ) {
2613 $descriptions = [
2614 'payment-methods' => __( 'Set a default method and edit or remove your saved cards anytime.', 'storeengine' ),
2615 ];
2616
2617 return $descriptions[ $path ] ?? $description;
2618 }
2619
2620 /**
2621 * Primary page action rendered on the right of the shared page-header for
2622 * endpoints that have one (replacing the per-page hand-rolled buttons).
2623 *
2624 * @param string $path
2625 * @param string $sub_path
2626 */
2627 function storeengine_frontend_dashboard_topbar_actions( $path, $sub_path ) {
2628 if ( 'payment-methods' === $path && ! $sub_path && StoreEngine\Utils\Helper::get_payment_gateways()->get_available_payment_gateways() ) {
2629 printf(
2630 '<a class="storeengine-btn storeengine-btn--preset-primary storeengine-dashboard-header-action" href="%1$s"><span aria-hidden="true">+</span> %2$s</a>',
2631 esc_url( StoreEngine\Utils\Helper::get_account_endpoint_url( 'add-payment-method' ) ),
2632 esc_html__( 'Add Payment Method', 'storeengine' )
2633 );
2634 }
2635 }
2636
2637 function storeengine_frontend_dashboard_breadcrumbs_order_title( string $path, string $sub_path ) {
2638 if ( 'orders' === $path && $sub_path ) {
2639 echo ' <i class="storeengine-icon storeengine-icon--arrow-right" aria-hidden="true"></i> ';
2640 /* translators: %s. Order ID/Number. */
2641 printf( esc_html__( 'Order #%s', 'storeengine' ), esc_html( $sub_path ) );
2642 }
2643 }
2644
2645 function storeengine_frontend_dashboard_breadcrumbs_plan_title( string $path, string $sub_path ) {
2646 if ( 'plans' === $path && $sub_path ) {
2647 echo ' <i class="storeengine-icon storeengine-icon--arrow-right" aria-hidden="true"></i> ';
2648 /* translators: %s. Plan ID. */
2649 printf( esc_html__( 'Plan #%s', 'storeengine' ), esc_html( $sub_path ) );
2650 }
2651 }
2652
2653 /**
2654 * Resolves page template file for plugin.
2655 *
2656 * Shop page (product-archive) will not go through `page_template` filter. As the plugin marks it as a archive page
2657 * and instead WordPress will load it through `archive_template` filter.
2658 *
2659 * @param string $template
2660 *
2661 * @return string
2662 */
2663 function storeengine_redirect_canvas_page_template( $template ) {
2664 if ( Helper::is_fse_theme() ) {
2665 return $template;
2666 }
2667
2668 $post = get_post();
2669 $page_template = get_post_meta( $post->ID, '_wp_page_template', true );
2670 if ( 'storeengine-canvas.php' === basename( $page_template ) ) {
2671 $template = STOREENGINE_TEMPLATE_PATH . 'storeengine-canvas.php';
2672 }
2673
2674 return $template;
2675 }
2676
2677 // Dashboard Order Pagination
2678 if ( ! function_exists( 'storeengine_dashboard_order_pagination' ) ) {
2679 /**
2680 * @param $query
2681 *
2682 * @return void
2683 * @deprecated 1.6.9
2684 * @see storeengine_dashboard_collection_query_pagination()
2685 */
2686 function storeengine_dashboard_order_pagination( $query ) {
2687 _deprecated_function( __FUNCTION__, '1.6.9', 'storeengine_dashboard_collection_query_pagination' );
2688 $current_page = max( 1, get_query_var( 'paged' ) );
2689 Template::get_template( 'frontend-dashboard/partials/query-pagination.php', [
2690 'query' => $query,
2691 'page_url' => Helper::get_current_dashboard_endpoint_url(),
2692 'current_page' => $current_page,
2693 'previous_page' => $current_page - 1,
2694 'max_pages' => $query->get_max_num_pages(),
2695 ] );
2696 }
2697 }
2698
2699 // Dashboard Order Pagination
2700 if ( ! function_exists( 'storeengine_dashboard_subscription_pagination' ) ) {
2701 /**
2702 * @param $query
2703 *
2704 * @return void
2705 * @see storeengine_dashboard_collection_query_pagination()
2706 * @deprecated 1.6.9
2707 */
2708 function storeengine_dashboard_subscription_pagination( $query ) {
2709 _deprecated_function( __FUNCTION__, '1.6.9', 'storeengine_dashboard_collection_query_pagination' );
2710 $current_page = max( 1, get_query_var( 'paged' ) );
2711 Template::get_template( 'frontend-dashboard/partials/query-pagination.php', [
2712 'query' => $query,
2713 'page_url' => Helper::get_current_dashboard_endpoint_url(),
2714 'current_page' => $current_page,
2715 'previous_page' => $current_page - 1,
2716 'max_pages' => $query->get_max_num_pages(),
2717 ] );
2718 }
2719 }
2720
2721 if ( ! function_exists( 'storeengine_dashboard_collection_query_pagination' ) ) {
2722 /**
2723 * Renders simple pagination from `Collection Query`.
2724 *
2725 * @param AbstractCollection $query Collection Query instance.
2726 * @param string|null $endpoint Current endpoint (dashboard page).
2727 *
2728 * @return void
2729 * @since 1.6.9
2730 */
2731 function storeengine_dashboard_collection_query_pagination( AbstractCollection $query, string $endpoint = null ) {
2732 if ( $query->get_max_num_pages() <= 1 ) {
2733 return;
2734 }
2735
2736 $current_page = max( 1, get_query_var( 'paged' ) );
2737 Template::get_template( 'frontend-dashboard/partials/query-pagination.php', [
2738 'query' => $query,
2739 'page_url' => Helper::get_current_dashboard_endpoint_url( $endpoint ),
2740 'current_page' => $current_page,
2741 'previous_page' => $current_page - 1,
2742 'max_pages' => $query->get_max_num_pages(),
2743 ] );
2744 }
2745 }
2746
2747 if ( ! function_exists( 'storeengine_single_product_feedback' ) ) {
2748 function storeengine_single_product_feedback() {
2749 if ( ! (bool) Helper::get_settings( 'enable_product_reviews', true ) ) {
2750 return;
2751 }
2752 $rating = \StoreEngine\Models\Product::get_product_rating( get_the_ID() );
2753 Template::get_template( 'single-product/feedback.php', array( 'rating' => $rating ) );
2754 }
2755 }
2756
2757 if ( ! function_exists( 'storeengine_single_product_review_and_comments' ) ) {
2758 function storeengine_single_product_review_and_comments() {
2759 $enable_product_reviews = (bool) \StoreEngine\Utils\Helper::get_settings( 'enable_product_reviews', true );
2760 $enable_product_comments = (bool) \StoreEngine\Utils\Helper::get_settings( 'enable_product_comments', false );
2761
2762 if ( ! $enable_product_comments && ! $enable_product_reviews ) {
2763 return;
2764 }
2765
2766 // Only render the comments block when product comments are enabled in
2767 // settings. The product post type always `supports` comments, so
2768 // comments_open() alone stays true even after the store disables them —
2769 // gating here is what actually hides the comments section.
2770 if ( $enable_product_comments && ( comments_open() || get_comments_number() ) ) {
2771 comments_template();
2772 }
2773
2774 if ( $enable_product_reviews ) {
2775 $rating = StoreEngine\Models\Product::get_product_rating( get_the_ID() );
2776 Helper::get_template( 'single-product/feedback.php', [ 'rating' => $rating ] );
2777 Helper::get_template( 'single-product/reviews.php', [ 'product_id' => get_the_ID() ] );
2778 }
2779 }
2780 }
2781
2782 if ( ! function_exists( 'storeengine_has_ordered_product' ) ) {
2783 /**
2784 * Whether a customer has an order (any non-draft status) for a product.
2785 *
2786 * @param int $product_id Product id.
2787 * @param int $user_id Customer id (0 = current user).
2788 *
2789 * @return bool
2790 */
2791 function storeengine_has_ordered_product( $product_id, $user_id = 0 ): bool {
2792 global $wpdb;
2793
2794 if ( ! $user_id ) {
2795 $user_id = get_current_user_id();
2796 }
2797
2798 if ( ! $user_id ) {
2799 return false;
2800 }
2801
2802 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
2803 return (bool) $wpdb->get_var( $wpdb->prepare(
2804 "SELECT o.id
2805 FROM {$wpdb->prefix}storeengine_orders o
2806 JOIN {$wpdb->prefix}storeengine_order_product_lookup op ON o.id = op.order_id
2807 WHERE o.customer_id = %d
2808 AND op.product_id = %d
2809 AND o.status NOT IN ( 'draft', 'auto-draft', 'trash' )
2810 LIMIT 1;",
2811 $user_id,
2812 $product_id
2813 ) );
2814 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
2815 }
2816 }
2817
2818 if ( ! function_exists( 'storeengine_can_review_product' ) ) {
2819 /**
2820 * Whether the current user may leave a review, per the `review_permission`
2821 * setting: completed_order (default) | purchased | everyone.
2822 *
2823 * @param int $product_id Product id.
2824 * @param int $user_id User id (0 = current user).
2825 *
2826 * @return bool
2827 */
2828 function storeengine_can_review_product( $product_id, $user_id = 0 ): bool {
2829 $permission = \StoreEngine\Utils\Helper::get_settings( 'review_permission', 'completed_order' );
2830
2831 // Anyone (still requires login — reviews are stored as comments).
2832 if ( 'everyone' === $permission ) {
2833 $allowed = is_user_logged_in();
2834 } elseif ( ! is_user_logged_in() ) {
2835 $allowed = false;
2836 } elseif ( 'purchased' === $permission ) {
2837 $allowed = storeengine_has_ordered_product( $product_id, $user_id );
2838 } else {
2839 // completed_order — bought AND the order is completed.
2840 $allowed = (bool) \StoreEngine\Utils\Helper::is_purchase_the_product( $product_id, $user_id );
2841 }
2842
2843 return (bool) apply_filters( 'storeengine/can_review_product', $allowed, $product_id, $user_id, $permission );
2844 }
2845 }
2846
2847 if ( ! function_exists( 'storeengine_get_user_review' ) ) {
2848 /**
2849 * The current (or given) user's own review for a product, if any.
2850 *
2851 * @param int $product_id Product id.
2852 * @param int $user_id User id (0 = current user).
2853 *
2854 * @return \WP_Comment|null
2855 */
2856 function storeengine_get_user_review( $product_id, $user_id = 0 ) {
2857 if ( ! $user_id ) {
2858 $user_id = get_current_user_id();
2859 }
2860 if ( ! $user_id ) {
2861 return null;
2862 }
2863
2864 $comments = get_comments( [
2865 'user_id' => $user_id,
2866 'post_id' => $product_id,
2867 'type' => 'storeengine_product',
2868 'number' => 1,
2869 'status' => 'all',
2870 ] );
2871
2872 return $comments ? $comments[0] : null;
2873 }
2874 }
2875
2876 if ( ! function_exists( 'storeengine_review_auto_approve' ) ) {
2877 /**
2878 * Whether new reviews are auto-approved (vs held for manual approval),
2879 * per the `review_approval` setting.
2880 *
2881 * @return bool
2882 */
2883 function storeengine_review_auto_approve(): bool {
2884 $approval = \StoreEngine\Utils\Helper::get_settings( 'review_approval', 'auto' );
2885
2886 return (bool) apply_filters( 'storeengine/review_auto_approve', 'pending' !== $approval );
2887 }
2888 }
2889
2890 if ( ! function_exists( 'storeengine_review_media_max_size' ) ) {
2891 /**
2892 * Max bytes a single review upload may be — the smaller of the plugin's own
2893 * cap and the server's real PHP limit (upload_max_filesize / post_max_size).
2894 *
2895 * @param int $product_id Product id (for the filter).
2896 *
2897 * @return int Bytes.
2898 */
2899 function storeengine_review_media_max_size( $product_id = 0 ): int {
2900 $plugin_max = (int) apply_filters( 'storeengine/review_media_max_size', 25 * MB_IN_BYTES, $product_id );
2901 $server_max = (int) wp_max_upload_size();
2902
2903 if ( $server_max > 0 ) {
2904 return min( $plugin_max, $server_max );
2905 }
2906
2907 return $plugin_max;
2908 }
2909 }
2910
2911 if ( ! function_exists( 'storeengine_review_media_max' ) ) {
2912 /**
2913 * Max media attachments allowed per review (0 = unlimited).
2914 *
2915 * @return int
2916 */
2917 function storeengine_review_media_max(): int {
2918 return max( 0, (int) \StoreEngine\Utils\Helper::get_settings( 'review_media_max', 5 ) );
2919 }
2920 }
2921
2922 if ( ! function_exists( 'storeengine_get_product_faqs' ) ) {
2923 /**
2924 * Resolve the grouped FAQ list for a product (inline + rule-matched groups).
2925 *
2926 * @param int $product_id Product id.
2927 *
2928 * @return array<int,array{id:int,title:string,items:array<int,array{question:string,answer:string}>}>
2929 */
2930 function storeengine_get_product_faqs( $product_id ): array {
2931 return \StoreEngine\Classes\Faq::get_product_faqs( absint( $product_id ) );
2932 }
2933 }
2934
2935 if ( ! function_exists( 'storeengine_single_product_faq' ) ) {
2936 function storeengine_single_product_faq() {
2937 // The whole FAQ feature can be turned off in settings.
2938 if ( ! (bool) Helper::get_settings( 'enable_faqs', true ) ) {
2939 return;
2940 }
2941
2942 $product_id = get_the_ID();
2943 $groups = storeengine_get_product_faqs( $product_id );
2944
2945 if ( empty( $groups ) ) {
2946 return;
2947 }
2948
2949 Helper::get_template( 'single-product/faq.php', [
2950 'product_id' => $product_id,
2951 'groups' => $groups,
2952 ] );
2953
2954 storeengine_product_faq_schema( $product_id, $groups );
2955 }
2956 }
2957
2958 if ( ! function_exists( 'storeengine_product_faq_schema' ) ) {
2959 /**
2960 * Emit schema.org FAQPage JSON-LD so search engines can surface FAQ rich
2961 * results. Answers are reduced to plain text as the spec requires.
2962 *
2963 * @param int $product_id Product id.
2964 * @param array $groups Resolved FAQ groups.
2965 */
2966 function storeengine_product_faq_schema( $product_id, array $groups ) {
2967 if ( ! apply_filters( 'storeengine/product_faq_schema_enabled', true, $product_id ) ) {
2968 return;
2969 }
2970
2971 $entities = [];
2972 foreach ( $groups as $group ) {
2973 foreach ( $group['items'] as $item ) {
2974 $question = trim( wp_strip_all_tags( $item['question'] ) );
2975 $answer = trim( wp_strip_all_tags( $item['answer'] ) );
2976
2977 if ( '' === $question || '' === $answer ) {
2978 continue;
2979 }
2980
2981 $entities[] = [
2982 '@type' => 'Question',
2983 'name' => $question,
2984 'acceptedAnswer' => [
2985 '@type' => 'Answer',
2986 'text' => $answer,
2987 ],
2988 ];
2989 }
2990 }
2991
2992 if ( empty( $entities ) ) {
2993 return;
2994 }
2995
2996 $schema = [
2997 '@context' => 'https://schema.org',
2998 '@type' => 'FAQPage',
2999 'mainEntity' => $entities,
3000 ];
3001
3002 echo '<script type="application/ld+json">' . wp_json_encode( $schema ) . '</script>' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
3003 }
3004 }
3005
3006 if ( ! function_exists( 'storeengine_review_display_gravatar' ) ) {
3007 /**
3008 * Display the review authors gravatar
3009 *
3010 * @param stdClass|WP_Comment $comment WP_Comment.
3011 *
3012 * @return void
3013 */
3014 function storeengine_review_display_gravatar( $comment ) {
3015 echo get_avatar( $comment->comment_author_email, apply_filters( 'storeengine/review_gravatar_size', '80' ), '' );
3016 }
3017 }
3018
3019 if ( ! function_exists( 'storeengine_review_display_rating' ) ) {
3020 /**
3021 * Display the reviewers star rating
3022 *
3023 * @return void
3024 */
3025 function storeengine_review_display_rating( $comment ) {
3026 if ( post_type_supports( 'storeengine_product', 'comments' ) ) {
3027 Template::get_template( 'single-product/review-rating.php', [ 'comment' => $comment ] );
3028 }
3029 }
3030 }
3031
3032 if ( ! function_exists( 'storeengine_review_display_meta' ) ) {
3033 /**
3034 * Display the review authors meta (name, verified owner, review date)
3035 *
3036 * @return void
3037 */
3038 function storeengine_review_display_meta( $comment ) {
3039 Template::get_template( 'single-product/review-meta.php', [ 'comment' => $comment ] );
3040 }
3041 }
3042
3043 if ( ! function_exists( 'storeengine_review_display_comment_text' ) ) {
3044
3045 /**
3046 * Display the review content.
3047 */
3048 function storeengine_review_display_comment_text( $comment ) {
3049 echo '<div class="storeengine-review-description">';
3050 comment_text( $comment->comment_ID );
3051 // @TODO make dynamic single-product/review-thumbnail.php
3052 echo '</div>';
3053 }
3054 }
3055
3056 if ( ! function_exists( 'storeengine_get_review_media' ) ) {
3057 /**
3058 * Get the media (images/videos) attached to a review.
3059 *
3060 * @param int $comment_id Comment id.
3061 *
3062 * @return array<int,array{id:int,url:string,thumb:string,type:string}>
3063 */
3064 function storeengine_get_review_media( $comment_id ): array {
3065 $ids = get_comment_meta( $comment_id, 'storeengine_review_media', true );
3066
3067 if ( empty( $ids ) || ! is_array( $ids ) ) {
3068 return [];
3069 }
3070
3071 $media = [];
3072 foreach ( $ids as $attachment_id ) {
3073 $attachment_id = absint( $attachment_id );
3074 $url = wp_get_attachment_url( $attachment_id );
3075
3076 if ( ! $url ) {
3077 continue;
3078 }
3079
3080 $mime = (string) get_post_mime_type( $attachment_id );
3081 $media[] = [
3082 'id' => $attachment_id,
3083 'url' => $url,
3084 'thumb' => wp_get_attachment_image_url( $attachment_id, 'thumbnail' ) ?: $url,
3085 'type' => ( 0 === strpos( $mime, 'video/' ) ) ? 'video' : 'image',
3086 ];
3087 }
3088
3089 return $media;
3090 }
3091 }
3092
3093 if ( ! function_exists( 'storeengine_review_display_media' ) ) {
3094 /**
3095 * Render the media gallery attached to a review.
3096 *
3097 * @param \WP_Comment $comment Comment.
3098 */
3099 function storeengine_review_display_media( $comment ) {
3100 $media = storeengine_get_review_media( $comment->comment_ID );
3101
3102 if ( empty( $media ) ) {
3103 return;
3104 }
3105
3106 echo '<div class="storeengine-review-media">';
3107 foreach ( $media as $item ) {
3108 if ( 'video' === $item['type'] ) {
3109 printf(
3110 '<a class="storeengine-review-media__item storeengine-review-media__item--video" href="%1$s" target="_blank" rel="noopener"><video src="%1$s" preload="metadata" muted playsinline></video><span class="storeengine-review-media__play" aria-hidden="true"></span></a>',
3111 esc_url( $item['url'] )
3112 );
3113 } else {
3114 printf(
3115 '<a class="storeengine-review-media__item storeengine-review-media__item--image" href="%1$s" target="_blank" rel="noopener"><img src="%2$s" alt="%3$s" loading="lazy" /></a>',
3116 esc_url( $item['url'] ),
3117 esc_url( $item['thumb'] ),
3118 esc_attr__( 'Review media', 'storeengine' )
3119 );
3120 }
3121 }
3122 echo '</div>';
3123 }
3124 }
3125
3126 if ( ! function_exists( 'storeengine_get_rating_html' ) ) {
3127 /**
3128 * Get HTML for ratings.
3129 *
3130 * @param float $rating Rating being shown.
3131 * @param int $count Total number of ratings.
3132 *
3133 * @return string
3134 */
3135 function storeengine_get_rating_html( $rating, $count = 0 ) {
3136 $html = '';
3137 if ( 0 < $rating ) {
3138 $html = Helper::single_star_rating_generator( $rating );
3139 }
3140
3141 return apply_filters( 'storeengine/course_get_rating_html', $html, $rating, $count );
3142 }
3143 }
3144
3145 if ( ! function_exists( 'storeengine_get_rating_html' ) ) {
3146 function storeengine_single_product_count_review() {
3147 ?>
3148 <div class="storeengine-single__rating storeengine-d-flex">
3149 <div>
3150 <i class="storeengine-icon storeengine-icon--star-fill"></i>
3151 <i class="storeengine-icon storeengine-icon--star-fill"></i>
3152 <i class="storeengine-icon storeengine-icon--star-fill"></i>
3153 <i class="storeengine-icon storeengine-icon--star-fill"></i>
3154 <i class="storeengine-icon storeengine-icon--star-fill"></i>
3155 </div>
3156 <div><a href="#">(<span>1</span> customer review)</a></div>
3157 </div>
3158 <?php
3159 }
3160 }
3161
3162 if ( ! function_exists( 'storeengine_review_lists' ) ) {
3163 function storeengine_review_lists( $comment ) {
3164 Template::get_template( 'single-product/review.php', [ 'comment' => $comment ] );
3165 }
3166 }
3167
3168 if ( ! function_exists( 'storeengine_comment_reform' ) ) {
3169 /**
3170 * Comment Message Box
3171 */
3172 function storeengine_comment_reform( $arg ) {
3173 $arg['title_reply'] = esc_html__( 'Post your Comment About This Product', 'storeengine' );
3174
3175 return $arg;
3176 }
3177
3178 add_filter( 'comment_form_defaults', 'storeengine_comment_reform' );
3179 }
3180
3181 if ( ! function_exists( 'storeengine_time_ago_comment' ) ) {
3182 function storeengine_time_ago_comment( $comment_id = null ): string {
3183 if ( is_null( $comment_id ) ) {
3184 $comment_id = get_comment_ID(); // Get the current comment's ID if not provided
3185 }
3186
3187 $comment_time = get_comment_date( 'U', $comment_id ); // Get the comment's timestamp
3188 $time_diff = human_time_diff( $comment_time, current_time( 'timestamp' ) ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
3189
3190 /* translators: %s: Human-readable time difference. */
3191 return sprintf( esc_html__( '%s ago', 'storeengine' ), $time_diff );
3192 }
3193 }
3194
3195 if ( ! function_exists( 'storeengine_comments' ) ) {
3196 function storeengine_comments( $comment, $args, $depth ) {
3197 $GLOBALS['comment'] = $comment; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
3198
3199 $commenter = wp_get_current_commenter();
3200 $show_pending_links = ! empty( $commenter['comment_author'] );
3201
3202 if ( $commenter['comment_author_email'] ) {
3203 $moderation_note = __( 'Your comment is awaiting moderation.', 'storeengine' );
3204 } else {
3205 $moderation_note = __( 'Your comment is awaiting moderation. This is a preview; your comment will be visible after it has been approved.', 'storeengine' );
3206 }
3207
3208 $classes = 'storeengine-comment';
3209
3210 if ( ! empty( $args['has_children'] ) ) {
3211 $classes .= ' storeengine-comment--parent parent';
3212 }
3213
3214 // @XXX DO NOT CLOSE THE `LI` TAGS. THE COMMENT WALKER CLASS CLOSES IT.
3215 if ( ( 'pingback' === $comment->comment_type || 'trackback' === $comment->comment_type ) && $args['short_ping'] ) : ?>
3216 <li id="comment-<?php comment_ID(); ?>" <?php comment_class( 'storeengine-' . $comment->comment_type, $comment ); ?>>
3217 <div class="comment-body">
3218 <?php esc_html_e( 'Pingback:', 'storeengine' ); ?><?php comment_author_link(); ?><?php edit_comment_link( esc_attr__( 'Edit', 'storeengine' ), '<span class="edit-link">', '</span>' ); ?>
3219 </div>
3220 <?php else : ?>
3221 <li id="comment-<?php comment_ID(); ?>" <?php comment_class( $classes, $comment ); ?>>
3222 <article id="div-comment-<?php comment_ID(); ?>" class="comment-body storeengine-row storeengine-relative">
3223 <div class="storeengine-col-2 storeengine-col-md-1 storeengine-comment-contain-img">
3224 <?php echo get_avatar( $comment, 64, '', get_comment_author( $comment ) ); // 50 is the size of the avatar ?>
3225 </div>
3226 <div class="storeengine-col-10 storeengine-col-md-11">
3227 <div class="comment-header">
3228 <div class="comment-author vcard">
3229 <?php
3230 $comment_author = get_comment_author_link( $comment );
3231
3232 if ( '0' === $comment->comment_approved && ! $show_pending_links ) {
3233 $comment_author = get_comment_author( $comment );
3234 }
3235
3236 echo wp_kses_post(
3237 sprintf(
3238 /* translators: %s: Comment author link. */
3239 __( '%s <span class="says">says:</span>', 'storeengine' ),
3240 sprintf( '<b class="comment-author-title fn">%s</b>', $comment_author )
3241 )
3242 );
3243 ?>
3244 </div>
3245 <div class="comment-metadata">
3246 <?php
3247 printf(
3248 '<a class="storeengine-comment-time" href="%s"><time class="moment-skip" datetime="%s">%s</time></a>',
3249 esc_url( get_comment_link( $comment, $args ) ),
3250 esc_attr( get_comment_time( 'c' ) ),
3251 esc_html( storeengine_time_ago_comment( $comment ) )
3252 );
3253 edit_comment_link( __( 'Edit', 'storeengine' ), ' <span class="edit-link">', '</span>' );
3254 comment_reply_link( array_merge( $args, array(
3255 'add_below' => 'div-comment',
3256 'depth' => $depth,
3257 'max_depth' => $args['max_depth'],
3258 'before' => '<span class="storeengine_replay_text_link">',
3259 'after' => '</span>',
3260 ) ) );
3261 ?>
3262 </div>
3263 </div>
3264 <!--
3265 <div class="storeengine-flex storeengine-flex-gap-4">
3266 <a class="storeengine-comment-time" href="<?php echo esc_url( get_comment_link( $comment->comment_ID ) ); ?>">
3267 <time datetime="<?php comment_time( 'c' ); ?>">
3268 </time>
3269 </a>
3270 </div>
3271 -->
3272 <div class="comment-content storeengine-comment--content">
3273 <?php if ( '0' === $comment->comment_approved ) : ?>
3274 <p><em class="comment-awaiting-moderation"><?php echo esc_html( $moderation_note ); ?></em></p>
3275 <?php endif; ?>
3276 <?php comment_text(); ?>
3277 </div>
3278 </div>
3279 </article>
3280 <?php
3281 endif;
3282 }
3283 }
3284
3285 // Dashboard Downloads Pagination
3286 if ( ! function_exists( 'storeengine_dashboard_downloads_pagination' ) ) {
3287 function storeengine_dashboard_downloads_pagination( $downloadable_permissions ) {
3288 Template::get_template(
3289 'frontend-dashboard/partials/downloads-pagination.php',
3290 array( 'downloadable_permissions' => $downloadable_permissions )
3291 );
3292 }
3293 }
3294
3295 if ( ! function_exists( 'storeengine_get_cart_item_data' ) ) {
3296 function storeengine_get_cart_item_data( $cart_item ): array {
3297 $item_data = [];
3298
3299 foreach ( $cart_item->variation ?? [] as $taxonomy => $value ) {
3300 $taxonomy = get_taxonomy( $taxonomy );
3301 if ( $taxonomy instanceof WP_Taxonomy ) {
3302 $term = get_term_by( 'slug', $value, $taxonomy->name );
3303 if ( $term instanceof WP_Term ) {
3304 $value = $term->name;
3305 }
3306 }
3307 $item_data[] = [
3308 // `$taxonomy->label` is the admin plural — register_taxonomy()
3309 // sets labels['name'] to "Product %s" and WP then copies that
3310 // over ->label, so it reads "Product Color" here. The singular
3311 // is the attribute's own name ("Color"), which is what belongs
3312 // on a customer-facing "Color: Red" line.
3313 'label' => $taxonomy instanceof WP_Taxonomy
3314 ? ( $taxonomy->labels->singular_name ?: $taxonomy->label )
3315 : $taxonomy,
3316 'value' => $value,
3317 ];
3318 }
3319
3320 return apply_filters( 'storeengine/get_cart_item_data', $item_data, $cart_item );
3321 }
3322 }
3323
3324 if ( ! function_exists( 'storeengine_display_item_meta' ) ) {
3325 /**
3326 * Display item meta data.
3327 *
3328 * @param OrderItemProduct $item Order Item.
3329 * @param array $args Arguments.
3330 *
3331 * @return string|void
3332 * @since 0.0.6-beta
3333 */
3334 function storeengine_display_item_meta( OrderItemProduct $item, array $args = [] ) {
3335 $strings = [];
3336 $html = '';
3337 $args = wp_parse_args(
3338 $args,
3339 [
3340 'before' => '<ul class="storeengine-order-item-meta"><li>',
3341 'after' => '</li></ul>',
3342 'separator' => '</li><li>',
3343 'echo' => true,
3344 'autop' => true,
3345 'label_before' => '<strong class="storeengine-order-item-meta-label">',
3346 'label_after' => ':</strong> ',
3347 ]
3348 );
3349
3350 foreach ( $item->get_all_formatted_metadata() as $metadata ) {
3351 $value = wp_kses_post( make_clickable( trim( $metadata['display_value'] ) ) );
3352 $strings[] = $args['label_before'] . wp_kses_post( $metadata['display_key'] ) . $args['label_after'] . $value;
3353 }
3354
3355 if ( $strings ) {
3356 $html = $args['before'] . implode( $args['separator'], $strings ) . $args['after'];
3357 }
3358
3359 $html = apply_filters( 'storeengine/display_item_meta', $html, $item, $args );
3360
3361 if ( $args['echo'] ) {
3362 echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
3363 } else {
3364 return $html;
3365 }
3366 }
3367 }
3368
3369 if ( ! function_exists( 'storeengine_single_product_display_price' ) ) {
3370 /**
3371 * @param Price $price_item
3372 *
3373 * @return void
3374 * @deprecated
3375 */
3376 function storeengine_single_product_display_price( Price $price_item ) {
3377 if ( empty( $price_item->get_price() ) ) {
3378 return;
3379 }
3380 ?>
3381 <span class="has-storeengine-single__sell-amount storeengine-product__price-amount storeengine-mr-1">
3382 <?php
3383 if ( 'subscription' === $price_item->get_type() ) {
3384 echo wp_kses_post( $price_item->get_formatted_payment_duration() );
3385 if ( $price_item->is_trial() ) {
3386 echo '<br>';
3387 echo wp_kses_post( 'Starting in ' . $price_item->get_trial_days() . ' days' );
3388 }
3389
3390 if ( $price_item->is_setup_fee() ) {
3391 echo '<br>';
3392 echo wp_kses_post( Formatting::price( $price_item->get_setup_fee_price() ) . ' ' . $price_item->get_setup_fee_name() );
3393 }
3394 } else {
3395 echo wp_kses_post( Formatting::price( $price_item->get_price() ) );
3396 }
3397 ?>
3398 </span>
3399 <?php
3400 }
3401 }
3402
3403 // Single Like and dislike
3404 if ( ! function_exists( 'storeengine_single_like_dislike' ) ) {
3405 function storeengine_single_like_dislike() {
3406 Template::get_template( 'single-product/like.php' );
3407 }
3408 }
3409
3410 // Single
3411 if ( ! function_exists( 'storeengine_single_filter' ) ) {
3412 function storeengine_single_filter() {
3413 Template::get_template( 'single-product/filter.php' );
3414 }
3415 }
3416
3417 /**
3418 * Get account formatted address.
3419 *
3420 * @param string $address_type Type of address; 'billing' or 'shipping'.
3421 * @param int $customer_id Customer ID. Defaults to 0.
3422 *
3423 * @return string
3424 */
3425 function storeengine_get_dashboard_formatted_address( string $address_type = 'billing', int $customer_id = 0 ): string {
3426 $getter = "get_{$address_type}";
3427 $address = [];
3428
3429 if ( 0 === $customer_id ) {
3430 $customer_id = get_current_user_id();
3431 }
3432
3433 $customer = new \StoreEngine\Classes\Customer( $customer_id );
3434
3435 if ( is_callable( array( $customer, $getter ) ) ) {
3436 $address = $customer->$getter();
3437 unset( $address['email'], $address['tel'] );
3438 }
3439
3440 return Countries::init()->get_formatted_address( apply_filters( 'storeengine/frontend/dashboard_formatted_address', $address, $customer->get_id(), $address_type ) );
3441 }
3442
3443 if ( ! function_exists( 'storeengine_form_field' ) ) {
3444
3445 /**
3446 * Outputs a checkout/address form field.
3447 *
3448 * @param string $key Key.
3449 * @param array|string $args Arguments.
3450 * @param ?string $value (default: null).
3451 *
3452 * @return string|void
3453 * @noinspection HtmlUnknownAttribute
3454 */
3455 function storeengine_form_field( string $key, $args, ?string $value = null ) {
3456 $defaults = [
3457 'type' => 'text',
3458 'label' => '',
3459 'description' => '',
3460 'placeholder' => '',
3461 'maxlength' => false,
3462 'minlength' => false,
3463 'required' => false,
3464 'autocomplete' => false,
3465 'id' => $key,
3466 'class' => [],
3467 'label_class' => [],
3468 'input_class' => [],
3469 'return' => false,
3470 'options' => [],
3471 'custom_attributes' => [],
3472 'validate' => [],
3473 'default' => '',
3474 'autofocus' => '',
3475 'priority' => '',
3476 'unchecked_value' => null,
3477 'checked_value' => '1',
3478 ];
3479
3480 $args = wp_parse_args( $args, $defaults );
3481 $args = apply_filters( 'storeengine/frontend/form_field_args', $args, $key, $value );
3482
3483 if ( is_string( $args['class'] ) ) {
3484 $args['class'] = array( $args['class'] );
3485 }
3486
3487 if ( is_string( $args['label_class'] ) ) {
3488 $args['label_class'] = array( $args['label_class'] );
3489 }
3490
3491 if ( is_null( $value ) ) {
3492 $value = $args['default'];
3493 }
3494
3495 // Custom attribute handling.
3496 $custom_attributes = [];
3497 $args['custom_attributes'] = array_filter( (array) $args['custom_attributes'], 'strlen' );
3498
3499 if ( $args['required'] ) {
3500 // hidden inputs are the only kind of inputs that don't need an `aria-required` attribute.
3501 // checkboxes apply the `custom_attributes` to the label - we need to apply the attribute on the input itself, instead.
3502 if ( ! in_array( $args['type'], [ 'hidden', 'checkbox' ], true ) ) {
3503 $args['custom_attributes']['aria-required'] = 'true';
3504 $args['label_class'][] = 'required_field';
3505 }
3506
3507 $args['class'][] = 'validate-required';
3508 $required_indicator = '&nbsp;<abbr class="storeengine-required" title="' . esc_attr__( 'Required', 'storeengine' ) . '" aria-hidden="true">*</abbr>';
3509 } else {
3510 $required_indicator = '&nbsp;<span class="storeengine-optional screen-reader-text">(' . esc_html__( 'optional', 'storeengine' ) . ')</span>';
3511 }
3512
3513 if ( $args['maxlength'] ) {
3514 $args['custom_attributes']['maxlength'] = absint( $args['maxlength'] );
3515 }
3516
3517 if ( $args['minlength'] ) {
3518 $args['custom_attributes']['minlength'] = absint( $args['minlength'] );
3519 }
3520
3521 if ( ! empty( $args['autocomplete'] ) ) {
3522 $args['custom_attributes']['autocomplete'] = $args['autocomplete'];
3523 }
3524
3525 if ( true === $args['autofocus'] ) {
3526 $args['custom_attributes']['autofocus'] = 'autofocus';
3527 }
3528
3529 if ( $args['description'] ) {
3530 $args['custom_attributes']['aria-describedby'] = $args['id'] . '-description';
3531 }
3532
3533 if ( ! empty( $args['custom_attributes'] ) && is_array( $args['custom_attributes'] ) ) {
3534 foreach ( $args['custom_attributes'] as $attribute => $attribute_value ) {
3535 $custom_attributes[] = esc_attr( $attribute ) . '="' . esc_attr( $attribute_value ) . '"';
3536 }
3537 }
3538
3539 if ( ! empty( $args['validate'] ) ) {
3540 foreach ( $args['validate'] as $validate ) {
3541 $args['class'][] = 'validate-' . $validate;
3542 }
3543 }
3544
3545 $field = '';
3546 $label_id = $args['id'];
3547 $sort = $args['priority'] ? $args['priority'] : '';
3548 $field_container = '<p class="storeengine-form-field %1$s" id="%2$s" data-priority="' . esc_attr( $sort ) . '">%3$s</p>';
3549
3550 /** @noinspection HtmlUnknownAttribute */
3551 switch ( $args['type'] ) {
3552 case 'country':
3553 $countries = 'shipping_country' === $key ? Countries::init()->get_shipping_countries() : Countries::init()->get_allowed_countries();
3554
3555 if ( 1 === count( $countries ) ) {
3556 $field .= '<strong>' . current( array_values( $countries ) ) . '</strong>';
3557
3558 $field .= '<input type="hidden" name="' . esc_attr( $key ) . '" id="' . esc_attr( $args['id'] ) . '" value="' . current( array_keys( $countries ) ) . '" ' . implode( ' ', $custom_attributes ) . ' class="country_to_state" readonly="readonly" />';
3559 } else {
3560 $data_label = ! empty( $args['label'] ) ? 'data-label="' . esc_attr( $args['label'] ) . '"' : '';
3561
3562 $field = '<select name="' . esc_attr( $key ) . '" id="' . esc_attr( $args['id'] ) . '" class="store-form-control country_to_state country_select ' . esc_attr( implode( ' ', $args['input_class'] ) ) . '" ' . implode( ' ', $custom_attributes ) . ' data-placeholder="' . esc_attr( $args['placeholder'] ? $args['placeholder'] : esc_attr__( 'Select a country / region&hellip;', 'storeengine' ) ) . '" ' . $data_label . '><option value="">' . esc_html__( 'Select a country / region&hellip;', 'storeengine' ) . '</option>';
3563
3564 foreach ( $countries as $ckey => $cvalue ) {
3565 $field .= '<option value="' . esc_attr( $ckey ) . '" ' . selected( $value, $ckey, false ) . '>' . esc_html( $cvalue ) . '</option>';
3566 }
3567
3568 $field .= $required_indicator . '</select>';
3569
3570 $field .= '<noscript><button type="submit" name="storeengine_checkout_update_totals" value="' . esc_attr__( 'Update country / region', 'storeengine' ) . '">' . esc_html__( 'Update country / region', 'storeengine' ) . '</button></noscript>';
3571 }
3572
3573 break;
3574 case 'state':
3575 /* Get country this state field is representing */
3576 $for_country = null;
3577
3578 if ( isset( $args['country'] ) ) {
3579 $for_country = $args['country'];
3580 } else {
3581 $index = 'billing_state' === $key ? 'billing_country' : 'shipping_country';
3582 if ( ! empty( $_POST[ $index ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checking if country field is selected.
3583 $for_country = sanitize_text_field( wp_unslash( $_POST[ $index ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Setting selected country code for retrieving states for the country.
3584 } else {
3585 if ( storeengine()->get_customer() && storeengine()->get_customer()->get_id() ) {
3586 $for_country = storeengine()->get_customer()->{'get_' . $index}();
3587 if ( '' === $for_country ) {
3588 $for_country = null;
3589 }
3590 }
3591 }
3592 }
3593
3594 $states = Countries::init()->get_states( $for_country );
3595
3596 if ( is_array( $states ) && empty( $states ) ) {
3597 $field_container = '<p class="storeengine-form-field %1$s" id="%2$s" style="display: none">%3$s</p>';
3598
3599 $field .= '<input type="hidden" class="hidden" name="' . esc_attr( $key ) . '" id="' . esc_attr( $args['id'] ) . '" value="" ' . implode( ' ', $custom_attributes ) . ' placeholder="' . esc_attr( $args['placeholder'] ) . '" readonly="readonly" data-input-classes="' . esc_attr( implode( ' ', $args['input_class'] ) ) . '"/>';
3600 } elseif ( ! is_null( $for_country ) && is_array( $states ) ) {
3601 $data_label = ! empty( $args['label'] ) ? 'data-label="' . esc_attr( $args['label'] ) . '"' : '';
3602
3603 $field .= '<select name="' . esc_attr( $key ) . '" id="' . esc_attr( $args['id'] ) . '" class="store-form-control state_select ' . esc_attr( implode( ' ', $args['input_class'] ) ) . '" ' . implode( ' ', $custom_attributes ) . ' data-placeholder="' . esc_attr( $args['placeholder'] ? $args['placeholder'] : esc_html__( 'Select an option&hellip;', 'storeengine' ) ) . '" data-input-classes="' . esc_attr( implode( ' ', $args['input_class'] ) ) . '" ' . $data_label . '>
3604 <option value="">' . esc_html__( 'Select an option&hellip;', 'storeengine' ) . '</option>';
3605
3606 foreach ( $states as $ckey => $cvalue ) {
3607 $field .= '<option value="' . esc_attr( $ckey ) . '" ' . selected( $value, $ckey, false ) . '>' . esc_html( $cvalue ) . '</option>';
3608 }
3609
3610 $field .= '</select>';
3611 } else {
3612 $field .= '<input type="text" class="store-form-control ' . esc_attr( implode( ' ', $args['input_class'] ) ) . '" value="' . esc_attr( $value ) . '" placeholder="' . esc_attr( $args['placeholder'] ) . '" name="' . esc_attr( $key ) . '" id="' . esc_attr( $args['id'] ) . '" ' . implode( ' ', $custom_attributes ) . ' data-input-classes="' . esc_attr( implode( ' ', $args['input_class'] ) ) . '"/>';
3613 }
3614
3615 break;
3616 case 'textarea':
3617 $field .= '<textarea name="' . esc_attr( $key ) . '" class="store-form-control ' . esc_attr( implode( ' ', $args['input_class'] ) ) . '" id="' . esc_attr( $args['id'] ) . '" placeholder="' . esc_attr( $args['placeholder'] ) . '" ' . ( empty( $args['custom_attributes']['rows'] ) ? ' rows="2"' : '' ) . ( empty( $args['custom_attributes']['cols'] ) ? ' cols="5"' : '' ) . implode( ' ', $custom_attributes ) . '>' . esc_textarea( $value ) . '</textarea>';
3618
3619 break;
3620 case 'checkbox':
3621 $field = '<label class="checkbox ' . esc_attr( implode( ' ', $args['label_class'] ) ) . '" ' . implode( ' ', $custom_attributes ) . '>';
3622
3623 // Output a hidden field so a value is POSTed if the box is not checked.
3624 if ( ! is_null( $args['unchecked_value'] ) ) {
3625 $field .= sprintf( '<input type="hidden" name="%1$s" value="%2$s" />', esc_attr( $key ), esc_attr( $args['unchecked_value'] ) );
3626 }
3627
3628 $field .= sprintf(
3629 '<input type="checkbox" name="%1$s" id="%2$s" value="%3$s" class="%4$s" %5$s%6$s /> %7$s',
3630 esc_attr( $key ),
3631 esc_attr( $args['id'] ),
3632 esc_attr( $args['checked_value'] ),
3633 esc_attr( 'input-checkbox ' . implode( ' ', $args['input_class'] ) ),
3634 checked( $value, $args['checked_value'], false ),
3635 $args['required'] ? ' aria-required="true"' : '',
3636 wp_kses_post( $args['label'] )
3637 );
3638
3639 $field .= $required_indicator . '</label>';
3640
3641 break;
3642 case 'text':
3643 case 'password':
3644 case 'datetime':
3645 case 'datetime-local':
3646 case 'date':
3647 case 'month':
3648 case 'time':
3649 case 'week':
3650 case 'number':
3651 case 'email':
3652 case 'url':
3653 case 'tel':
3654 $field .= '<input type="' . esc_attr( $args['type'] ) . '" class="store-form-control ' . esc_attr( implode( ' ', $args['input_class'] ) ) . '" name="' . esc_attr( $key ) . '" id="' . esc_attr( $args['id'] ) . '" placeholder="' . esc_attr( $args['placeholder'] ) . '" value="' . esc_attr( $value ) . '" ' . implode( ' ', $custom_attributes ) . ' />';
3655
3656 break;
3657 case 'hidden':
3658 $field .= '<input type="' . esc_attr( $args['type'] ) . '" class="hidden storeengine-hidden ' . esc_attr( implode( ' ', $args['input_class'] ) ) . '" name="' . esc_attr( $key ) . '" id="' . esc_attr( $args['id'] ) . '" value="' . esc_attr( $value ) . '" ' . implode( ' ', $custom_attributes ) . ' />';
3659
3660 break;
3661 case 'select':
3662 $options = '';
3663
3664 if ( ! empty( $args['options'] ) ) {
3665 foreach ( $args['options'] as $option_key => $option_text ) {
3666 if ( '' === $option_key ) {
3667 // A blank option is the proper way to set a placeholder. If one is supplied we make sure the placeholder key is set for the enhanced select field.
3668 if ( empty( $args['placeholder'] ) ) {
3669 $args['placeholder'] = $option_text ? $option_text : __( 'Choose an option', 'storeengine' );
3670 }
3671 $custom_attributes[] = 'data-allow_clear="true"';
3672 }
3673 $options .= '<option value="' . esc_attr( $option_key ) . '" ' . selected( $value, $option_key, false ) . '>' . esc_html( $option_text ) . '</option>';
3674 }
3675
3676 $field .= '<select name="' . esc_attr( $key ) . '" id="' . esc_attr( $args['id'] ) . '" class="store-form-control ' . esc_attr( implode( ' ', $args['input_class'] ) ) . '" ' . implode( ' ', $custom_attributes ) . ' data-placeholder="' . esc_attr( $args['placeholder'] ) . '">
3677 ' . $options . '
3678 </select>';
3679 }
3680
3681 break;
3682 case 'radio':
3683 $label_id .= '_' . current( array_keys( $args['options'] ) );
3684
3685 if ( ! empty( $args['options'] ) ) {
3686 foreach ( $args['options'] as $option_key => $option_text ) {
3687 $field .= '<input type="radio" class="store-form-control-radio storeengine-radio ' . esc_attr( implode( ' ', $args['input_class'] ) ) . '" value="' . esc_attr( $option_key ) . '" name="' . esc_attr( $key ) . '" ' . implode( ' ', $custom_attributes ) . ' id="' . esc_attr( $args['id'] ) . '_' . esc_attr( $option_key ) . '"' . checked( $value, $option_key, false ) . ' />';
3688 $field .= '<label for="' . esc_attr( $args['id'] ) . '_' . esc_attr( $option_key ) . '" class="radio ' . implode( ' ', $args['label_class'] ) . '">' . esc_html( $option_text ) . $required_indicator . '</label>';
3689 }
3690 }
3691
3692 break;
3693 }
3694
3695 if ( ! empty( $field ) ) {
3696 $field_html = '';
3697
3698 if ( $args['label'] && 'checkbox' !== $args['type'] ) {
3699 $field_html .= '<label for="' . esc_attr( $label_id ) . '" class="store-form-control-checkbox ' . esc_attr( implode( ' ', $args['label_class'] ) ) . '">' . wp_kses_post( $args['label'] ) . $required_indicator . '</label>';
3700 }
3701
3702 $field_html .= '<span class="storeengine-input-wrapper">' . $field;
3703
3704 if ( $args['description'] ) {
3705 $field_html .= '<span class="storeengine-description" id="' . esc_attr( $args['id'] ) . '-description" aria-hidden="true">' . wp_kses_post( $args['description'] ) . '</span>';
3706 }
3707
3708 $field_html .= '</span>';
3709
3710 $container_class = esc_attr( implode( ' ', $args['class'] ) );
3711 $container_id = esc_attr( $args['id'] ) . '_field';
3712 $field = sprintf( $field_container, $container_class, $container_id, $field_html );
3713 }
3714
3715 /**
3716 * Filter by type.
3717 */
3718 $field = apply_filters( 'storeengine/frontend/form_field_' . $args['type'], $field, $key, $args, $value );
3719
3720 /**
3721 * General filter on form fields.
3722 */
3723 $field = apply_filters( 'storeengine/frontend/form_field', $field, $key, $args, $value );
3724
3725 if ( $args['return'] ) {
3726 return $field;
3727 } else {
3728 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
3729 echo $field;
3730 }
3731 }
3732 }
3733
3734 if ( ! function_exists( 'storeengine_render_upsell_items' ) ) {
3735 function storeengine_render_upsell_items(): void {
3736 $id = is_int( $temp_id = get_the_ID() ) ? $temp_id : null;
3737 $ids = ! is_null( $id ) ? get_post_meta( $id, '_storeengine_upsell_ids', true ) : null;
3738 $ids = is_array( $ids ) ? $ids : null;
3739
3740 if ( empty( $ids ) ) {
3741 return;
3742 }
3743
3744 $args = array(
3745 'post_type' => Helper::PRODUCT_POST_TYPE,
3746 'post_status' => 'publish',
3747 'posts_per_page' => - 1,
3748 'fields' => 'ids',
3749 'post__in' => $ids,
3750 );
3751
3752 $products_per_row = Helper::get_settings( 'product_archive_products_per_row', (object) [
3753 'desktop' => 3,
3754 'tablet' => 2,
3755 'mobile' => 1,
3756 ] );
3757
3758 $grid_class = Helper::get_responsive_column( array(
3759 'desktop' => (int) $products_per_row->desktop ?? 3,
3760 'tablet' => 2,
3761 'mobile' => 1,
3762 ) );
3763
3764 wp_reset_postdata();
3765
3766 // phpcs:ignore WordPress.WP.DiscouragedFunctions.query_posts_query_posts
3767 query_posts( apply_filters( 'storeengine/products/cross_sell/args', $args ) );
3768 Template::get_template( 'single-product/upsell.php', array( 'grid_class' => $grid_class ) );
3769 wp_reset_postdata();
3770 }
3771 }
3772
3773 if ( ! function_exists( 'storeengine_render_crosssell_items' ) ) {
3774 function storeengine_render_crosssell_items(): void {
3775 // Get product IDs from cart
3776 $cart_items = storeengine_cart()->get_cart_items();
3777 $product_ids = array_unique(
3778 array_values(
3779 array_map(
3780 fn( $cart_item ) => (int) $cart_item->product_id,
3781 $cart_items
3782 )
3783 )
3784 );
3785
3786 // Collect all cross-sell IDs
3787 $cross_sell_ids = [];
3788 foreach ( $product_ids as $pid ) {
3789 $cr_ids = get_post_meta( $pid, '_storeengine_crosssell_ids', true );
3790 if ( is_array( $cr_ids ) ) {
3791 $cross_sell_ids = array_merge( $cross_sell_ids, $cr_ids );
3792 }
3793 }
3794 $cross_sell_ids = array_unique( $cross_sell_ids );
3795
3796 if ( empty( $cross_sell_ids ) ) {
3797 return;
3798 }
3799
3800 $args = array(
3801 'post_type' => Helper::PRODUCT_POST_TYPE,
3802 'post_status' => 'publish',
3803 'posts_per_page' => - 1,
3804 'fields' => 'ids',
3805 'post__in' => $cross_sell_ids,
3806 );
3807
3808 $products_per_row = Helper::get_settings( 'product_archive_products_per_row', (object) [
3809 'desktop' => 3,
3810 'tablet' => 2,
3811 'mobile' => 2,
3812 ] );
3813
3814 $grid_class = Helper::get_responsive_column( array(
3815 'desktop' => (int) $products_per_row->desktop ?? 3,
3816 'tablet' => 2,
3817 'mobile' => 2,
3818 ) );
3819
3820 if ( wp_is_rest_endpoint() ) {
3821 // For mini-cart.
3822 $grid_class = Helper::get_responsive_column( array(
3823 'desktop' => 2,
3824 'tablet' => 2,
3825 'mobile' => 2,
3826 ) );
3827 }
3828
3829 wp_reset_postdata();
3830
3831 // phpcs:ignore WordPress.WP.DiscouragedFunctions.query_posts_query_posts
3832 query_posts( apply_filters( 'storeengine/products/cross_sell/args', $args ) );
3833 Template::get_template( 'cart/cross-sell.php', array( 'grid_class' => $grid_class ) );
3834 wp_reset_postdata();
3835 }
3836 }
3837
3838 /**
3839 * Get account orders actions.
3840 *
3841 * @param Order $order Order instance or ID.
3842 *
3843 * @return array
3844 */
3845 function storeengine_get_account_orders_actions( Order $order ): array {
3846 $actions = [
3847 'view' => [
3848 'classes' => 'storeengine-btn--preset-gray',
3849 'url' => $order->get_view_order_url(),
3850 'icon' => 'eye',
3851 'name' => __( 'Details', 'storeengine' ),
3852 /* translators: %s: order number */
3853 'aria-label' => sprintf( __( 'View order %s', 'storeengine' ), $order->get_order_number() ),
3854 ],
3855 'pay' => [
3856 'classes' => 'storeengine-btn--preset-success',
3857 'url' => $order->get_checkout_payment_url(),
3858 // A failed order is a retry, not a first payment — label it as such.
3859 'name' => OrderStatus::PAYMENT_FAILED === $order->get_status() ? __( 'Retry payment', 'storeengine' ) : __( 'Pay now', 'storeengine' ),
3860 /* translators: %s: order number */
3861 'aria-label' => sprintf( __( 'Pay for order %s', 'storeengine' ), $order->get_order_number() ),
3862 ],
3863 'cancel' => [
3864 'classes' => 'storeengine-btn--preset-red',
3865 'url' => $order->get_cancel_order_url( Helper::get_dashboard_url() ),
3866 'name' => __( 'Cancel Order', 'storeengine' ),
3867 /* translators: %s: order number */
3868 'aria-label' => sprintf( __( 'Cancel order %s', 'storeengine' ), $order->get_order_number() ),
3869 'data' => [
3870 'confirm-action' => __( 'Are you sure you want to cancel your order?', 'storeengine' ),
3871 ],
3872 ],
3873 ];
3874
3875 if ( ! $order->needs_payment() ) {
3876 unset( $actions['pay'] );
3877 }
3878
3879 /**
3880 * Filters the valid order statuses for cancel action.
3881 *
3882 * @param array $statuses_for_cancel Array of valid order statuses for cancel action.
3883 * @param Order $order Order instance.
3884 */
3885 $statuses_for_cancel = apply_filters( 'storeengine/order/valid_statuses_for_cancel', [
3886 OrderStatus::PAYMENT_PENDING,
3887 OrderStatus::PAYMENT_FAILED,
3888 ], $order );
3889
3890 if ( ! in_array( $order->get_status(), $statuses_for_cancel, true ) ) {
3891 unset( $actions['cancel'] );
3892 }
3893
3894 return apply_filters( 'storeengine/dashboard/order/actions', $actions, $order );
3895 }
3896
3897 /**
3898 * List of dashboard action button configurations.
3899 *
3900 * @param array<string, array{
3901 * url: string,
3902 * name?: string,
3903 * icon?: string,
3904 * data?: array<string, string>,
3905 * classes?: string,
3906 * target?: string,
3907 * styles?: string|array<string>|array<string,string>,
3908 * priority?: int,
3909 * }>&array $actions
3910 * @param string $type Context type (e.g. 'order', 'subscription').
3911 * @param ?string $menu_name Menu name for accessibility (E.g. "Order", "Payment method", etc).
3912 * @param bool $split Whether to split primary/secondary actions.
3913 *
3914 * @example
3915 * ```php
3916 * storeengine_render_dashboard_action_buttons( [
3917 * 'view' => [
3918 * 'classes' => 'storeengine-btn--preset-gray',
3919 * 'url' => 'https://example.com/order/123',
3920 * 'icon' => 'eye',
3921 * 'aria-label' => 'View Order 123',
3922 * ],
3923 * 'reactivate' => [
3924 * 'url' => 'https://example.com/order/delete/123',
3925 * 'name' => 'Delete',
3926 * 'aria-label' => 'Delete Order 123',
3927 * 'data' => [
3928 * 'confirm-action' => 'Are you sure?', // supported by js.
3929 * ],
3930 * ],
3931 * ] );
3932 * ```
3933 *
3934 * @return void
3935 */
3936 function storeengine_render_dashboard_action_buttons( array $actions, string $type = 'order', string $menu_name = null, bool $split = true ): void {
3937 if ( ! $actions ) {
3938 return;
3939 }
3940
3941 ArrayUtil::priority_sort( $actions );
3942
3943 $action = null;
3944
3945 if ( $split && $actions ) {
3946 $action = array_slice( $actions, 0, 1, true ); // Get the first key-value pair;
3947 $actions = array_slice( $actions, 1, null, true ); // Get the remaining key-value pairs;
3948 }
3949
3950 $id = wp_unique_id( $type . '-' );
3951 ?>
3952 <div class="storeengine-action-dropdown storeengine-actions--<?php echo esc_attr( $type ); ?>">
3953 <?php
3954 if ( $action ) {
3955 foreach ( $action as $key => $item ) { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
3956 storeengine_print_dropdown_action_button( $key, $item, $type );
3957 }
3958 }
3959 ?>
3960 <?php if ( ! empty( $actions ) ) {
3961 $menu_name = $menu_name ?? __( 'Dropdown', 'storeengine' );
3962 ?>
3963 <button id="<?php echo esc_attr( $id . '-menu-button' ); ?>" type="button" class="storeengine-btn storeengine-btn--preset-gray storeengine-action-dropdown--toggle" data-toggle="dropdown" aria-controls="<?php echo esc_attr( $id . '-menu' ); ?>" aria-haspopup="true" aria-expanded="false">
3964 <span class="screen-reader-text">
3965 <?php
3966 printf(
3967 // translators: %s. Toggle menu name screen-reader (accessibility).
3968 esc_html__( 'Toggle %s Actions', 'storeengine' ),
3969 esc_html( $menu_name )
3970 );
3971 ?>
3972 </span>
3973 <?php storeengine_render_icon( 'three-dots-menu' ); ?>
3974 </button>
3975 <div id="<?php echo esc_attr( $id . '-menu' ); ?>" class="storeengine-action-dropdown--menu" role="menu" aria-labelledby="<?php echo esc_attr( $id . '-menu-button' ); ?>">
3976 <?php
3977 foreach ( $actions as $key => $item ) { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
3978 storeengine_print_dropdown_action_button( $key, $item, $type );
3979 }
3980 ?>
3981 </div>
3982 <?php } ?>
3983 </div>
3984 <?php
3985 }
3986
3987 if ( ! function_exists( 'storeengine_render_icon' ) ) {
3988 /**
3989 * Render Icon.
3990 *
3991 * @param string $icon
3992 * @param string|string[] $args
3993 *
3994 * @return string|void
3995 */
3996 function storeengine_render_icon( string $icon, $args = '' ) {
3997 $args = wp_parse_args( $args, [
3998 'return' => false,
3999 'classes' => '',
4000 'aria-hidden' => true,
4001 ] );
4002
4003 $return = (bool) $args['return'];
4004 $classname = 'storeengine-icon storeengine-icon--' . $icon;
4005
4006 if ( $args['classes'] ) {
4007 $classname .= ' ' . $args['classes'];
4008 }
4009
4010 unset( $args['return'], $args['classes'] );
4011
4012 if (
4013 ! empty( $args['title'] ) ||
4014 ! empty( $args['aria-label'] ) ||
4015 ! empty( $args['aria-labelledby'] ) ||
4016 ! empty( $args['aria-description'] ) ||
4017 ! empty( $args['aria-describedby'] ) ||
4018 ! $args['aria-hidden']
4019 ) {
4020 unset( $args['aria-hidden'] );
4021 }
4022
4023 $attrs = [];
4024
4025 foreach ( $args as $k => $v ) {
4026 if ( is_bool( $v ) ) {
4027 $v = $v ? 'true' : 'false';
4028 }
4029
4030 $attrs[] = esc_attr( $k ) . '="' . esc_attr( $v ) . '"';
4031 }
4032
4033 $output = '<span class="' . esc_attr( $classname ) . '" ' . implode( ' ', $attrs ) . '></span>';
4034
4035 if ( $return ) {
4036 return $output;
4037 } else {
4038 echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
4039 }
4040 }
4041 }
4042
4043 function storeengine_print_dropdown_action_button( string $key, array $action, string $type = null ): void {
4044 if ( ! empty( $action['is-divider'] ) ) {
4045 ?>
4046 <div class="storeengine-action-dropdown--divider"></div>
4047 <?php
4048 return;
4049 }
4050
4051 $aria_label = $action['aria-label'] ?? $action['name'];
4052 $classes = [ 'storeengine-action-dropdown--item storeengine-btn storeengine-btn--link' ];
4053
4054 if ( ! empty( $action['classes'] ) && is_string( $action['classes'] ) ) {
4055 $classes[] = sanitize_html_class( $action['classes'] );
4056 }
4057
4058 $classes[] = 'storeengine-btn--' . sanitize_html_class( $key );
4059 $classes[] = $type ? $type . '-' . sanitize_html_class( $key ) : '';
4060 $classes = implode( ' ', array_filter( $classes ) );
4061
4062 // Prepare escaped attribute string.
4063 $attributes = 'class="' . esc_attr( $classes ) . '"';
4064 $attributes .= ' href="' . esc_url( $action['url'] ) . '"';
4065 $attributes .= ' aria-label="' . esc_attr( $aria_label ) . '"';
4066
4067 if ( ! empty( $action['title'] ) ) {
4068 if ( true === $action['title'] ) {
4069 $title = $action['name'] ?? $action['aria-label'] ?? '';
4070 if ( $title ) {
4071 $attributes .= ' title="' . esc_attr( $title ) . '"';
4072 }
4073 } else {
4074 $attributes .= ' title="' . esc_attr( $action['title'] ) . '"';
4075 }
4076 }
4077
4078 if ( ! empty( $action['target'] ) && '_blank' === $action['target'] ) {
4079 $attributes .= ' target="_blank"';
4080 }
4081
4082 if ( ! empty( $action['data'] ) && is_array( $action['data'] ) ) {
4083 foreach ( $action['data'] as $key => $value ) {
4084 $attributes .= ' data-' . sanitize_title( $key ) . '="' . esc_attr( $value ) . '"';
4085 }
4086 unset( $action['data'] );
4087 }
4088
4089 if ( ! empty( $action['styles'] ) ) {
4090 $styles = $action['styles'];
4091 if ( is_array( $action['styles'] ) ) {
4092 if ( wp_is_numeric_array( $action['styles'] ) ) {
4093 $styles = implode( ';', $action['styles'] );
4094 } else {
4095 $styles = '';
4096 foreach ( $action['styles'] as $key => $value ) {
4097 $styles .= sanitize_key( $key ) . ':' . esc_attr( $value ) . ';';
4098 }
4099 }
4100 }
4101
4102 unset( $action['styles'] );
4103
4104 if ( $styles && is_string( $styles ) ) {
4105 $attributes .= ' style="' . $styles . '"';
4106 }
4107 }
4108
4109 ?>
4110 <a <?php echo $attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- attributes escaped above. ?>>
4111 <?php
4112 if ( ! empty( $action['icon'] ) ) {
4113 storeengine_render_icon( $action['icon'] );
4114 }
4115
4116 if ( ! empty( $action['name'] ) ) {
4117 echo esc_html( $action['name'] );
4118 }
4119 ?>
4120 </a>
4121 <?php
4122 }
4123
4124 if ( ! function_exists( 'storeengine_direct_checkout_url_params' ) ) {
4125 function storeengine_direct_checkout_url_params() {
4126 if ( ! Helper::is_checkout() || ! isset( $_GET['product_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- request from email or outside shared link.
4127 return;
4128 }
4129
4130 if ( ! Helper::cart() ) {
4131 return;
4132 }
4133
4134 $product_id = absint( wp_unslash( $_GET['product_id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- request from email or outside shared link.
4135 $price_id = isset( $_GET['price_id'] ) ? absint( wp_unslash( $_GET['price_id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- request from email or outside shared link.
4136 $quantity = isset( $_GET['qty'] ) ? absint( wp_unslash( $_GET['qty'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- request from email or outside shared link.
4137 $variation_id = isset( $_GET['variation_id'] ) ? absint( wp_unslash( $_GET['variation_id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- request from email or outside shared link.
4138
4139 if ( ! $price_id ) {
4140 $product = Helper::get_product( $product_id );
4141 if ( ! $product ) {
4142 return;
4143 }
4144
4145 $prices = $product->get_prices();
4146
4147 if ( empty( $prices ) ) {
4148 return;
4149 }
4150
4151 $price_id = reset( $prices )->get_id();
4152 }
4153
4154 Helper::cart()->clear_cart();
4155 Helper::cart()->add_product_to_cart( $price_id, $quantity, $variation_id );
4156
4157 $coupon_code = sanitize_text_field( wp_unslash( $_GET['coupon'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- request from email or outside shared link.
4158
4159 if ( ! empty( $coupon_code ) ) {
4160 Helper::cart()->apply_coupon( $coupon_code );
4161 }
4162
4163 Helper::cart()->store_on_database();
4164 }
4165 }
4166
4167 if ( ! function_exists( 'storeengine_print_time' ) ) {
4168 /**
4169 * Print datetime with time tag.
4170 *
4171 * @TODO add support for string|int datetime input.
4172 *
4173 * @param StoreengineDatetime|null $datetime
4174 * @param string|string[] $args
4175 *
4176 * @return void
4177 */
4178 function storeengine_print_time( ?StoreengineDatetime $datetime, $args = [] ) {
4179 if ( is_string( $args ) ) {
4180 $args = [ 'format' => $args ];
4181 }
4182
4183 $args = wp_parse_args( $args, [
4184 'format' => 'Y-m-d H:i:s',
4185 'fallback' => __( 'N/A', 'storeengine' ),
4186 'display_format' => _x(
4187 'MMM DD, YYYY',
4188 'Moment.js supported date format for user-dashboard order date',
4189 'storeengine'
4190 ),
4191 ] );
4192
4193 if ( ! $datetime && is_scalar( $args['fallback'] ) && ! empty( $args['fallback'] ) ) {
4194 echo esc_html( $args['fallback'] );
4195
4196 return;
4197 }
4198
4199 ?>
4200 <time datetime="<?php echo esc_attr( $datetime ); ?>" data-format="<?php echo esc_attr( $args['display_format'] ); ?>"><?php echo esc_html( $datetime->toLocal( $args['format'] ) ); ?></time>
4201 <?php
4202 }
4203 }
4204
4205 /**
4206 * Get the quantity input args.
4207 *
4208 * Note, when autocomplete is enabled in firefox, it will overwrite actual value with what user entered last. So we default to off.
4209 *
4210 * @param array $args The arguments.
4211 * @param Price|null $price
4212 * @param SimpleProduct|VariableProduct|null $product Product.
4213 *
4214 * @return array
4215 */
4216 function storeengine_get_quantity_args( array $args, ?Price $price = null, $product = null ): array {
4217 if ( is_null( $product ) && is_null( $price ) ) {
4218 $product = $GLOBALS['product'] ?? false;
4219 } elseif ( is_null( $product ) && $price ) {
4220 $product = $price->get_product();
4221 }
4222
4223 $defaults = [
4224 'id' => uniqid( 'quantity_' ),
4225 'name' => 'quantity',
4226 'pattern' => apply_filters( 'storeengine/quantity_input_pattern', '[0-9]*' ),
4227 'inputmode' => apply_filters( 'storeengine/quantity_input_inputmode', 'numeric' ), // Or decimal.
4228 'placeholder' => apply_filters( 'storeengine/quantity_input_placeholder', '', $price ),
4229 'autocomplete' => apply_filters( 'storeengine/quantity_input_autocomplete', 'off', $price ),
4230 'disabled' => false,
4231 'readonly' => false,
4232 ];
4233
4234 if ( $product ) {
4235 $defaults['max_qty'] = - 1; // $product->get_max_purchase_quantity();
4236 $defaults['min_qty'] = 1; // $product->get_min_purchase_quantity();
4237 $defaults['step'] = 1; // $product->get_purchase_quantity_step();
4238 $defaults['product_name'] = $product->get_name();
4239 } else {
4240 $defaults['max_qty'] = apply_filters( 'storeengine/quantity_input_max', - 1, $product, $price );
4241 $defaults['min_qty'] = apply_filters( 'storeengine/quantity_input_min', 1, $product, $price );
4242 $defaults['step'] = apply_filters( 'storeengine/quantity_input_step', 1, $product, $price );
4243 $defaults['product_name'] = '';
4244 }
4245
4246 // translators: %s. Product name.
4247 $args['label'] = ! empty( $args['product_name'] ) ? sprintf( __( '%s quantity', 'storeengine' ), wp_strip_all_tags( $args['product_name'] ) ) : __( 'Quantity', 'storeengine' );
4248
4249 /**
4250 * Filters all quantity input args.
4251 *
4252 * @param array $args The arguments.
4253 * @param SimpleProduct|VariableProduct|null $product The product.
4254 *
4255 * @return array
4256 * @since 1.6.9
4257 */
4258 $args = apply_filters( 'storeengine/quantity_input_args', wp_parse_args( $args, $defaults ), $product );
4259
4260 // Apply correction to min/max args - min cannot be lower than 0.
4261 $args['min_qty'] = max( $args['min_qty'], 0 );
4262 $args['max_qty'] = 0 < $args['max_qty'] ? $args['max_qty'] : '';
4263
4264 // Max cannot be lower than min if defined.
4265 if ( '' !== $args['max_qty'] && $args['max_qty'] < $args['min_qty'] ) {
4266 $args['max_qty'] = $args['min_qty'];
4267 }
4268
4269 // Default value should be the min value unless defined.
4270 $args['quantity'] = $args['quantity'] ?? $defaults['min_qty'];
4271
4272 /**
4273 * The input type attribute will generally be 'number' unless the quantity cannot be changed, in which case
4274 * it will be set to 'hidden'. An exception is made for non-hidden readonly inputs: in this case we set the
4275 * type to 'text' (this prevents most browsers from rendering increment/decrement arrows, which are useless
4276 * and/or confusing in this context).
4277 */
4278 $type = $args['min_qty'] > 0 && $args['min_qty'] === $args['max_qty'] ? 'hidden' : 'number';
4279 $type = $args['readonly'] && 'hidden' !== $type ? 'text' : $type;
4280
4281 // Store-wide "Hide quantity selector" setting — force quantity to 1 and render
4282 // a hidden input (the quantity-input template keeps submitting the value).
4283 if ( Helper::get_settings( 'hide_quantity_selector' ) ) {
4284 $args['min_qty'] = 1;
4285 $args['max_qty'] = '';
4286 $args['quantity'] = 1;
4287 $type = 'hidden';
4288 }
4289
4290 /**
4291 * Controls the quantity input's type attribute.
4292 *
4293 * @param string $type A valid input type attribute value, usually 'number' or 'hidden'.
4294 *
4295 * @since 1.6.9
4296 */
4297 $args['type'] = apply_filters( 'storeengine/quantity_input_type', $type );
4298
4299 return $args;
4300 }
4301
4302 if ( ! function_exists( 'storeengine_quantity_input' ) ) {
4303 /**
4304 * Output the quantity input for any form.
4305 *
4306 * @param array|string $args Args for the input.
4307 * @param Price|null $price
4308 * @param SimpleProduct|VariableProduct|null $product Product.
4309 * @param boolean $echo Whether to return or echo|string.
4310 *
4311 * @return string|void
4312 */
4313 function storeengine_quantity_input( $args = [], ?Price $price = null, $product = null, bool $echo = true ) {
4314 $args = storeengine_get_quantity_args( $args, $price, $product );
4315
4316 if ( ! $echo ) {
4317 ob_start();
4318 Template::get_template( 'global/quantity-input.php', $args );
4319
4320 return ob_get_clean();
4321 }
4322
4323 Template::get_template( 'global/quantity-input.php', $args );
4324 }
4325 }
4326
4327 if ( ! function_exists( 'storeengine_oops_message' ) ) {
4328 /**
4329 * Renders the no-content template.
4330 *
4331 * @param string|array{image?:string,title?:string,message?:string,classes?:string} $args
4332 *
4333 * @return void
4334 */
4335 function storeengine_oops_message( $args = [] ) {
4336 $args = wp_parse_args( $args, [
4337 'image' => null,
4338 'title' => null,
4339 'message' => null,
4340 'classes' => null,
4341 ] );
4342
4343 Template::get_template( 'global/oops.php', $args );
4344 }
4345 }
4346
4347 if ( ! function_exists( 'storeengine_table_oops_message' ) ) {
4348 /**
4349 * Renders the no-content template.
4350 *
4351 * @param string|array{columns:int,image?:string,title?:string,message?:string,classes?:string} $args
4352 *
4353 * @return void
4354 */
4355 function storeengine_table_oops_message( $args ) {
4356 $args = wp_parse_args( $args, [
4357 'columns' => 100,
4358 'image' => null,
4359 'title' => null,
4360 'message' => null,
4361 'classes' => null,
4362 ] );
4363
4364 $columns = $args['columns'];
4365 unset( $args['columns'] );
4366
4367 Template::get_template( 'global/table-oops.php', [
4368 'columns' => $columns,
4369 'args' => $args,
4370 ] );
4371 }
4372 }
4373
4374