'', 'tab' => '', 'shared' => '', 'title' => 'yes', ), $atts, self::SHORTCODE ); $title = ! in_array( strtolower( trim( (string) $atts['title'] ) ), array( 'no', '0', 'false', 'off' ), true ); if ( '' !== trim( (string) $atts['shared'] ) ) { return self::render_shared( absint( $atts['shared'] ), $title ); } return self::render( absint( $atts['product'] ), (string) $atts['tab'], $title ); } /** * A shared tab on its own, in the language of the page: its published translation, or the tab itself while the * translation is missing (unless the settings hide untranslated tabs). * * @param int $id Shared tab, in any language. * @param bool $show_title Its heading. */ public static function render_shared( int $id, bool $show_title = true ): string { if ( self::$depth > 0 || ! $id ) { return ''; } $tab = Library::resolve( $id ); if ( ! $tab || Tabs::is_blank( $tab['content'] ) ) { return ''; } ++self::$depth; $product = ( $GLOBALS['product'] ?? null ) instanceof \WC_Product ? $GLOBALS['product'] : null; $lang = $product ? Multilingual::post_language( $product->get_id() ) : Multilingual::current_language(); $context = array( 'type' => 'library', 'library_id' => $tab['id'], 'product_id' => $product ? $product->get_id() : 0, // False when the original shared tab shows because its translation is missing or not published. 'translated' => '' === $lang || Multilingual::post_language( (int) $tab['id'] ) === $lang, 'title' => $tab['title'], 'content' => $tab['content'], ); if ( $product ) { // On a product, the tab as its page shows it: dynamic content of Premium, other changes made to its tabs. $tabs = apply_filters( 'prodtabs_product_tabs', array( $tab['anchor'] => array( 'title' => esc_html( $tab['title'] ), 'priority' => 50, 'callback' => array( Render::class, 'render_panel' ), 'prodtabs' => $context, ), ), $product ); $context = is_array( $tabs ) && ! empty( $tabs[ $tab['anchor'] ]['prodtabs'] ) ? $tabs[ $tab['anchor'] ]['prodtabs'] : null; } $html = ''; if ( $context ) { $html = '
'; $html .= $show_title ? '

' . esc_html( (string) $context['title'] ) . '

' : ''; $html .= Render::content( (string) $context['content'], $context ) . '
'; } --self::$depth; return $html; } /** * Shortcode of the tabs of a product, or of one of them. * * @param int $product_id Product. * @param string $anchor Anchor of one tab; empty for every tab. */ public static function shortcode_for( int $product_id, string $anchor = '' ): string { return '[' . self::SHORTCODE . ' product="' . $product_id . '"' . ( '' !== $anchor ? ' tab="' . $anchor . '"' : '' ) . ']'; } /** * Shortcode of a shared tab. * * @param int $id Shared tab. */ public static function shortcode_shared( int $id ): string { return '[' . self::SHORTCODE . ' shared="' . $id . '"]'; } /** Script of the Copy buttons of the shortcodes, with its words. */ public static function copy_script(): void { if ( wp_script_is( 'prodtabs-copy', 'enqueued' ) ) { return; } wp_enqueue_script( 'prodtabs-copy', PRODTABS_URL . 'assets/js/copy.js', array( 'wp-a11y' ), Plugin::asset_version( 'assets/js/copy.js' ), true ); wp_localize_script( 'prodtabs-copy', 'prodtabsCopy', array( 'copied' => __( 'Copied', 'prodtabs' ), 'spoken' => __( 'Shortcode copied.', 'prodtabs' ), ) ); } /** * Read-only field with a shortcode and a button that copies it (see copy_script()). * * @param string $code Shortcode. * @param string $label Label of the field. * @param string $id Id of the field. */ public static function shortcode_field( string $code, string $label, string $id ): string { return '

' . ' ' . '

'; } /** Registers the block, with its editor script (no build step: plain script on the wp globals). */ public static function register_block(): void { if ( ! function_exists( 'register_block_type' ) ) { return; } wp_register_script( 'prodtabs-block-editor', PRODTABS_URL . 'assets/js/block-editor.js', array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-server-side-render' ), Plugin::asset_version( 'assets/js/block-editor.js' ), true ); // Labels from PHP: the translations of the plugin are .mo files, not JSON files for scripts. wp_add_inline_script( 'prodtabs-block-editor', 'window.prodtabsBlock = ' . wp_json_encode( array( 'name' => self::BLOCK, 'labels' => array( 'panel' => __( 'Product tabs', 'prodtabs' ), 'product' => __( 'Product ID', 'prodtabs' ), 'productHelp' => __( 'Empty: the product of the page, or of the Single Product block or product list around this block.', 'prodtabs' ), 'tab' => __( 'One tab only', 'prodtabs' ), 'tabHelp' => __( 'Anchor of the tab, such as shipping for #tab-shipping. Empty: every tab.', 'prodtabs' ), 'showTitle' => __( 'Show the title of the tab', 'prodtabs' ), 'showTitleHelp' => __( 'With one tab only.', 'prodtabs' ), ), ) ) . ';', 'before' ); register_block_type( PRODTABS_DIR . 'blocks/product-tabs', array( 'render_callback' => array( __CLASS__, 'render_block' ), ) ); } /** * Server side rendering of the block. * * @param array $attributes Attributes: productId, tab, showTitle. * @param string $content Inner content (none). * @param \WP_Block|null $block Block, with its context. */ public static function render_block( $attributes, $content = '', $block = null ): string { $attributes = is_array( $attributes ) ? $attributes : array(); $product_id = absint( $attributes['productId'] ?? 0 ); if ( ! $product_id && $block instanceof \WP_Block && ! empty( $block->context['postId'] ) && 'product' === get_post_type( (int) $block->context['postId'] ) ) { $product_id = (int) $block->context['postId']; // Single Product block, product template, product list. } $html = self::render( $product_id, (string) ( $attributes['tab'] ?? '' ), (bool) ( $attributes['showTitle'] ?? true ) ); if ( '' === $html && defined( 'REST_REQUEST' ) && REST_REQUEST ) { // Preview of the editor, which has no product of the page. $html = '

' . esc_html__( 'Choose a product in the block settings, or place this block in a product template or in a Single Product block.', 'prodtabs' ) . '

'; } return '' === $html ? '' : '
' . $html . '
'; } /** * Tabs of a product, or one of its tabs. * * @param int $product_id Product; 0 for the product of the page. * @param string $tab Anchor of one tab (shipping, tab-shipping or #tab-shipping); empty for every tab. * @param bool $show_title With one tab: its heading. */ public static function render( int $product_id, string $tab = '', bool $show_title = true ): string { if ( self::$depth > 0 ) { return ''; } $product = self::product( $product_id ); if ( ! $product ) { return ''; } global $post; $previous_post = $post; $previous_product = $GLOBALS['product'] ?? null; ++self::$depth; $post = get_post( $product->get_id() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- globals of WordPress and WooCommerce, put back below. $GLOBALS['product'] = $product; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- globals of WordPress and WooCommerce, put back below. setup_postdata( $post ); ob_start(); $tab = self::tab_key( $tab ); if ( '' === $tab ) { Render::enqueue_assets(); wp_enqueue_script( 'wc-single-product' ); // Opens and closes the tabs; registered by WooCommerce on every page. wc_get_template( 'single-product/tabs/tabs.php' ); } else { self::one_tab( $tab, $show_title ); } $html = (string) ob_get_clean(); $GLOBALS['product'] = $previous_product; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- the product of the page, global of WooCommerce. $post = $previous_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- the post of the page. if ( $post instanceof \WP_Post ) { setup_postdata( $post ); } --self::$depth; return trim( $html ); } /** * Product to show: the one asked for, or the product of the page. Unpublished and password protected products show * nothing to visitors. * * @param int $product_id Product; 0 for the product of the page. */ private static function product( int $product_id ): ?\WC_Product { if ( ! $product_id ) { $product = ( $GLOBALS['product'] ?? null ) instanceof \WC_Product ? $GLOBALS['product'] : wc_get_product( (int) get_the_ID() ); return $product instanceof \WC_Product && self::showable( $product ) ? $product : null; } // On a translated page, the translation of the product asked for; while it is not published, the product asked // for, or the original when the id is itself that translation (WPML points the ids of the shortcode to the // language of the page). $ids = array( Multilingual::in_current_language( $product_id, 'product' ), $product_id, Multilingual::original( $product_id, 'product' ) ); foreach ( array_unique( array_filter( $ids ) ) as $id ) { $product = wc_get_product( $id ); if ( $product instanceof \WC_Product && self::showable( $product ) ) { return $product; } } return null; } /** * Whether a product can be shown: not a variation, published or readable by the current user, without password. * * @param \WC_Product $product Product. */ private static function showable( \WC_Product $product ): bool { $id = $product->get_id(); if ( $product->get_parent_id() ) { return false; // A variation. } return ( 'publish' === get_post_status( $id ) || current_user_can( 'read_post', $id ) ) && ! post_password_required( $id ); } /** * Key of a tab from what was typed, # left out (see one_tab()). * * @param string $tab Anchor. */ private static function tab_key( string $tab ): string { $tab = trim( $tab ); return 0 === strpos( $tab, '#' ) ? substr( $tab, 1 ) : $tab; } /** * One tab of the product of the page, as its panel shows it, without the tab titles. * * @param string $key Tab key. * @param bool $show_title Heading of the tab. */ private static function one_tab( string $key, bool $show_title ): void { $tabs = apply_filters( 'woocommerce_product_tabs', array() ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce hook. if ( ! is_array( $tabs ) ) { return; } // The anchor as the product screen gives it (shipping, tab-2024 for a tab titled 2024), as the address shows it // (tab-shipping), then the title as it reads (Shipping and returns, 2024). $address = 0 === strpos( $key, 'tab-' ) ? substr( $key, 4 ) : ''; foreach ( array( $key, $address, Tabs::anchor( $key, '' ) ) as $candidate ) { if ( '' !== $candidate && isset( $tabs[ $candidate ] ) ) { $key = $candidate; break; } } if ( ! isset( $tabs[ $key ]['callback'] ) || ! is_callable( $tabs[ $key ]['callback'] ) ) { return; } $heading = static function ( $heading, $data = array() ) use ( $show_title ) { if ( ! $show_title ) { return ''; } return is_array( $data ) && isset( $data['title'] ) ? (string) $data['title'] : $heading; }; // The WooCommerce tabs: their heading even when the settings turn headings off, since it was asked for. $title = html_entity_decode( wp_strip_all_tags( (string) ( $tabs[ $key ]['title'] ?? '' ) ), ENT_QUOTES, 'UTF-8' ); $native = static function ( $heading ) use ( $show_title, $title ) { if ( ! $show_title ) { return ''; } return '' !== (string) $heading ? $heading : $title; }; // A theme that hides the heading of the panels (the tab bar already shows the titles, often through the YIKES // filter) is right on a product page; a tab shown on its own has no tab bar, and its title was asked for. $restore = static function ( $html, $data = array() ) use ( $show_title ) { if ( ! $show_title || '' !== trim( (string) $html ) || ! is_array( $data ) || '' === (string) ( $data['title'] ?? '' ) ) { return $html; } return '

' . esc_html( (string) $data['title'] ) . '

'; }; add_filter( 'prodtabs_tab_heading', $heading, 20, 2 ); add_filter( 'prodtabs_tab_heading_html', $restore, PHP_INT_MAX, 2 ); add_filter( 'woocommerce_product_description_heading', $native, 20 ); add_filter( 'woocommerce_product_additional_information_heading', $native, 20 ); echo '
'; call_user_func( $tabs[ $key ]['callback'], $key, $tabs[ $key ] ); echo '
'; remove_filter( 'prodtabs_tab_heading', $heading, 20 ); remove_filter( 'prodtabs_tab_heading_html', $restore, PHP_INT_MAX ); remove_filter( 'woocommerce_product_description_heading', $native, 20 ); remove_filter( 'woocommerce_product_additional_information_heading', $native, 20 ); } }