self::panel_label(), 'target' => 'prodtabs_product_data', 'class' => array(), 'priority' => 75, ); return $tabs; } /** Scripts and styles of the product screen. */ public static function assets(): void { $screen = get_current_screen(); if ( ! $screen || 'product' !== $screen->id ) { return; } wp_enqueue_editor(); wp_enqueue_style( 'prodtabs-admin', PRODTABS_URL . 'assets/css/admin.css', array(), Plugin::asset_version( 'assets/css/admin.css' ) ); $deps = array( 'jquery', 'jquery-ui-sortable', 'wp-a11y' ); if ( wp_script_is( 'selectWoo', 'registered' ) ) { $deps[] = 'selectWoo'; wp_enqueue_style( 'select2' ); } wp_enqueue_script( 'prodtabs-admin-product', PRODTABS_URL . 'assets/js/admin-product.js', $deps, Plugin::asset_version( 'assets/js/admin-product.js' ), true ); Display::copy_script(); wp_localize_script( 'prodtabs-admin-product', 'prodtabsProduct', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::NONCE_AJAX ), 'productId' => (int) get_the_ID(), 'i18n' => array( /* translators: %s: tab title. */ 'moveUp' => __( 'Move “%s” up', 'prodtabs' ), /* translators: %s: tab title. */ 'moveDown' => __( 'Move “%s” down', 'prodtabs' ), /* translators: %s: tab title. */ 'remove' => __( 'Remove “%s”', 'prodtabs' ), /* translators: 1: tab title, 2: new position, 3: number of tabs. */ 'moved' => __( '“%1$s” moved to position %2$d of %3$d.', 'prodtabs' ), /* translators: %s: tab title. */ 'added' => __( '“%s” added.', 'prodtabs' ), /* translators: %s: tab title. */ 'removed' => __( '“%s” removed.', 'prodtabs' ), /* translators: %s: tab title. */ 'confirmRemove' => __( 'Remove the tab “%s”? Its content will be lost when you update the product.', 'prodtabs' ), 'confirmRevert' => __( 'Go back to the shared version? The changes made to this tab on this product will be lost when you update the product.', 'prodtabs' ), 'untitled' => __( '(no title)', 'prodtabs' ), 'error' => __( 'Something went wrong. Reload the page and try again.', 'prodtabs' ), 'newTab' => __( 'New tab', 'prodtabs' ), ), ) ); } /** Panel content. */ public static function panel(): void { global $post; $product_id = $post ? (int) $post->ID : 0; $entries = $product_id ? Product_Tabs::get( $product_id ) : array(); $product = $product_id ? wc_get_product( $product_id ) : null; if ( $product ) { $entries = Product_Tabs::with_auto( $entries, $product ); } if ( ! $entries ) { $entries = Tabs::with_natives( array(), Settings::get( 'native_order' ) ); } $context = array( 'lang' => $product_id ? Multilingual::post_language( $product_id ) : '', 'locked' => $product_id && Multilingual::structure_locked( $product_id ), 'source' => self::source_texts( $product_id ), 'product' => $product_id, ); $library = $context['locked'] ? array() : self::library_choices( $context['lang'] ); ?> Library::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => 500, 'orderby' => 'title', 'order' => 'ASC', 'no_found_rows' => true, ); if ( '' !== $lang ) { $args['lang'] = $lang; // Polylang. $args['suppress_filters'] = false; // WPML filters the query by the current admin language. } $choices = array(); foreach ( get_posts( $args ) as $post ) { $shop = Library::shop_title( $post ); /* translators: 1: title of the tab in the shop, 2: name of the library tab. */ $choices[ $post->ID ] = $shop === $post->post_title ? $shop : sprintf( __( '%1$s (%2$s)', 'prodtabs' ), $shop, $post->post_title ); } return $choices; } /** * One row of the list. * * @param array $entry Normalised entry. * @param int|string $index Position in the form (a placeholder in the template). * @param array $context lang, locked, source (texts of the source product). * @param bool $expanded Whether the row opens on display. */ public static function row( array $entry, $index, array $context = array(), bool $expanded = false ): string { $context = wp_parse_args( $context, array( 'lang' => '', 'locked' => false, 'source' => array(), 'product' => 0, ) ); $index = (string) $index; $name = 'prodtabs[entries][' . $index . ']'; $type = $entry['type']; $title = self::row_title( $entry, $context ); $body = ''; $notes = array(); $locked = (bool) $context['locked']; if ( 'native' === $type ) { $natives = Settings::get( 'natives' ); $visible = null === $entry['visible'] ? $natives[ $entry['key'] ]['visible'] : $entry['visible']; $value = null === $entry['visible'] ? '' : ( $entry['visible'] ? '1' : '0' ); $badge = __( 'WooCommerce', 'prodtabs' ); $body = self::native_body( $entry, $context ); } else { $visible = (bool) $entry['visible']; $value = $visible ? '1' : '0'; $badge = 'local' === $type ? __( 'This product', 'prodtabs' ) : __( 'Shared', 'prodtabs' ); $body = 'local' === $type ? self::local_body( $entry, $index, $name, $context ) : self::library_body( $entry, $context ); if ( 'library' === $type && ! Library::get_tab( (int) $entry['library_id'] ) ) { $notes[] = __( 'Not published: not shown in the shop.', 'prodtabs' ); } if ( ! empty( $entry['auto'] ) ) { $notes[] = __( 'Added automatically', 'prodtabs' ); } if ( 'local' === $type && empty( $entry['translated'] ) ) { $notes[] = __( 'Not translated', 'prodtabs' ); } } $title_id = 'prodtabs-title-' . $index; $body_id = 'prodtabs-body-' . $index; $classes = array( 'prodtabs-item', 'prodtabs-item--' . $type ); if ( ! $visible ) { $classes[] = 'is-hidden'; } if ( $expanded ) { $classes[] = 'is-open'; } ob_start(); ?>
  • >
  • __( 'Description', 'prodtabs' ), 'additional_information' => __( 'Additional information', 'prodtabs' ), 'reviews' => __( 'Reviews', 'prodtabs' ), ); return $labels[ $entry['key'] ]; } if ( 'local' === $entry['type'] ) { if ( '' === $entry['title'] && isset( $context['source'][ $entry['key'] ]['title'] ) ) { return (string) $context['source'][ $entry['key'] ]['title']; } return $entry['title']; } $post = get_post( self::library_id_in( (int) $entry['library_id'], (string) $context['lang'] ) ); return $post && Library::POST_TYPE === $post->post_type ? Library::shop_title( $post ) : __( '(deleted shared tab)', 'prodtabs' ); } /** * Library tab to show for a product: its translation in the language of the product when there is one. * * @param int $id Library tab id. * @param string $lang Language of the product. */ private static function library_id_in( int $id, string $lang ): int { if ( '' === $lang ) { return $id; } $translated = Multilingual::translation( $id, Library::POST_TYPE, $lang ); return $translated ? $translated : $id; } /** * Fields of a tab of the product. * * @param array $entry Entry. * @param string $index Position in the form. * @param string $name Field name prefix. * @param array $context Row context. */ private static function local_body( array $entry, string $index, string $name, array $context ): string { $origin = (int) $entry['origin']; $source = $origin ? get_post( self::library_id_in( $origin, (string) $context['lang'] ) ) : null; $ref = $context['source'][ $entry['key'] ] ?? null; ob_start(); if ( $source && Library::POST_TYPE === $source->post_type ) : ?>

    >

    post_type ) : ?>

    post_content ) ); ?>

    get_description() ), 40 ) : ''; $status = '' === $text ? __( 'The description is empty: WooCommerce does not show this tab.', 'prodtabs' ) : ''; $targets[] = array( 'description', __( 'Go to the description', 'prodtabs' ) ); break; case 'additional_information': $source = __( 'Content made by WooCommerce: the attributes shown on the product page, and the weight and dimensions.', 'prodtabs' ); if ( $product ) { foreach ( $product->get_attributes() as $attribute ) { if ( ! $attribute instanceof \WC_Product_Attribute || ! $attribute->get_visible() ) { continue; } $values = $attribute->is_taxonomy() ? wc_get_product_terms( $product->get_id(), $attribute->get_name(), array( 'fields' => 'names' ) ) : $attribute->get_options(); $rows[] = array( wc_attribute_label( $attribute->get_name(), $product ), implode( ', ', (array) $values ) ); } if ( $product->has_weight() ) { $rows[] = array( __( 'Weight', 'prodtabs' ), wc_format_weight( $product->get_weight() ) ); } if ( $product->has_dimensions() ) { $rows[] = array( __( 'Dimensions', 'prodtabs' ), wc_format_dimensions( $product->get_dimensions( false ) ) ); } } $status = $rows ? '' : __( 'Nothing to show: WooCommerce does not show this tab on this product.', 'prodtabs' ); $targets[] = array( 'product_attributes', '' ); $targets[] = array( 'shipping_product_data', '' ); break; default: $source = __( 'Content of this tab: the customer reviews of the product.', 'prodtabs' ); if ( 'yes' !== get_option( 'woocommerce_enable_reviews', 'yes' ) ) { $status = __( 'Reviews are off for the whole shop: WooCommerce does not show this tab.', 'prodtabs' ); } elseif ( $product && ! $product->get_reviews_allowed() ) { $status = __( 'Reviews are off for this product: WooCommerce does not show this tab.', 'prodtabs' ); } else { $count = $product ? (int) $product->get_review_count() : 0; $status = __( 'No reviews yet: the tab shows the review form.', 'prodtabs' ); if ( $count ) { /* translators: %d: number of reviews. */ $status = sprintf( _n( '%d review.', '%d reviews.', $count, 'prodtabs' ), $count ); } } $targets[] = array( 'advanced_product_data', '' ); } ob_start(); ?>

    get_id(), $entry['key'] ), __( 'Shortcode of this tab', 'prodtabs' ), 'prodtabs-shortcode-' . $entry['key'] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in shortcode_field(). ?> get_id() ) ) { return; } $product_id = $product->get_id(); // The last field of the panel is missing when PHP dropped fields (max_input_vars): saving would lose tabs. if ( empty( $_POST['prodtabs']['complete'] ) ) { self::notice( __( 'The tabs of this product were not saved: the form was too long for the server (max_input_vars setting of PHP). Your host can raise this limit.', 'prodtabs' ) ); return; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- cleaned by normalize() and sanitize_input(). $raw = isset( $_POST['prodtabs']['entries'] ) && is_array( $_POST['prodtabs']['entries'] ) ? wp_unslash( $_POST['prodtabs']['entries'] ) : array(); $entries = Product_Tabs::sanitize_input( Tabs::normalize( array_values( $raw ) ) ); // Structure changed in another language while this screen was open (Polylang shares it): keep the stored one. $loaded = isset( $_POST['prodtabs']['signature'] ) ? sanitize_key( wp_unslash( $_POST['prodtabs']['signature'] ) ) : ''; $stored_now = Tabs::signature( Product_Tabs::layout( $product_id ) ); $texts_only = Multilingual::structure_locked( $product_id ); if ( '' !== $loaded && $loaded !== $stored_now ) { $texts_only = true; if ( Tabs::signature( $entries ) !== $loaded ) { self::notice( __( 'The list of tabs of this product was changed in another language while you were editing it. The texts you typed were saved, but not your changes to the list: check the Tabs panel and make them again.', 'prodtabs' ) ); } } // A tab added and left empty is dropped; a stored tab without text stays, to be translated later. $stored = array(); foreach ( Product_Tabs::layout( $product_id ) as $item ) { if ( is_array( $item ) && isset( $item['key'] ) ) { $stored[ (string) $item['key'] ] = true; } } $entries = array_values( array_filter( $entries, static function ( $entry ) use ( $stored ) { return 'local' !== $entry['type'] || isset( $stored[ $entry['key'] ] ) || '' !== $entry['title'] || '' !== trim( $entry['content'] ); } ) ); // New tabs get a readable key from their title, the name translators see in WPML. Keys still holding texts in // another language (a tab removed earlier) are avoided: those old texts would come back as translations. $keys = array(); foreach ( Multilingual::translation_ids( $product_id, 'product' ) as $translation ) { $texts = get_post_meta( $translation, Tabs::META_TEXTS, true ); $keys += is_array( $texts ) ? array_fill_keys( array_map( 'strval', array_keys( $texts ) ), true ) : array(); } foreach ( $entries as $entry ) { if ( 'local' === $entry['type'] && isset( $stored[ $entry['key'] ] ) ) { $keys[ $entry['key'] ] = true; } } foreach ( $entries as $i => $entry ) { if ( 'local' === $entry['type'] && ! isset( $stored[ $entry['key'] ] ) ) { $entries[ $i ]['key'] = Tabs::key_from( $entry['title'], $keys ); $keys[ $entries[ $i ]['key'] ] = true; } } Product_Tabs::set( $product, $entries, $texts_only ); } /** * Message shown on the product screen after the redirection that follows saving. * * @param string $message Message. */ private static function notice( string $message ): void { set_transient( 'prodtabs_notice_' . get_current_user_id(), $message, 5 * MINUTE_IN_SECONDS ); } /** Shows the message left by the last save. */ public static function show_notice(): void { $key = 'prodtabs_notice_' . get_current_user_id(); $message = get_transient( $key ); if ( ! $message ) { return; } delete_transient( $key ); echo '

    ' . esc_html( (string) $message ) . '

    '; } /** Checks an AJAX request of the panel. */ private static function check_ajax(): void { check_ajax_referer( self::NONCE_AJAX, 'nonce' ); if ( ! current_user_can( 'edit_products' ) ) { wp_send_json_error( null, 403 ); } } /** * Library tab asked by an AJAX request, if the user may read it. * * @param int $id Library tab id. */ private static function readable_library_tab( int $id ): \WP_Post { $post = get_post( $id ); if ( ! $post || Library::POST_TYPE !== $post->post_type || 'trash' === $post->post_status ) { wp_send_json_error( null, 404 ); } if ( ! current_user_can( 'read_post', $id ) ) { wp_send_json_error( null, 403 ); } return $post; } /** Context of a row built by an AJAX request. */ private static function ajax_context(): array { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked in check_ajax(). $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : 0; return array( 'lang' => $product_id ? Multilingual::post_language( $product_id ) : '', 'locked' => false, 'source' => self::source_texts( $product_id ), ); } /** AJAX: row of a library tab, to add it or to go back to it. */ public static function ajax_library_row(): void { self::check_ajax(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked by check_ajax() above. $id = isset( $_POST['library_id'] ) ? absint( $_POST['library_id'] ) : 0; $index = isset( $_POST['index'] ) ? absint( $_POST['index'] ) : 0; $anchor = isset( $_POST['anchor'] ) ? sanitize_text_field( wp_unslash( $_POST['anchor'] ) ) : ''; // phpcs:enable $post = self::readable_library_tab( $id ); $entry = array( 'type' => 'library', 'key' => 'lib_' . $id, 'library_id' => $id, 'anchor' => $anchor, 'visible' => true, ); wp_send_json_success( array( 'html' => self::row( $entry, $index, self::ajax_context() ), 'title' => Library::shop_title( $post ), ) ); } /** AJAX: copy of a library tab as a tab of the product, keeping its anchor. */ public static function ajax_customize(): void { self::check_ajax(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked by check_ajax() above. $id = isset( $_POST['library_id'] ) ? absint( $_POST['library_id'] ) : 0; $index = isset( $_POST['index'] ) ? absint( $_POST['index'] ) : 0; $anchor = isset( $_POST['anchor'] ) ? sanitize_text_field( wp_unslash( $_POST['anchor'] ) ) : ''; // phpcs:enable $post = self::readable_library_tab( $id ); // The product editor is the classic one: block markup is turned into the HTML it renders. $content = has_blocks( $post->post_content ) ? do_blocks( $post->post_content ) : $post->post_content; $entry = array( 'type' => 'local', 'key' => Tabs::new_key(), 'anchor' => $anchor, 'title' => Library::shop_title( $post ), 'content' => trim( $content ), 'origin' => $id, 'visible' => true, 'translated' => true, ); wp_send_json_success( array( 'html' => self::row( $entry, $index, self::ajax_context(), true ), 'title' => $entry['title'], ) ); } }