repository = $repository; $this->references = $references; } public function register(): void { add_action( 'add_meta_boxes_product', array( $this, 'add_box' ) ); add_filter( 'get_user_option_meta-box-order_product', array( $this, 'place_below_product_data' ) ); add_action( 'woocommerce_admin_process_product_object', array( $this, 'save_prior_price' ), 30 ); } public function add_box(): void { add_meta_box( 'strikewise-history', __( 'Strikewise – price history', 'strikewise' ), array( $this, 'render' ), 'product', 'normal', 'core' ); } /** * WooCommerce saves a box order for the product editor. Our box goes right * below "Product data", unless the user already placed it somewhere. * * @param mixed $order * @return mixed */ public function place_below_product_data( $order ) { if ( ! is_array( $order ) || empty( $order['normal'] ) || false !== strpos( implode( ',', $order ), 'strikewise-history' ) ) { return $order; } $boxes = explode( ',', $order['normal'] ); $index = array_search( 'woocommerce-product-data', $boxes, true ); array_splice( $boxes, false === $index ? 0 : $index + 1, 0, 'strikewise-history' ); $order['normal'] = implode( ',', $boxes ); return $order; } /** * @param \WP_Post $post */ public function render( $post ): void { $product = wc_get_product( $post->ID ); if ( ! $product ) { return; } $variable = $product->is_type( 'variable' ); $rows = $this->repository->get_recent_for_product( $product->get_id(), 100 ); if ( ! $variable && null !== PriceResolver::snapshot( $product ) ) { $this->render_overview( $product ); } elseif ( $variable ) { echo '

i' . esc_html__( 'Each variation has its own price history and lowest price. Open a variation to change its prices.', 'strikewise' ) . '

'; } if ( empty( $rows ) ) { echo '
' . esc_html__( 'No prices recorded yet', 'strikewise' ) . '' . esc_html__( 'Strikewise records every price change from now on.', 'strikewise' ) . '
'; } else { $this->render_history( $rows, $variable ); } $this->render_footer( $product ); } private function render_overview( \WC_Product $product ): void { $reference = $this->references->refresh( $product ); $snapshot = PriceResolver::snapshot( $product ); $reduced = $snapshot['reduced']; /** * Whether the shop shows this product as a price comparison (e.g. with the * manufacturer's recommended price) instead of a reduction. Used by the premium UVP mode. * * @param bool $comparison * @param \WC_Product $product */ $comparison = (bool) apply_filters( 'strikewise_is_price_comparison', false, $product ); echo '
'; // The price as customers see it: CJEU mode may change the strike-through price or hide the reduction. $shown = $reduced && $product->is_on_sale(); $current = esc_html( PriceChart::price( $snapshot['price'] ) ); if ( $shown && '' !== $product->get_regular_price() ) { $current = '' . esc_html( PriceChart::price( PriceResolver::to_gross( $product, (float) $product->get_regular_price() ) ) ) . '' . $current; } if ( $comparison ) { $pill = '' . esc_html__( 'Price comparison', 'strikewise' ) . ''; } elseif ( $shown ) { $pill = '' . esc_html__( 'Reduced', 'strikewise' ) . ''; } elseif ( $reduced ) { $pill = '' . esc_html__( 'Not shown as a reduction', 'strikewise' ) . ''; } else { $pill = '' . esc_html__( 'Regular price', 'strikewise' ) . ''; } $this->stat( __( 'In the shop', 'strikewise' ), $current, $pill ); // Lowest price before the reduction. if ( $comparison ) { $this->stat( __( 'Lowest price before', 'strikewise' ), '–', esc_html__( 'not needed for a price comparison', 'strikewise' ) ); } elseif ( $reference->has_price() && null !== $reference->t0 ) { $this->stat( __( 'Lowest price before', 'strikewise' ), esc_html( PriceChart::price( (float) $reference->price ) ), esc_html__( 'shown in the shop', 'strikewise' ) ); } elseif ( Reference::STATUS_NO_HISTORY === $reference->status ) { $this->stat( __( 'Lowest price before', 'strikewise' ), '–', '' . esc_html__( 'unknown', 'strikewise' ) . '' ); } else { $this->stat( __( 'Lowest price before', 'strikewise' ), '–', esc_html__( 'only needed for reductions', 'strikewise' ) ); } // Reduction start and window. if ( null !== $reference->t0 ) { $window_start = $reference->t0 - \Strikewise\Admin\Settings::window_days() * DAY_IN_SECONDS; $this->stat( __( 'Reduced since', 'strikewise' ), esc_html( wp_date( get_option( 'date_format' ), $reference->t0 ) ), /* translators: 1: start date, 2: end date */ esc_html( sprintf( __( 'compared with %1$s – %2$s', 'strikewise' ), wp_date( 'j.n.', $window_start ), wp_date( 'j.n.', $reference->t0 ) ) ) ); } else { $this->stat( __( 'Reduced since', 'strikewise' ), '–', esc_html__( 'not reduced', 'strikewise' ) ); } echo '
'; $chart = PriceChart::render( $this->repository->get_rows( $product->get_id(), PriceResolver::currency() ), $reference, Settings::window_days(), time() ); if ( '' !== $chart ) { echo '

' . esc_html__( 'Price history', 'strikewise' ) . '

'; echo wp_kses( $chart, Html::allowed() ); echo wp_kses( PriceChart::legend( null !== $reference->t0 ), Html::allowed() ); echo '
'; } $callouts = $this->callouts( $product, $reference ); if ( '' !== $callouts ) { echo '
' . wp_kses( $callouts, Html::allowed() ) . '
'; } } private function callouts( \WC_Product $product, Reference $reference ): string { $html = ''; if ( Perishable::applies( $product ) ) { return self::callout( 'info', 'i', __( 'Marked as perishable: no lowest price is required while it is reduced because it is about to spoil or expire (§ 11 Abs. 4 PAngV). The shop shows the reason instead.', 'strikewise' ) ); } if ( ReferenceService::launched_reduced( $product, $reference ) ) { $html .= self::callout( 'critical', '!', __( 'Not a price reduction: the product has been reduced since it was added and was never offered at a higher price. CJEU mode does not show it as a reduction.', 'strikewise' ) ); } elseif ( Reference::STATUS_NO_HISTORY === $reference->status ) { $html .= self::callout( 'warning', '!', CjeuMode::is_enabled() && CjeuMode::hides_unknown() ? __( 'No price from before the reduction is known, so the shop does not show it as a reduction. If you know the lowest price of the 30 days before the reduction from your own records, enter it here.', 'strikewise' ) : __( 'No price from before the reduction is known. A strike-through price without the earlier price is not allowed. Enter the earlier price here if you know it.', 'strikewise' ) ); $html .= $this->prior_price_field( $product ); } if ( $reference->has_price() && null !== $reference->t0 && ! $reference->complete ) { $html .= self::callout( 'info', 'i', __( 'The recorded history does not cover the full 30 days yet. The lowest known price is used.', 'strikewise' ) ); } $regular = $product->get_regular_price( 'edit' ); $snapshot = PriceResolver::snapshot( $product ); $cjeu = CjeuMode::is_enabled(); if ( $reference->has_price() && null !== $reference->t0 && null !== $snapshot ) { $lowest = PriceChart::price( (float) $reference->price ); if ( (float) $reference->price - $snapshot['price'] <= 0.005 ) { // Not below the lowest price of the 30 days before: not a reduction at all. $html .= $cjeu ? self::callout( 'ok', '✓', /* translators: %s: lowest price */ sprintf( __( 'The sale price is not below the lowest price of the 30 days before the reduction (%s). CJEU mode does not show it as a reduction in the shop.', 'strikewise' ), $lowest ) ) : self::callout( 'critical', '!', /* translators: %s: lowest price */ sprintf( __( 'Not a price reduction: the sale price is not below the lowest price of the 30 days before the reduction (%s). Lower the sale price or remove it.', 'strikewise' ), $lowest ) ); } elseif ( '' !== $regular && abs( PriceResolver::to_gross( $product, (float) $regular ) - (float) $reference->price ) >= 0.005 ) { $strike = PriceChart::price( PriceResolver::to_gross( $product, (float) $regular ) ); $html .= $cjeu ? self::callout( 'ok', '✓', /* translators: 1: lowest price, 2: regular price */ sprintf( __( 'CJEU mode shows %1$s as strike-through price in the shop instead of the regular price (%2$s).', 'strikewise' ), $lowest, $strike ) ) : self::callout( 'warning', '!', sprintf( /* translators: 1: regular price, 2: reference price */ __( 'The strike-through price (%1$s) differs from the lowest price of the 30 days before the reduction (%2$s). According to the CJEU (C-330/23), a percentage or strike-through price must refer to the lowest price. Set the regular price to it or turn on CJEU mode.', 'strikewise' ), $strike, $lowest ) ); } } /** * Filters the hints shown in the price history box (the premium UVP mode replaces them). * * @param string $html * @param \WC_Product $product * @param Reference $reference */ return (string) apply_filters( 'strikewise_box_callouts', $html, $product, $reference ); } /** * @param array> $rows Newest first. */ private function render_history( array $rows, bool $variable ): void { // Simple products: figures, chart and hints say what matters, the full log is one click away. if ( ! $variable ) { echo '
'; /* translators: %d: number of entries */ echo esc_html( sprintf( _n( 'Recorded prices (%d entry)', 'Recorded prices (%d entries)', count( $rows ), 'strikewise' ), count( $rows ) ) ); echo ''; $this->history_table( $rows, $variable ); echo '
'; return; } echo '
'; echo '

' . esc_html__( 'Recorded prices', 'strikewise' ) . '

'; $this->history_table( array_slice( $rows, 0, self::VISIBLE_ROWS ), $variable ); $more = array_slice( $rows, self::VISIBLE_ROWS ); if ( $more ) { /* translators: %d: number of further entries */ echo '
' . esc_html( sprintf( _n( 'Show %d older entry', 'Show %d older entries', count( $more ), 'strikewise' ), count( $more ) ) ) . ''; $this->history_table( $more, $variable ); echo '
'; } echo '
'; } /** * @param array> $rows */ private function history_table( array $rows, bool $variable ): void { $format = get_option( 'date_format' ) . ', ' . get_option( 'time_format' ); echo ''; if ( $variable ) { echo ''; } echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ( $rows as $row ) { $user = $row['user_id'] ? get_userdata( (int) $row['user_id'] ) : false; echo ''; if ( $variable ) { $variation = wc_get_product( (int) $row['product_id'] ); $label = $variation ? wc_get_formatted_variation( $variation, true, false, false ) : ''; echo ''; } echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html__( 'Variation', 'strikewise' ) . '' . esc_html__( 'Valid from', 'strikewise' ) . '' . esc_html__( 'Valid until', 'strikewise' ) . '' . esc_html__( 'Price incl. VAT', 'strikewise' ) . '' . esc_html__( 'Regular price', 'strikewise' ) . '' . esc_html__( 'Source', 'strikewise' ) . '' . esc_html__( 'User', 'strikewise' ) . '
' . esc_html( '' !== $label ? $label : '#' . $row['product_id'] ) . '' . esc_html( wp_date( $format, Repository::to_timestamp( $row['valid_from'] ) ) ) . '' . ( null === $row['valid_to'] ? '' . esc_html__( 'current', 'strikewise' ) . '' : esc_html( wp_date( $format, Repository::to_timestamp( $row['valid_to'] ) ) ) ) . '' . esc_html( PriceChart::price( (float) $row['price'] ) ); if ( $row['is_reduced'] ) { echo ' ' . esc_html__( 'Sale', 'strikewise' ) . ''; } echo '' . ( null === $row['regular_price'] ? '–' : esc_html( PriceChart::price( (float) $row['regular_price'] ) ) ) . '' . esc_html( self::source_label( (string) $row['source'] ) ) . '' . ( $user ? esc_html( $user->display_name ) : '–' ) . '
'; } private function render_footer( \WC_Product $product ): void { ob_start(); /** * Fires in the footer of the price history box (used by the premium export). * * @param \WC_Product $product */ do_action( 'strikewise_history_box_actions', $product ); $actions = (string) ob_get_clean(); if ( '' === trim( $actions ) ) { return; } echo ''; } private function stat( string $label, string $value_html, string $hint_html ): void { echo '

' . esc_html( $label ) . '

'; echo '

' . wp_kses( $value_html, Html::allowed() ) . '

'; echo '

' . wp_kses( $hint_html, Html::allowed() ) . '

'; } private static function callout( string $tone, string $icon, string $text ): string { return '

' . esc_html( $icon ) . '' . esc_html( $text ) . '

'; } /** * Input for the earlier price; saved with the product. */ private function prior_price_field( \WC_Product $product ): string { if ( $product->is_type( 'variation' ) || ! current_user_can( 'edit_product', $product->get_id() ) ) { return ''; } $label = wc_prices_include_tax() || ! wc_tax_enabled() ? __( 'Earlier price', 'strikewise' ) : __( 'Earlier price (excl. VAT, as your prices are entered)', 'strikewise' ); return '

' . ' ' . '' . esc_html__( 'Recorded as the price of the 30 days before the reduction, with your name. Only enter a price you can prove.', 'strikewise' ) . '

'; } /** * Records the earlier price entered in the box as the price of the 30 days * before the reduction, if nothing is known about that time yet. * * @param \WC_Product $product */ public function save_prior_price( $product ): void { // phpcs:disable WordPress.Security.NonceVerification -- WooCommerce verified the product form nonce. if ( ! $product instanceof \WC_Product || empty( $_POST[ self::FIELD_PRIOR ] ) || ! $product->get_id() ) { return; } $amount = (float) wc_format_decimal( sanitize_text_field( wp_unslash( $_POST[ self::FIELD_PRIOR ] ) ) ); // phpcs:enable if ( $amount <= 0 || ! current_user_can( 'edit_product', $product->get_id() ) ) { return; } $currency = PriceResolver::currency(); $open = $this->repository->get_open( $product->get_id(), $currency ); if ( null === $open || ! $open['is_reduced'] ) { return; } $t0 = Repository::to_timestamp( $open['valid_from'] ); if ( $this->repository->earliest_start( $product->get_id(), $currency ) < $t0 ) { return; // Something is known about the time before the reduction already. } $change = new Change(); $change->product_id = $product->get_id(); $change->parent_id = $product->get_parent_id(); $change->currency = $currency; $change->price = PriceResolver::to_gross( $product, $amount ); $change->regular_price = $change->price; $change->reduced = false; $change->valid_from = Repository::to_datetime( $t0 - Settings::window_days() * DAY_IN_SECONDS ); $change->source = 'manual'; $change->user_id = get_current_user_id(); $this->repository->insert_interval( $change, Repository::to_datetime( $t0 ) ); $this->references->refresh( $product ); } private static function source_label( string $source ): string { $labels = array( 'editor' => __( 'Editor', 'strikewise' ), 'rest' => __( 'REST API', 'strikewise' ), 'cli' => __( 'WP-CLI', 'strikewise' ), 'schedule' => __( 'Scheduled sale', 'strikewise' ), 'cron' => __( 'Cron', 'strikewise' ), 'sync' => __( 'Sync', 'strikewise' ), 'seed' => __( 'Start', 'strikewise' ), 'lazy' => __( 'Storefront', 'strikewise' ), 'migration' => __( 'Omnibus import', 'strikewise' ), 'manual' => __( 'Entered manually', 'strikewise' ), ); return isset( $labels[ $source ] ) ? $labels[ $source ] : $source; } }