is_cart_empty() && empty( $wp->query_vars['order-pay'] ) && ! isset( $wp->query_vars['order-received'] ) && ! isset( $wp->query_vars['order_pay'] ) && ! is_customize_preview() && apply_filters( 'storeengine/checkout_redirect_empty_cart', true ) ) { wp_safe_redirect( Helper::get_page_permalink( 'cart_page' ) ); exit; } // phpcs:disable WordPress.Security.NonceVerification.Recommended // Logout endpoint under My Account page. Logging out requires a valid nonce. if ( Helper::is_endpoint( 'customer-logout' ) ) { if ( ! empty( $_REQUEST['_wpnonce'] ) && wp_verify_nonce( sanitize_key( $_REQUEST['_wpnonce'] ), 'customer-logout' ) ) { wp_logout(); if ( ! empty( $_REQUEST['redirect_to'] ) ) { $redirect_to = esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ); } else { $redirect_to = storeengine_get_logout_redirect_url(); } wp_safe_redirect( wp_validate_redirect( $redirect_to ) ); exit; } /** @noinspection HtmlUnknownTarget */ wp_die( /* translators: %s: logout url */ sprintf( wp_kses_post( __( 'Are you sure you want to log out? Confirm and log out', 'storeengine' ) ), esc_url( storeengine_logout_url() ) ), esc_html__( 'Are you sure you want to log out?', 'storeengine' ), [ 'back_link' => true ] ); } // If user landed from another page with redirect-to param. if ( is_user_logged_in() && ! empty( $_REQUEST['redirect_to'] ) ) { $redirect_to = wp_validate_redirect( esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) ); if ( $redirect_to ) { wp_safe_redirect( $redirect_to ); } else { wp_safe_redirect( remove_query_arg( 'redirect_to' ) ); } exit; } // phpcs:enable WordPress.Security.NonceVerification.Recommended // Trigger 404 if trying to access an endpoint on wrong page. if ( Helper::is_endpoint() && ! Helper::is_dashboard() && ! Helper::is_checkout() && apply_filters( 'storeengine/dashboard/endpoint_page_not_found', true ) ) { $wp_query->set_404(); status_header( 404 ); include get_query_template( '404' ); exit; } // Redirect to the product page if we have a single product. 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 ) ) { $product = storeengine_get_product( $wp_query->post->ID ); if ( $product && $product->is_visible() ) { wp_safe_redirect( get_permalink( $product->get_id() ) ); exit; } } } /** * SE Login URL * * Do not hook this function into login_url ever. * * @param string $redirect Path to redirect to on log in. * @param bool $force_reauth Whether to force reauthorization, even if a cookie is present. * Default false. * * @return string The login URL. Not HTML-encoded. */ function storeengine_login_url( string $redirect = '', bool $force_reauth = false ): string { $auth_redirect_type = Helper::get_settings( 'auth_redirect_type', 'storeengine' ); $login_url = wp_login_url( $redirect, $force_reauth ); if ( 'default' === $auth_redirect_type || is_admin() ) { return wp_login_url( $redirect, $force_reauth ); } if ( 'custom' === $auth_redirect_type && Helper::get_settings( 'auth_redirect_url' ) ) { $login_url = add_query_arg( 'redirect_to', urlencode( $redirect ), Helper::get_settings( 'auth_redirect_url' ) ); } elseif ( 'storeengine' === $auth_redirect_type && Helper::get_settings( 'dashboard_page' ) ) { $login_url = add_query_arg( 'redirect_to', urlencode( $redirect ), Helper::get_dashboard_url() ); } if ( $force_reauth ) { $login_url = add_query_arg( 'reauth', '1', $login_url ); } return apply_filters( 'storeengine/login_url', $login_url, $redirect, $force_reauth ); } /** * Login URL tailored for the checkout "email already registered" flow. * * Returns the shopper back to checkout after a successful login and pre-fills * the email (via the `se_email` query arg the login template reads) so they * don't have to retype it. Shared by BOTH the inline contact pre-check and the * Place-Order 409, so the "Log in to continue" call-to-action is identical on * every surface that can surface it. * * @param string $email Email to pre-fill on the login form. * @param string $redirect Where to send the shopper after login. Defaults to * the current checkout URL. * * @return string */ function storeengine_checkout_login_url( string $email = '', string $redirect = '' ): string { if ( '' === $redirect ) { $redirect = Helper::get_checkout_url(); } $url = storeengine_login_url( $redirect ); // add_query_arg() url-encodes the value itself — pass the raw address. if ( '' !== $email && is_email( $email ) ) { $url = add_query_arg( 'se_email', $email, $url ); } return apply_filters( 'storeengine/checkout/contact_login_url', $url, $email, $redirect ); } /** * Prevent any user who cannot 'edit_posts' (subscribers, customers etc.) from seeing the admin bar. * * @param bool $show_admin_bar If site should display admin bar. * * @return bool */ function storeengine_disable_admin_bar( bool $show_admin_bar ): bool { /** * Controls whether the StoreEngine admin bar should be disabled. * * @param bool $enabled */ if ( apply_filters( 'storeengine/disable_admin_bar', true ) && ! ( current_user_can( 'edit_posts' ) || current_user_can( 'manage_storeengine' ) ) ) { $show_admin_bar = false; } return $show_admin_bar; } /** * Initialize global product for current request. * This setup the global before the loop for current page only. * * @return void */ function storeengine_init_global_product_early() { if ( empty( $GLOBALS['post'] ) ) { return; } if ( empty( $GLOBALS['post']->post_type ) || $GLOBALS['post']->post_type !== 'storeengine_product' ) { return; } $GLOBALS['product'] = storeengine_get_product( $GLOBALS['post']->ID ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound } /** * Initialize global product inside the loop. * * @param $post * * @return false|SimpleProduct|VariableProduct|void */ function storeengine_initialize_product_data( $post ) { if ( is_int( $post ) ) { $post = get_post( $post ); } if ( empty( $post->post_type ) || $post->post_type !== 'storeengine_product' ) { return; } $GLOBALS['the_original_product'] = $GLOBALS['product'] ?? null; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Compat global name mirroring the $product loop global preserved above. $GLOBALS['product'] = storeengine_get_product( $post->ID ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound return $GLOBALS['product']; } function storeengine_archive_title_parts( array $title ): array { global $wp_query; if ( Helper::is_shop() ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only archive sort label derived from the public query var; no state change. if ( ! empty( $_REQUEST['orderby'] ) && $wp_query->get( 'orderby' ) && $wp_query->get( 'order' ) ) { $sort = strtolower( $wp_query->get( 'orderby' ) . ':' . $wp_query->get( 'order' ) ); switch ( $sort ) { case 'post_title:asc': $label = __( 'Alphabetical (A–Z)', 'storeengine' ); break; case 'post_title:desc': $label = __( 'Alphabetical (Z–A)', 'storeengine' ); break; case 'publish_date:desc': $label = __( 'Newest first', 'storeengine' ); break; case 'publish_date:asc': $label = __( 'Oldest first', 'storeengine' ); break; case 'modified:desc': $label = __( 'Recently updated', 'storeengine' ); break; case 'modified:asc': $label = __( 'Least recently updated', 'storeengine' ); break; case 'menu_order:asc': $label = __( 'Custom order (ascending)', 'storeengine' ); break; case 'menu_order:desc': $label = __( 'Custom order (descending)', 'storeengine' ); break; case 'id:asc': $label = __( 'ID (ascending)', 'storeengine' ); break; case 'id:desc': $label = __( 'ID (descending)', 'storeengine' ); break; default: $label = str_ends_with( $sort, ':desc' ) ? __( 'Sort (descending)', 'storeengine' ) : __( 'Sort (ascending)', 'storeengine' ); break; } if ( $label ) { $page = $title['page'] ?? null; $site = $title['site'] ?? null; unset( $title['page'], $title['site'] ); $title['sort'] = $label; if ( $page ) { $title['page'] = $page; } if ( $site ) { $title['site'] = $site; } } } } return $title; } add_filter( 'document_title_parts', 'storeengine_archive_title_parts' ); /** * @param int $id * * @return AbstractOrder|Order|WP_Error * @throws StoreEngineException */ function storeengine_get_order( int $id ) { $order = Helper::get_order( $id ); if ( is_wp_error( $order ) ) { throw StoreEngineException::from_wp_error( $order ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } return $order; } function storeengine_cart(): Cart { return StoreEngine::init()->get_cart(); } if ( ! function_exists( 'storeengine_get_header' ) ) { function storeengine_get_header( $header_name = 'product' ) { if ( Helper::is_fse_theme() ) { ?> > >
is_type( 'bundled' ) && 'storeengine/templates/single-product/header_right_content' === current_action() ) { $bundles = $product->get_bundles(); } if ( empty( $bundles ) || ! is_array( $bundles ) ) { return; } Template::get_template( 'global/bundle-info.php', [ 'bundles' => $bundles ] ); } } if ( ! function_exists( 'storeengine_single_product_add_to_cart' ) ) { function storeengine_single_product_add_to_cart() { Template::get_template( 'single-product/add-to-cart.php' ); } } if ( ! function_exists( 'storeengine_single_product_footer' ) ) { function storeengine_single_product_footer() { Template::get_template( 'single-product/footer.php', ); } } if ( ! function_exists( 'storeengine_single_product_description' ) ) { function storeengine_single_product_description() { Template::get_template( 'single-product/description.php', ); } } if ( ! function_exists( 'storeengine_add_to_cart_form' ) ) { function storeengine_add_to_cart_form() { $cart_page_permalink = Helper::get_page_permalink( 'cart_page' ); $has_view_cart = true; Template::get_template( 'single-product/add-to-cart.php', [ 'cart_page_permalink' => $cart_page_permalink, 'has_view_cart' => $has_view_cart, ] ); } } function storeengine_placeholder_image_src( $size = 'storeengine_thumbnail' ) { // @TODO allow admin to change placeholder image. // @TODO implement size args. return apply_filters( 'storeengine/placeholder/image_src', STOREENGINE_ASSETS_URI . 'images/thumbnail-placeholder.png', $size ); } /** * Get the placeholder image. * * @param string $size * @param string|array $attr * * @return string * * Mirrors the standard option-seeding on install. * * @see add_image_size() */ function storeengine_placeholder_image( string $size = 'storeengine_thumbnail', $attr = '' ): string { // @TODO add thumbnail & single product image size // @TODO use wp_get_attachment_image $dimensions = [ 'width' => 600, 'height' => 600, ]; $default_attr = [ 'class' => 'storeengine-placeholder wp-post-image', 'alt' => __( 'Placeholder', 'storeengine' ), ]; $attr = wp_parse_args( $attr, $default_attr ); $image = storeengine_placeholder_image_src( $size ); $hwstring = image_hwstring( $dimensions['width'], $dimensions['height'] ); $attributes = []; foreach ( $attr as $name => $value ) { $attributes[] = esc_attr( $name ) . '="' . esc_attr( $value ) . '"'; } $image_html = ''; // phpcs:ignore PluginCheck.CodeAnalysis.ImageFunctions.NonEnqueuedImage return apply_filters( 'storeengine/placeholder/image', $image_html, $size, $dimensions ); } function storeengine_has_product_thumbnail( ?int $product_id = null ): bool { if ( ! $product_id ) { $product_id = get_the_ID(); } return (bool) apply_filters( 'storeengine/has_post_thumbnail', has_post_thumbnail( $product_id ), $product_id ); } function storeengine_has_product_gallery( ?int $product_id = null ): bool { if ( ! $product_id ) { $product_id = get_the_ID(); } // Show the carousel when there's more than one item, or any video (even a // single video still needs a player rather than the plain-image layout). $items = storeengine_get_product_gallery_items( $product_id ); $has_video = false; foreach ( $items as $item ) { if ( 'video' === $item['type'] ) { $has_video = true; break; } } $has_gallery = count( $items ) > 1 || $has_video; return apply_filters( 'storeengine/has_product_gallery', $has_gallery, $product_id ); } /** * Resolve the single-product gallery layout (global setting): carousel | * stacked | grid. Defaults to carousel. */ function storeengine_get_product_gallery_layout(): string { $layout = \StoreEngine\Utils\Helper::get_settings( 'single_product_gallery_layout', 'carousel' ); $allowed = [ 'carousel', 'stacked', 'grid' ]; if ( ! in_array( $layout, $allowed, true ) ) { $layout = 'carousel'; } return apply_filters( 'storeengine/product/gallery_layout', $layout ); } /** * Conditionally enqueue the custom (vanilla-JS, no jQuery/Flickity) product * gallery bundle — only when the product actually has gallery media, so the * carousel/stacked/grid + lightbox/zoom code loads on demand. * * @param int|null $product_id */ function storeengine_enqueue_product_gallery_assets( ?int $product_id = null ) { if ( ! $product_id ) { $product_id = get_the_ID(); } if ( ! $product_id || ! storeengine_has_product_gallery( $product_id ) ) { return; } $asset_file = STOREENGINE_ASSETS_DIR_PATH . 'build/product-gallery.' . STOREENGINE_VERSION . '.asset.php'; $asset = file_exists( $asset_file ) ? include $asset_file : [ 'dependencies' => [], 'version' => STOREENGINE_VERSION, ]; wp_enqueue_style( 'storeengine-product-gallery', STOREENGINE_ASSETS_URI . 'build/product-gallery.css', [], $asset['version'] ); wp_enqueue_script( 'storeengine-product-gallery', STOREENGINE_ASSETS_URI . 'build/product-gallery.' . STOREENGINE_VERSION . '.js', $asset['dependencies'], $asset['version'], true ); wp_localize_script( 'storeengine-product-gallery', 'StoreEngineGalleryL10n', [ 'close' => __( 'Close', 'storeengine' ), 'next' => __( 'Next image', 'storeengine' ), 'prev' => __( 'Previous image', 'storeengine' ), 'zoomIn' => __( 'Zoom in', 'storeengine' ), 'zoomOut' => __( 'Zoom out', 'storeengine' ), 'resetZoom' => __( 'Reset zoom', 'storeengine' ), 'playVideo' => __( 'Play video', 'storeengine' ), /* translators: %1$s current index, %2$s total. */ 'counter' => __( '%1$s of %2$s', 'storeengine' ), ] ); } function storeengine_get_product_image( $size = 'storeengine_thumbnail', $product_id = null, $attr = '', $placeholder = true ) { $image = ''; $thumbnail_id = 0; if ( ! $product_id ) { $product_id = get_the_ID(); } if ( $product_id ) { $thumbnail_id = (int) get_post_thumbnail_id( $product_id ); } if ( ! $thumbnail_id ) { // Fallback to first item on the gallery. $ids = get_post_meta( $product_id, '_storeengine_product_gallery_ids', true ); if ( $ids && is_array( $ids ) ) { $ids = array_unique( array_filter( $ids ) ); $thumbnail_id = reset( $ids ); } } if ( $thumbnail_id ) { $image = wp_get_attachment_image( $thumbnail_id, $size, false, $attr ); } if ( ! $image && $placeholder ) { $image = storeengine_placeholder_image( $size, $attr ); } return apply_filters( 'storeengine/product/image_html', $image, $product_id, $size, $attr, $placeholder ); } function storeengine_product_image( $size = 'storeengine_thumbnail', $product_id = null, $attr = '', $placeholder = true ) { // Output from wp image html. // wp_kses-post remove srcset, sizes etc attributes echo storeengine_get_product_image( $size, $product_id, $attr, $placeholder ); // phpcs:ignore } function storeengine_product_gallery( $product_id = null, $size = 'storeengine_thumbnail', $attr = '' ) { if ( ! $product_id ) { $product_id = get_the_ID(); } if ( $product_id ) { // @TODO allow admin to choose a featured image too.. // @XXX backend allows admin to add same image multiple time. $ids = get_post_meta( $product_id, '_storeengine_product_gallery_ids', true ); if ( ! is_array( $ids ) ) { $ids = []; } $ids = array_unique( array_filter( $ids ) ); if ( ! Helper::is_product() && count( $ids ) > 1 ) { $ids = [ reset( $ids ) ]; $ids = array_filter( $ids ); } foreach ( $ids as $id ) { $image = wp_get_attachment_image_src( $id, $size ); if ( ! $image ) { continue; } printf( '%1$s', wp_get_attachment_image( $id, $size, false, $attr ) ); } } } /** * Normalized list of gallery videos for a product. * * @param int|null $product_id * * @return array */ function storeengine_get_product_gallery_videos( ?int $product_id = null ): array { if ( ! $product_id ) { $product_id = get_the_ID(); } $videos = $product_id ? get_post_meta( $product_id, '_storeengine_product_gallery_videos', true ) : []; if ( ! is_array( $videos ) ) { $videos = []; } $out = []; foreach ( $videos as $video ) { $url = is_array( $video ) ? ( $video['url'] ?? '' ) : (string) $video; $url = trim( (string) $url ); if ( '' === $url ) { continue; } $out[] = [ 'url' => $url, 'poster_id' => is_array( $video ) ? absint( $video['poster_id'] ?? 0 ) : 0, ]; } return apply_filters( 'storeengine/product/gallery_videos', $out, $product_id ); } /** * Extract a YouTube video id from common URL shapes (watch, youtu.be, embed, * shorts). Returns '' when the url is not a recognizable YouTube link. */ function storeengine_extract_youtube_id( string $url ): string { if ( preg_match( '~(?:youtube\.com/(?:watch\?(?:.*&)?v=|embed/|shorts/|v/)|youtu\.be/)([A-Za-z0-9_-]{11})~i', $url, $m ) ) { return $m[1]; } return ''; } /** * Whether a URL points to a directly-playable video file (uploaded / media * library video, or any .mp4/.webm/... link). */ function storeengine_is_video_file_url( string $url ): bool { $path = wp_parse_url( $url, PHP_URL_PATH ); $ext = $path ? strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ) : ''; return in_array( $ext, [ 'mp4', 'm4v', 'webm', 'ogv', 'ogg', 'mov' ], true ); } /** * Sanitise an oEmbed / iframe embed fragment down to a safe, iframe-allowing * allow-list with wp_kses (mirrors WordPress core's oEmbed sanitisation). * Strips scripts, event handlers and unsafe protocols while keeping provider * iframes intact. * * Used as defence-in-depth: the JS injects this HTML via innerHTML, so we do * not want to rely solely on core's `oembed_result` filter still being * attached (another plugin could remove it) when a lower-privileged editor / * marketplace vendor — who may lack `unfiltered_html` — supplied the video URL. * * @param string $html * @return string */ function storeengine_kses_gallery_embed( string $html ): string { $allowed = [ 'a' => [ 'href' => true, 'title' => true, ], 'blockquote' => [ 'class' => true ], 'iframe' => [ 'src' => true, 'width' => true, 'height' => true, 'frameborder' => true, 'marginwidth' => true, 'marginheight' => true, 'scrolling' => true, 'title' => true, 'class' => true, 'name' => true, 'allow' => true, 'allowfullscreen' => true, 'loading' => true, 'referrerpolicy' => true, 'sandbox' => true, 'style' => true, ], ]; return wp_kses( $html, $allowed ); } /** * Resolve a video URL to sanitised, cached oEmbed HTML. * * `wp_oembed_get()` performs an outbound HTTP request (plus provider discovery * for non-whitelisted hosts) on every call, so the result is cached in a * transient keyed by URL + width to avoid a fetch on every product-page view. * The HTML is additionally run through {@see storeengine_kses_gallery_embed()}. * * @param string $url * @param int $width * @return string Sanitised embed HTML, or '' if the URL isn't embeddable. */ function storeengine_get_gallery_oembed_html( string $url, int $width = 1200 ): string { $cache_key = 'se_gallery_oembed_' . md5( $url . '|' . $width ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return (string) $cached; } $embed = wp_oembed_get( $url, [ 'width' => $width ] ); $html = $embed ? storeengine_kses_gallery_embed( (string) $embed ) : ''; // Cache successes for a day; cache misses only briefly so a transient // provider hiccup doesn't suppress a valid embed for long. set_transient( $cache_key, $html, $html ? DAY_IN_SECONDS : 5 * MINUTE_IN_SECONDS ); return $html; } /** * Markup for a single gallery video's main (player) cell. * * @param array{url:string, poster_id:int} $video */ function storeengine_get_gallery_video_html( array $video ): string { $url = $video['url']; // Direct video file (uploaded / media library / any .mp4 etc.). Wrap in the // fixed-aspect container so the carousel cell has a stable height at init // (a bare