[family, size in the family's smallest unit]. */ public const UNITS = array( 'g' => array( 'mass', 1 ), 'kg' => array( 'mass', 1000 ), 'ml' => array( 'volume', 1 ), 'l' => array( 'volume', 1000 ), 'pc' => array( 'count', 1 ), 'm' => array( 'length', 1 ) ); /** Base choice => [quantity, unit]. */ public const BASES = array( '100g' => array( 100, 'g' ), '1kg' => array( 1, 'kg' ), '100ml' => array( 100, 'ml' ), '1l' => array( 1, 'l' ), '1pc' => array( 1, 'pc' ), '1m' => array( 1, 'm' ) ); /** "Automatic" base per family: the legal units of Turkish and EU unit pricing (kilogram, litre, piece, metre). */ private const DEFAULT_BASE = array( 'mass' => '1kg', 'volume' => '1l', 'count' => '1pc', 'length' => '1m' ); /** UN/CEFACT common codes (schema.org unitCode) and Google Merchant Center unit names. */ private const CEFACT = array( 'g' => 'GRM', 'kg' => 'KGM', 'ml' => 'MLT', 'l' => 'LTR', 'pc' => 'C62', 'm' => 'MTR' ); private const GOOGLE = array( 'g' => 'g', 'kg' => 'kg', 'ml' => 'ml', 'l' => 'l', 'pc' => 'ct', 'm' => 'm' ); private const MAX_QTY = 1000000; public static function hooks(): void { add_action( 'woocommerce_product_options_general_product_data', array( __CLASS__, 'fields' ) ); add_action( 'woocommerce_admin_process_product_object', array( __CLASS__, 'save' ) ); add_action( 'woocommerce_variation_options_pricing', array( __CLASS__, 'variation_fields' ), 20, 3 ); add_action( 'woocommerce_admin_process_variation_object', array( __CLASS__, 'save_variation' ), 10, 2 ); add_filter( 'storeruler_unit_measure', array( __CLASS__, 'measure_filter' ), 10, 2 ); add_filter( 'woocommerce_structured_data_product_offer', array( __CLASS__, 'schema_offer' ), 20, 2 ); } /* ---------- Reading ---------- */ /** * The measure of a product or option, or null: qty, unit (the net quantity), base_qty, base_unit. An option's empty * fields take the product's values. A base of another kind than the unit (litres for grams) falls back to the automatic one. */ public static function measure( \WC_Product $product ): ?array { $own = self::stored( $product ); if ( $product->is_type( 'variation' ) && $product->get_parent_id() && in_array( '', $own, true ) ) { // The product's saved values, read directly: building the whole product for three fields costs a card its speed. $parent = $product->get_parent_id(); $from = array( 'qty' => (string) get_post_meta( $parent, self::QTY, true ), 'unit' => (string) get_post_meta( $parent, self::UNIT, true ), 'base' => (string) get_post_meta( $parent, self::BASE, true ) ); foreach ( $own as $key => $value ) { if ( $value === '' ) { $own[ $key ] = $from[ $key ]; } } } $qty = is_numeric( $own['qty'] ) ? (float) $own['qty'] : 0.0; if ( $qty <= 0 || $qty > self::MAX_QTY || ! isset( self::UNITS[ $own['unit'] ] ) ) { return null; } $family = self::UNITS[ $own['unit'] ][0]; $base = isset( self::BASES[ $own['base'] ] ) && self::UNITS[ self::BASES[ $own['base'] ][1] ][0] === $family ? $own['base'] : self::DEFAULT_BASE[ $family ]; return array( 'qty' => $qty, 'unit' => $own['unit'], 'base_qty' => (float) self::BASES[ $base ][0], 'base_unit' => self::BASES[ $base ][1] ); } /** The three stored values as entered ('' when empty). */ private static function stored( \WC_Product $product ): array { return array( 'qty' => (string) $product->get_meta( self::QTY ), 'unit' => (string) $product->get_meta( self::UNIT ), 'base' => (string) $product->get_meta( self::BASE ) ); } /** Filter storeruler_unit_measure ($measure, $product): the theme's unit price line. */ public static function measure_filter( $measure, $product = null ) { return $product instanceof \WC_Product ? self::measure( $product ) : $measure; } /** "250g", "1.5kg": Google's form (a number and a unit, a point for decimals). */ private static function google_value( float $qty, string $unit ): string { return rtrim( rtrim( number_format( $qty, 3, '.', '' ), '0' ), '.' ) . self::GOOGLE[ $unit ]; } /** The feed attributes for a product or option: unit_pricing_measure and unit_pricing_base_measure, or none. */ public static function feed_measures( \WC_Product $product ): array { $measure = self::measure( $product ); if ( ! $measure ) { return array(); } return array( 'unit_pricing_measure' => self::google_value( $measure['qty'], $measure['unit'] ), 'unit_pricing_base_measure' => self::google_value( $measure['base_qty'], $measure['base_unit'] ) ); } /** * Structured data: the offer's current price specification says what quantity that price buys and what the unit price is * given for (Google's merchant listing "unit pricing measure"): referenceQuantity = the base, valueReference = the net * quantity. Simple products only (a variable product's AggregateOffer has no single quantity). */ public static function schema_offer( $offer, $product = null ) { if ( ! is_array( $offer ) || ! $product instanceof \WC_Product || ! $product->is_type( 'simple' ) || ( $offer['@type'] ?? '' ) !== 'Offer' || empty( $offer['priceSpecification'] ) || ! is_array( $offer['priceSpecification'] ) ) { return $offer; } $measure = self::measure( $product ); if ( ! $measure ) { return $offer; } $reference = array( '@type' => 'QuantitativeValue', 'value' => $measure['base_qty'], 'unitCode' => self::CEFACT[ $measure['base_unit'] ], 'valueReference' => array( '@type' => 'QuantitativeValue', 'value' => $measure['qty'], 'unitCode' => self::CEFACT[ $measure['unit'] ] ), ); foreach ( $offer['priceSpecification'] as $i => $spec ) { // The price in force only: a struck-through list price (priceType) is not what the quantity costs now. if ( is_array( $spec ) && ( $spec['@type'] ?? '' ) === 'UnitPriceSpecification' && empty( $spec['priceType'] ) ) { $offer['priceSpecification'][ $i ]['referenceQuantity'] = $reference; } } return $offer; } /* ---------- Product screen ---------- */ private static function unit_choices(): array { return array( 'g' => I18n::t( 'g (gram)' ), 'kg' => I18n::t( 'kg (kilogram)' ), 'ml' => I18n::t( 'ml (millilitre)' ), 'l' => I18n::t( 'l (litre)' ), 'pc' => I18n::t( 'pieces' ), 'm' => I18n::t( 'm (metre)' ) ); } private static function base_choices(): array { return array( '' => I18n::t( 'Automatic (1 kg, 1 l, 1 piece or 1 m)' ), '100g' => '100 g', '1kg' => '1 kg', '100ml' => '100 ml', '1l' => '1 l', '1pc' => I18n::t( '1 piece' ), '1m' => '1 m' ); } /** The choices of the product screen, for StoreRuler's own Products workspace: units, bases (with what they mean) and the automatic base per family. */ public static function choices(): array { return array( 'units'=>self::unit_choices(), 'bases'=>self::base_choices(), 'sizes'=>self::UNITS, 'base_values'=>self::BASES, 'auto'=>self::DEFAULT_BASE, 'max'=>self::MAX_QTY ); } public static function fields(): void { global $product_object; $product = $product_object instanceof \WC_Product ? $product_object : null; $value = static fn( string $key ): string => $product ? (string) $product->get_meta( $key ) : ''; echo '
'; woocommerce_wp_text_input( array( 'id' => 'sr_net_qty', 'value' => $value( self::QTY ), 'label' => I18n::t( 'Net quantity' ), 'type' => 'number', 'custom_attributes' => array( 'step' => 'any', 'min' => '0' ), 'desc_tip' => true, 'description' => I18n::t( 'Only for goods sold by weight, volume, length or count (food, cosmetics, cleaning…). The storefront then shows the unit price, for example "100 g: 49,96". Leave empty and no unit price is shown.' ) ) ); woocommerce_wp_select( array( 'id' => 'sr_net_unit', 'value' => $value( self::UNIT ) ?: 'g', 'label' => I18n::t( 'Unit' ), 'options' => self::unit_choices() ) ); woocommerce_wp_select( array( 'id' => 'sr_unit_base', 'value' => $value( self::BASE ), 'label' => I18n::t( 'Unit price per' ), 'options' => self::base_choices(), 'desc_tip' => true, 'description' => I18n::t( 'The quantity the unit price is given for. A choice of another kind than the unit (litres for grams) uses the automatic one.' ) ) ); echo '
'; } /** Saved with the product (WooCommerce checked the nonce and the user's rights before this action). */ public static function save( $product ): void { if ( ! $product instanceof \WC_Product ) { return; } // phpcs:disable WordPress.Security.NonceVerification.Missing -- WooCommerce's product save verified the nonce. $pick = static fn( string $key ) => isset( $_POST[ $key ] ) && is_scalar( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) : ''; self::apply( $product, $pick( 'sr_net_qty' ), $pick( 'sr_net_unit' ), $pick( 'sr_unit_base' ), false ); // phpcs:enable } public static function variation_fields( $loop, $variation_data, $variation ): void { $product = wc_get_product( $variation instanceof \WP_Post ? $variation->ID : 0 ); if ( ! $product instanceof \WC_Product ) { return; } $loop = (int) $loop; echo '
'; woocommerce_wp_text_input( array( 'id' => 'sr_net_qty_' . $loop, 'name' => 'sr_net_qty[' . $loop . ']', 'value' => (string) $product->get_meta( self::QTY ), 'label' => I18n::t( 'Net quantity' ), 'type' => 'number', 'placeholder' => I18n::t( 'As the product' ), 'custom_attributes' => array( 'step' => 'any', 'min' => '0' ), 'wrapper_class' => 'form-row form-row-first', 'desc_tip' => true, 'description' => I18n::t( 'Empty: the product\'s net quantity. Without one, no unit price is shown.' ) ) ); woocommerce_wp_select( array( 'id' => 'sr_net_unit_' . $loop, 'name' => 'sr_net_unit[' . $loop . ']', 'value' => (string) $product->get_meta( self::UNIT ), 'label' => I18n::t( 'Unit' ), 'options' => array( '' => I18n::t( 'As the product' ) ) + self::unit_choices(), 'wrapper_class' => 'form-row form-row-last' ) ); woocommerce_wp_select( array( 'id' => 'sr_unit_base_' . $loop, 'name' => 'sr_unit_base[' . $loop . ']', 'value' => (string) $product->get_meta( self::BASE ), 'label' => I18n::t( 'Unit price per' ), 'options' => array( '' => I18n::t( 'As the product' ) ) + array_slice( self::base_choices(), 1, null, true ), 'wrapper_class' => 'form-row form-row-full' ) ); echo '
'; } /** Saved with the option (WooCommerce's variation save checked its nonce and the user's rights). */ public static function save_variation( $variation, $i ): void { if ( ! $variation instanceof \WC_Product ) { return; } $i = (int) $i; // phpcs:disable WordPress.Security.NonceVerification.Missing -- WooCommerce's variation save verified the nonce. $pick = static fn( string $key ) => isset( $_POST[ $key ][ $i ] ) && is_array( $_POST[ $key ] ) && is_scalar( $_POST[ $key ][ $i ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ][ $i ] ) ) : ''; // phpcs:enable self::apply( $variation, $pick( 'sr_net_qty' ), $pick( 'sr_net_unit' ), $pick( 'sr_unit_base' ), true ); } /** * Stores clean values on the object (saved by WooCommerce right after). A product without a net quantity keeps none of * the three; an option keeps each field it sets and inherits the rest. */ public static function apply( \WC_Product $product, $qty, $unit, $base, bool $option ): void { $qty = is_scalar( $qty ) ? trim( str_replace( ',', '.', (string) $qty ) ) : ''; $qty = is_numeric( $qty ) && (float) $qty > 0 && (float) $qty <= self::MAX_QTY ? wc_format_decimal( $qty ) : ''; $unit = is_scalar( $unit ) && isset( self::UNITS[ (string) $unit ] ) ? (string) $unit : ''; $base = is_scalar( $base ) && isset( self::BASES[ (string) $base ] ) ? (string) $base : ''; if ( ! $option && $qty === '' ) { $unit = ''; $base = ''; } foreach ( array( self::QTY => $qty, self::UNIT => $unit, self::BASE => $base ) as $key => $value ) { if ( $value === '' ) { $product->delete_meta_data( $key ); } else { $product->update_meta_data( $key, $value ); } } } }