get_parent_id() ? wc_get_product( $product->get_parent_id() ) : $product; if ( ! $parent || ! self::is_enabled( $parent ) ) { return 0.0; } $prices = array_column( self::offered_sides( $parent ), 'price' ); return $prices ? (float) min( $prices ) : 0.0; } /** * The sides a product is printed on: its mockup's sides that have a print price on the product. An empty price * means the side is not offered; 0 offers it free. * * @return array Each side as Mockups::sides_for() gives it, with its `price`. */ public static function offered_sides( \WC_Product $product ): array { $parent = $product->get_parent_id() ? wc_get_product( $product->get_parent_id() ) : $product; if ( ! $parent ) { return array(); } $offered = array(); foreach ( Mockups::sides_for( $parent ) as $side => $spec ) { $price = (string) $parent->get_meta( self::META_PRICE . $side ); if ( '' !== $price ) { $offered[ $side ] = $spec + array( 'price' => (float) $price ); } } return $offered; } /** Whether customers design this product: the designer is on and at least one side is offered. */ public static function is_designable( \WC_Product $product ): bool { return self::is_enabled( $product ) && (bool) self::offered_sides( $product ); } /** * What someone who may edit a designer product is told when customers can't design it, and what to set; empty when * the product is designable, is no designer product, or the viewer may not edit it. */ public static function not_designable_notice( \WC_Product $product ): string { $parent = $product->get_parent_id() ? wc_get_product( $product->get_parent_id() ) : $product; if ( ! $parent || ! self::is_enabled( $parent ) || self::is_designable( $parent ) || ! current_user_can( 'edit_post', $parent->get_id() ) ) { return ''; } if ( ! Mockups::sides_for( $parent ) ) { return esc_html__( 'Customers don’t see the designer on this product: its mockup has no side with a photo. Add one under PrintBlob › Mockups.', 'printblob' ); } return sprintf( /* translators: %s: the product's PrintBlob designer tab, linked. */ esc_html__( 'Customers don’t see the designer on this product: no side has a print price. Set one in the product’s %s tab (an empty price means the side is not offered, 0 prints it free).', 'printblob' ), '' . esc_html( self::tab_label() ) . '' ); } /** On the page of a designer product customers can't design, the notice for someone who may edit it. */ public static function product_page_notice(): void { $product = wc_get_product( get_the_ID() ); $notice = $product ? self::not_designable_notice( $product ) : ''; if ( '' !== $notice ) { wc_print_notice( $notice, 'notice' ); } } /** * The designer's prices as it first shows them, with nothing printed, as App.tsx works them out from the same * configuration: the shirt the first choices allow (`shirt`: lowest and highest, a range while a choice is open), * the side counted until one is printed (`cheapest`: the first of the cheapest offered sides, as cheapestSide() in * sides.ts finds it; a design prints at least one side) and its price (`print`). * * @param array $config The designer's configuration (render.php, after printblob_designer_config). * @return array{shirt: array{float, float}, cheapest: string, print: float} */ public static function designer_prices( array $config ): array { $color = (string) ( ! empty( $config['initialColor'] ) ? $config['initialColor'] : ( $config['colors'][0]['slug'] ?? '' ) ); $choices = array( 'attribute_' . $config['attributes']['color'] => $color, 'attribute_' . $config['attributes']['size'] => empty( $config['sizes'] ) ? '' : null, ); foreach ( (array) ( $config['options'] ?? array() ) as $option ) { $choices[ (string) $option['field'] ] = isset( $option['choices'][0]['value'] ) ? (string) $option['choices'][0]['value'] : null; } $prices = array(); foreach ( (array) $config['variations'] as $variation ) { $matches = true; foreach ( $variation['attributes'] as $field => $value ) { $matches = $matches && ( '' === $value || ( array_key_exists( $field, $choices ) && ( null === $choices[ $field ] || $choices[ $field ] === $value ) ) ); } if ( $matches ) { $prices[] = (float) $variation['price']; } } if ( $prices && ! in_array( null, $choices, true ) ) { $prices = array( $prices[0] ); } if ( ! $prices ) { $prices = array( (float) $config['basePrice'] ); } $cheapest = ''; $print = null; foreach ( (array) $config['sides'] as $side ) { if ( null === $print || (float) $side['price'] < $print ) { $cheapest = (string) $side['key']; $print = (float) $side['price']; } } return array( 'shirt' => array( (float) min( $prices ), (float) max( $prices ) ), 'cheapest' => $cheapest, 'print' => (float) $print, ); } /** * The designer's price as it first shows, with nothing printed: the shirt and the cheapest offered side * (designer_prices()). * * @param array $config The designer's configuration (render.php, after printblob_designer_config). */ public static function designer_price( array $config ): string { $prices = self::designer_prices( $config ); return self::price_range_text( $prices['shirt'][0] + $prices['print'], $prices['shirt'][1] + $prices['print'] ); } /** A price, or a range from `$low` to `$high`, as plain text, as the designer writes it (formatPriceRange() in format.ts). */ public static function price_range_text( float $low, float $high ): string { return $low === $high ? self::price_text( $low ) : sprintf( /* translators: a price range: 1: lowest price, 2: highest price. */ __( '%1$s – %2$s', 'printblob' ), self::price_text( $low ), self::price_text( $high ) ); } /** A price as plain text in the shop's currency format, as the designer writes it (format.ts). */ public static function price_text( float $amount ): string { return html_entity_decode( wp_strip_all_tags( wc_price( $amount ) ), ENT_QUOTES ); } /** * Listed variation prices ("From …", sorting, structured data) of a designer product include the * cheapest print. The cart still adds each printed side's price to the shirt's own price. * * @param string|float $price Variation price. * @param \WC_Product_Variation $variation Variation. * @param \WC_Product $product Parent product. */ public static function variation_price_with_print( $price, $variation, $product ) { if ( '' === $price || null === $price ) { return $price; } $print = self::lowest_print_price( $product ); return $print > 0 ? wc_format_decimal( (float) $price + $print, wc_get_price_decimals() ) : $price; } /** * WooCommerce caches listed variation prices; the cache follows the print prices too. * * @param array $hash Cache key parts. * @param \WC_Product $product Product. */ public static function prices_hash( $hash, $product ): array { $hash = (array) $hash; $hash[] = 'printblob:' . self::lowest_print_price( $product ); return $hash; } /** * Simple designer products: the listed price includes the cheapest print. * * @param string $html Price HTML. * @param \WC_Product $product Product. */ public static function simple_price_with_print( $html, $product ) { if ( ! $product instanceof \WC_Product || $product->is_type( 'variable' ) || $product->get_parent_id() || '' === $product->get_price() ) { return $html; } $print = self::lowest_print_price( $product ); return $print > 0 ? wc_price( wc_get_price_to_display( $product ) + $print ) . $product->get_price_suffix() : $html; } /** * The product name's heading level in the designer when the block sets none: the page's first-level heading on the * product's own page, where the designer is the page, and on a block-theme page whose template renders no post title; * a second-level one under a title the template renders. A classic theme's page is taken to show its own title. */ public static function heading_level( \WC_Product $product ): int { $own_page = is_singular( 'product' ) && (int) get_queried_object_id() === $product->get_id(); return $own_page || false === self::template_renders_title() ? 1 : 2; } /** * Whether the block template being rendered shows the post's title: a core/post-title block in it, or in a template * part, pattern or synced pattern it uses, outside a query loop, whose titles are other posts'. WordPress keeps the * template in $_wp_current_template_content from locate_block_template() until template-canvas.php has rendered it. * Null where no block template is rendering: a classic theme, the editor, the REST API. */ public static function template_renders_title(): ?bool { global $_wp_current_template_content; if ( ! wp_is_block_theme() || empty( $_wp_current_template_content ) ) { return null; } return self::blocks_render_title( parse_blocks( (string) $_wp_current_template_content ), array() ); } /** * @param array> $blocks Parsed blocks. * @param array $seen Template parts and patterns already read, against a loop. */ private static function blocks_render_title( array $blocks, array $seen ): bool { foreach ( $blocks as $block ) { $name = (string) ( $block['blockName'] ?? '' ); $attrs = (array) ( $block['attrs'] ?? array() ); $inner = array(); switch ( $name ) { case 'core/post-title': return true; case 'core/query': continue 2; case 'core/template-part': $slug = (string) ( $attrs['slug'] ?? '' ); $id = (string) ( $attrs['theme'] ?? get_stylesheet() ) . '//' . $slug; $part = '' === $slug || isset( $seen[ $id ] ) ? null : get_block_template( $id, 'wp_template_part' ); $inner = $part && ! empty( $part->content ) ? parse_blocks( (string) $part->content ) : array(); break; case 'core/pattern': $id = 'pattern:' . (string) ( $attrs['slug'] ?? '' ); $pattern = isset( $seen[ $id ] ) ? null : \WP_Block_Patterns_Registry::get_instance()->get_registered( (string) ( $attrs['slug'] ?? '' ) ); $inner = $pattern ? parse_blocks( (string) ( $pattern['content'] ?? '' ) ) : array(); break; case 'core/block': $id = 'block:' . (int) ( $attrs['ref'] ?? 0 ); $synced = isset( $seen[ $id ] ) ? null : get_post( (int) ( $attrs['ref'] ?? 0 ) ); $inner = $synced && 'wp_block' === $synced->post_type ? parse_blocks( (string) $synced->post_content ) : array(); break; default: $id = ''; $inner = (array) ( $block['innerBlocks'] ?? array() ); } if ( $inner && self::blocks_render_title( $inner, '' === $id ? $seen : $seen + array( $id => true ) ) ) { return true; } } return false; } /** * Designer products use the `single-product-designer` template: the theme's own when it has one, otherwise the one * PrintBlob registers for block themes (register_designer_template()). Classic themes get the designer through * WooCommerce's product hooks (classic_designer()). */ public static function designer_template( array $templates ): array { $product = is_singular( 'product' ) ? wc_get_product( get_queried_object_id() ) : null; if ( $product && self::is_designable( $product ) ) { array_unshift( $templates, 'single-product-designer.php' ); } return $templates; } /** The product page of a designer product in a block theme: the theme's header and footer around the designer. */ public static function register_designer_template(): void { if ( ! function_exists( 'register_block_template' ) || ! wp_is_block_theme() ) { return; } register_block_template( 'printblob//single-product-designer', array( 'title' => __( 'Product with the PrintBlob designer', 'printblob' ), 'description' => __( 'The page of a product with the PrintBlob designer turned on: customers design their print, choose colour and size, and add it to the cart.', 'printblob' ), 'content' => '' . '
' . '' . '' . '
' . '', ) ); } /** * The product page of a designer product in a classic theme: the designer, which shows the product's name, price and * cart button itself, in place of WooCommerce's photos and summary. */ public static function classic_designer(): void { $product = wc_get_product( get_the_ID() ); if ( wp_is_block_theme() || ! $product || ! self::is_designable( $product ) ) { return; } remove_action( 'woocommerce_before_single_product_summary', 'woocommerce_show_product_sale_flash', 10 ); remove_action( 'woocommerce_before_single_product_summary', 'woocommerce_show_product_images', 20 ); remove_all_actions( 'woocommerce_single_product_summary' ); add_action( 'woocommerce_before_single_product_summary', static function () use ( $product ): void { echo render_block( self::designer_block( $product->get_id() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The designer block's own markup, escaped in its render.php. } ); } /** * The designer block on a product, as a parsed block for render_block() and serialize_block(). * * @param int $product_id The product. * @param array $attrs More of the block's attributes (align). */ public static function designer_block( int $product_id, array $attrs = array() ): array { return array( 'blockName' => 'printblob/designer', 'attrs' => array( 'productId' => $product_id ) + $attrs, 'innerBlocks' => array(), 'innerHTML' => '', 'innerContent' => array(), ); } /** * Products with the designer on, in any status but the trash, last changed first. * * @param int $limit How many IDs to return. * @return array{ids:int[], total:int} */ public static function designer_products( int $limit ): array { $query = new \WP_Query( array( 'post_type' => 'product', 'post_status' => 'any', 'posts_per_page' => $limit, 'fields' => 'ids', 'orderby' => 'modified', 'order' => 'DESC', 'meta_key' => self::META_ENABLED, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- The products with the designer on, for the overview and the first steps. 'meta_value' => 'yes', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- As above. ) ); return array( 'ids' => array_map( 'intval', $query->posts ), 'total' => (int) $query->found_posts, ); } public static function is_enabled( \WC_Product $product ): bool { $parent = $product->get_parent_id() ? wc_get_product( $product->get_parent_id() ) : $product; return $parent && 'yes' === $parent->get_meta( self::META_ENABLED ); } public static function side_price( \WC_Product $product, string $side ): float { $parent = $product->get_parent_id() ? wc_get_product( $product->get_parent_id() ) : $product; return $parent ? (float) $parent->get_meta( self::META_PRICE . $side ) : 0.0; } public static function add_tab( array $tabs ): array { $tabs['printblob_designer'] = array( 'label' => self::tab_label(), 'target' => 'printblob_designer_panel', 'class' => array( 'show_if_simple', 'show_if_variable' ), 'priority' => 65, ); return $tabs; } public static function tab_label(): string { return __( 'PrintBlob designer', 'printblob' ); } public static function render_panel(): void { global $product_object; $product = $product_object instanceof \WC_Product ? $product_object : null; $choices = Mockups::choices(); $chosen = $product ? (int) $product->get_meta( Mockups::PRODUCT_META ) : 0; $current = isset( $choices[ $chosen ] ) ? $chosen : (int) get_option( Mockups::DEFAULT_OPTION ); $printed = $current ? Mockups::printed_sides( $current ) : array(); echo ''; } /** * The tab's mockup choice: each mockup with the sides it prints and where it opens, and a link to the chosen one. * * @param array $choices The mockups, ID => name (Mockups::choices()). * @param int $current The chosen mockup, else the default. */ private static function mockup_field( array $choices, int $current ): void { $id = Mockups::PRODUCT_META; ?>

get_attributes() : array() as $attribute ) { if ( $attribute instanceof \WC_Product_Attribute ) { $attributes[ $attribute->get_name() ] = $attribute; } } $values = static function ( \WC_Product_Attribute $attribute ): string { $count = count( $attribute->get_options() ); /* translators: %d: how many values an attribute has on the product. */ return sprintf( _n( '%d value', '%d values', $count, 'printblob' ), $count ); }; $colour = Settings::color_attribute(); $size = Settings::size_attribute(); $lines = array(); if ( isset( $attributes[ $colour ] ) ) { /* translators: 1: the colour attribute's name, 2: how many values it has, e.g. "6 values". */ $lines[] = esc_html( sprintf( __( '%1$s: %2$s, chosen as the colour', 'printblob' ), wc_attribute_label( $colour, $product ), $values( $attributes[ $colour ] ) ) ); } else { /* translators: %s: the colour attribute's name. */ $lines[] = esc_html( sprintf( __( '%s: not on this product', 'printblob' ), wc_attribute_label( $colour, $product ) ) ); } if ( '' !== $size && isset( $attributes[ $size ] ) ) { /* translators: 1: the size attribute's name, 2: how many values it has, e.g. "6 values". */ $lines[] = esc_html( sprintf( __( '%1$s: %2$s, chosen as the size', 'printblob' ), wc_attribute_label( $size, $product ), $values( $attributes[ $size ] ) ) ); } elseif ( '' !== $size ) { /* translators: %s: the size attribute's name. */ $lines[] = esc_html( sprintf( __( '%s: not on this product', 'printblob' ), wc_attribute_label( $size, $product ) ) ); } foreach ( $attributes as $name => $attribute ) { if ( ! $attribute->get_variation() || in_array( $name, array( $colour, $size ), true ) ) { continue; } if ( Settings::pro_active() ) { /* translators: 1: an attribute's name, 2: how many values it has, e.g. "3 values". */ $lines[] = esc_html( sprintf( __( '%1$s: %2$s, chosen as an option', 'printblob' ), wc_attribute_label( $name, $product ), $values( $attribute ) ) ); continue; } /* translators: 1: an attribute's name, 2: how many values it has, e.g. "3 values". */ $lines[] = esc_html( sprintf( __( '%1$s: %2$s', 'printblob' ), wc_attribute_label( $name, $product ), $values( $attribute ) ) ) . ' ' . Pro::lock_html( 'product-options' ); } ?>
get_id() ) ) { return; } // phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified above. $product->update_meta_data( self::META_ENABLED, isset( $_POST[ self::META_ENABLED ] ) ? 'yes' : 'no' ); $mockup = isset( $_POST[ Mockups::PRODUCT_META ] ) ? absint( $_POST[ Mockups::PRODUCT_META ] ) : 0; $product->update_meta_data( Mockups::PRODUCT_META, Mockups::POST_TYPE === get_post_type( $mockup ) ? $mockup : '' ); foreach ( Mockups::sides() as $side ) { $key = self::META_PRICE . $side; $raw = isset( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) : ''; $product->update_meta_data( $key, '' === $raw ? '' : wc_format_decimal( $raw ) ); } // phpcs:enable } public static function side_label( string $side ): string { return Mockups::side_label( $side ); } }