references = $references; $this->recorder = $recorder; } public function register(): void { add_filter( 'woocommerce_get_price_html', array( $this, 'filter_price_html' ), 20, 2 ); add_shortcode( 'strikewise', array( $this, 'shortcode' ) ); // The name of version 1.0.0, kept for pages that already use it. add_shortcode( 'priceproof', array( $this, 'shortcode' ) ); add_action( 'init', array( $this, 'register_block' ) ); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_style' ) ); } /** * @param string $html * @param mixed $product * @return string */ public function filter_price_html( $html, $product ) { if ( ! $product instanceof \WC_Product || ( is_admin() && ! wp_doing_ajax() ) ) { return $html; } $single = self::is_single_context( $product ); if ( ( $single && ! Settings::show_single() ) || ( ! $single && ! Settings::show_loop() ) ) { return $html; } return $html . $this->render( $product ); } /** * [strikewise id="123"] – without an id the current product is used. * * @param array|string $atts */ public function shortcode( $atts ): string { $atts = shortcode_atts( array( 'id' => 0 ), (array) $atts, 'strikewise' ); $product = self::resolve_product( (int) $atts['id'] ); return $product ? $this->render( $product ) : ''; } public function register_block(): void { wp_register_script( 'strikewise-block', STRIKEWISE_URL . 'assets/block.js', array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-server-side-render', 'wp-i18n' ), STRIKEWISE_VERSION, true ); wp_set_script_translations( 'strikewise-block', 'strikewise' ); register_block_type( 'strikewise/lowest-price', array( 'api_version' => 3, 'editor_script' => 'strikewise-block', 'uses_context' => array( 'postId' ), 'attributes' => array( 'productId' => array( 'type' => 'integer', 'default' => 0, ), ), 'render_callback' => array( $this, 'render_block' ), ) ); } /** * @param array $attributes * @param string $content * @param \WP_Block|null $block */ public function render_block( $attributes, $content = '', $block = null ): string { $id = (int) ( $attributes['productId'] ?? 0 ); if ( ! $id && $block instanceof \WP_Block && isset( $block->context['postId'] ) ) { $id = (int) $block->context['postId']; } $product = self::resolve_product( $id ); $html = $product ? $this->render( $product ) : ''; // Preview in the block editor, where most products are not reduced. if ( '' === $html && defined( 'REST_REQUEST' ) && REST_REQUEST ) { $html = '' . esc_html( str_replace( '{price}', wp_strip_all_tags( wc_price( 99 ) ), Settings::text() ) ) . ''; } return $html; } public function enqueue_style(): void { wp_register_style( 'strikewise', false, array(), STRIKEWISE_VERSION ); wp_enqueue_style( 'strikewise' ); wp_add_inline_style( 'strikewise', '.' . self::CSS_CLASS . '{display:block;font-size:.85em;font-weight:normal;opacity:.85;margin-top:.25em}' ); } /** * The notice for one product, or '' if nothing has to be shown. */ public function render( \WC_Product $product ): string { if ( $product->is_type( 'variable' ) ) { return $this->render_variable( $product ); } // § 11 Abs. 4 PAngV: no lowest price, but the reason has to be recognisable. if ( Perishable::applies( $product ) ) { return $this->wrap( esc_html( Perishable::text() ) ); } $reference = $this->reference_for( $product ); if ( null === $reference ) { // Not reduced itself, but part of a public coupon campaign after a price raise. $campaign = $product->is_on_sale( 'edit' ) ? null : $this->references->campaign_reference( $product ); if ( null === $campaign ) { return ''; } $price = wc_price( PriceResolver::to_display( $product, $campaign ), array( 'currency' => PriceResolver::currency() ) ); return $this->wrap( str_replace( '{price}', $price, esc_html( Settings::text() ) ) ); } if ( $reference->has_price() ) { $price = wc_price( PriceResolver::to_display( $product, (float) $reference->price ), array( 'currency' => PriceResolver::currency() ) ); return $this->wrap( str_replace( '{price}', $price, esc_html( Settings::text() ) ) ); } if ( Reference::STATUS_NO_HISTORY === $reference->status && 'text' === Settings::fallback() ) { return $this->wrap( esc_html( Settings::fallback_text() ) ); } return ''; } /** * Variable products in lists: "from X" over the reduced variations. * On the product page the selected variation renders its own notice. */ private function render_variable( \WC_Product $product ): string { if ( self::is_single_context( $product ) ) { return ''; } $children = $product->get_children(); if ( empty( $children ) ) { return ''; } update_meta_cache( 'post', $children ); // In reduction mode only variations with a sale price, or a cached reduction // to clean up, can show a lowest price; the others are not loaded at all. $reduction_mode = ReferenceCalculator::MODE_ALWAYS !== Settings::mode(); $prices = array(); foreach ( $children as $child_id ) { if ( $reduction_mode && '' === (string) get_post_meta( $child_id, '_sale_price', true ) && '' === (string) get_post_meta( $child_id, ReferenceService::META_T0, true ) ) { continue; } $child = wc_get_product( $child_id ); if ( ! $child || Perishable::applies( $child ) ) { continue; } $reference = $this->reference_for( $child ); if ( $reference && $reference->has_price() ) { $prices[] = round( PriceResolver::to_display( $child, (float) $reference->price ), 2 ); } } if ( ! $prices ) { return ''; } // "from" only when the reduced variations have different lowest prices. $price = wc_price( min( $prices ), array( 'currency' => PriceResolver::currency() ) ); if ( count( array_unique( $prices ) ) > 1 ) { /* translators: %s: price */ $price = sprintf( esc_html__( 'from %s', 'strikewise' ), $price ); } return $this->wrap( str_replace( '{price}', $price, esc_html( Settings::text() ) ) ); } /** * Reference to show, or null if the product is not reduced (reduction mode). * Self-heals when a scheduled sale started without a product save. */ private function reference_for( \WC_Product $product ): ?Reference { $reference = $this->references->get( $product ); // 'edit' context: the stored sale, unaffected by discount plugins filtering the price. $reduced = $product->is_on_sale( 'edit' ); $cache_says_reduced = null !== $reference->t0; if ( $reduced !== $cache_says_reduced ) { $reference = $this->recorder->record( $product, 'lazy' ) ? $this->references->get( $product ) : $this->references->refresh( $product ); } if ( Reference::STATUS_NOT_REDUCED === $reference->status || Reference::STATUS_NO_DATA === $reference->status ) { return null; } // Reduction mode only shows the notice while the product is actually reduced. if ( ! $reduced && null !== $reference->t0 ) { return null; } // No reduction is announced on the page (e.g. CJEU mode hides one that is // not below the lowest price), so there is nothing to explain. if ( null !== $reference->t0 && ! $product->is_on_sale() ) { return null; } return $reference; } private function wrap( string $inner_html ): string { return ' ' . $inner_html . ''; } private static function is_single_context( \WC_Product $product ): bool { if ( ! is_singular( 'product' ) && ! wp_doing_ajax() ) { return false; } if ( wp_doing_ajax() ) { // Variation prices are loaded via AJAX on the product page. return $product->is_type( 'variation' ); } $queried = get_queried_object_id(); return $product->get_id() === $queried || $product->get_parent_id() === $queried; } private static function resolve_product( int $id ): ?\WC_Product { if ( ! $id ) { global $product; if ( $product instanceof \WC_Product ) { return $product; } $id = (int) get_the_ID(); } $found = $id ? wc_get_product( $id ) : null; return $found instanceof \WC_Product ? $found : null; } }