get_parent_id() && !current_user_can( 'edit_post', $p->get_parent_id() ) ) { throw new \RuntimeException(I18n::t( 'You do not have permission to edit this product.' )); } if ( !$p->is_type( array('simple', 'variable', 'variation') ) ) { throw new \InvalidArgumentException(I18n::t( 'Only simple products, products with options and their options are edited here.' )); } return $p; } /** A variable product's options (enabled and disabled), in the order WooCommerce shows them. */ private static function options( \WC_Product $p ) : array { if ( !$p->is_type( 'variable' ) ) { return array(); } global $wpdb; return array_map( 'intval', $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_parent=%d AND post_type='product_variation' AND post_status IN ('publish','private') ORDER BY menu_order,ID", $p->get_id() ) ) ); } /** The stored values this tab edits, for its version: the video, and each item's unit and pre-order fields. */ private static function stored( \WC_Product $p, array $options ) : array { $keys = array( UnitPricing::QTY, UnitPricing::UNIT, UnitPricing::BASE, '_sr_preorder', '_sr_preorder_date', '_sr_preorder_custom' ); $items = array(); foreach ( array_merge( array($p->get_id()), $options ) as $id ) { $row = array($id); foreach ( $keys as $key ) { $row[] = (string) maybe_serialize( get_post_meta( $id, $key, true ) ); } $items[] = $row; } return array( 'video' => maybe_serialize( get_post_meta( $p->get_id(), '_sr_product_video', true ) ), 'items' => $items, ); } private static function version( \WC_Product $p, array $options ) : string { return hash_hmac( 'sha256', 'product-extras|' . wp_json_encode( self::stored( $p, $options ) ), wp_salt( 'nonce' ) ); } /* ---------- Reading ---------- */ public static function read( array $data ) : array { $p = self::product( $data ); $options = self::options( $p ); $out = array( 'id' => $p->get_id(), 'name' => $p->get_name(), 'type' => $p->get_type(), 'version' => self::version( $p, $options ), 'parent_id' => (int) $p->get_parent_id(), 'storefront' => array( 'status' => $p->get_status(), 'visibility' => $p->get_catalog_visibility(), 'url' => get_permalink( ( $p->get_parent_id() ?: $p->get_id() ) ), 'own_image_id' => (int) $p->get_image_id( 'edit' ), 'image_url' => (string) wp_get_attachment_image_url( $p->get_image_id(), 'woocommerce_thumbnail' ), ), 'video' => null, 'video_parent' => $p->is_type( 'variation' ), 'unit' => self::unit_read( $p ), 'parent_unit' => null, 'unit_choices' => UnitPricing::choices(), 'preorder' => null, 'options' => array(), 'tr_flags' => self::tr_flags( $p ), 'theme' => self::video_available(), 'can_upload' => current_user_can( 'upload_files' ), ); if ( $p->is_type( 'variation' ) ) { $parent = wc_get_product( $p->get_parent_id() ); $out['parent_unit'] = ( $parent ? self::unit_read( $parent ) : null ); } if ( self::video_available() && !$p->is_type( 'variation' ) ) { $out['video'] = self::video_read( $p ); } $preorder = self::preorder_available(); if ( $preorder && !$p->is_type( 'variable' ) ) { $out['preorder'] = self::preorder_read( $p->get_id() ); } foreach ( $options as $id ) { $child = wc_get_product( $id ); if ( !$child ) { continue; } $row = array( 'id' => $id, 'name' => $child->get_name(), 'option' => wc_get_formatted_variation( $child, true, false, false ), 'enabled' => $child->get_status() === 'publish', 'price' => $child->get_price( 'edit' ), 'unit' => self::unit_read( $child ), 'preorder' => null, ); if ( $preorder ) { $row['preorder'] = self::preorder_read( $id ); } $out['options'][] = $row; } return $out; } /** The video field as the WooCommerce product screen's "Product video" box shows it, with its advice. */ private static function video_read( \WC_Product $p ) : array { $field = storeruler_video_field( $p ); $attachment = (int) ($field['attachment'] ?? 0); $url = ( $attachment ? (string) wp_get_attachment_url( $attachment ) : (string) ($field['url'] ?? '') ); if ( $field && $url === '' && in_array( $field['kind'], array( 'youtube', 'vimeo', 'bunny', 'cloudflare' ), true ) && function_exists( 'storeruler_video_embed_url' ) ) { $url = storeruler_video_embed_url( $field['kind'], $field['vid'], $field['extra'] ); } $poster = (int) ($field['poster'] ?? 0); $cover = ''; if ( function_exists( 'storeruler_product_video' ) && function_exists( 'storeruler_video_cover_url' ) ) { $video = storeruler_product_video( $p ); $cover = ( $video ? storeruler_video_cover_url( $video, 'thumbnail' ) : '' ); } return array( 'url' => $url, 'attachment' => $attachment, 'attachment_name' => ( $attachment ? wp_basename( (string) get_attached_file( $attachment ) ) : '' ), 'kind' => (string) ($field['kind'] ?? ''), 'poster' => $poster, 'poster_url' => ( $poster ? (string) wp_get_attachment_image_url( $poster, 'thumbnail' ) : '' ), 'cover_url' => $cover, 'position' => ( ($field['position'] ?? 'second') === 'last' ? 'last' : 'second' ), 'warnings' => storeruler_video_warnings( $attachment ), ); } /** An item's unit fields as entered, and the unit price it gives now (null without a net quantity). */ private static function unit_read( \WC_Product $item ) : array { return array( 'qty' => (string) $item->get_meta( UnitPricing::QTY ), 'unit' => (string) $item->get_meta( UnitPricing::UNIT ), 'base' => (string) $item->get_meta( UnitPricing::BASE ), 'preview' => self::unit_preview( $item ), ); } /** "1 kg: 199,60 ₺" for the price in force (the theme's own formula and label when it is active). */ private static function unit_preview( \WC_Product $item ) : ?array { if ( $item->is_type( array('variable', 'grouped', 'external') ) ) { return null; } $measure = UnitPricing::measure( $item ); if ( !$measure ) { return null; } $choices = UnitPricing::choices(); $base = ''; foreach ( $choices['base_values'] as $key => $value ) { if ( (float) $value[0] === (float) $measure['base_qty'] && $value[1] === $measure['base_unit'] ) { $base = (string) $choices['bases'][$key]; } } $price = $item->get_price( 'edit' ); if ( !is_numeric( $price ) || (float) $price <= 0 ) { return array( 'base' => $base, 'amount' => null, 'text' => '', ); } $sizes = $choices['sizes']; $amount = ( function_exists( 'storeruler_unit_amount' ) ? (float) storeruler_unit_amount( (float) $price, $measure ) : (float) $price / ($measure['qty'] * $sizes[$measure['unit']][1]) * ($measure['base_qty'] * $sizes[$measure['base_unit']][1]) ); return array( 'base' => $base, 'amount' => $amount, 'text' => $base . ': ' . html_entity_decode( wp_strip_all_tags( wc_price( $amount ) ), ENT_QUOTES, 'UTF-8' ), ); } private static function preorder_read( int $id ) : ?array { $out = null; return $out; } /* ---------- The Turkish 10-day rule's warnings ---------- */ /** * The rule's warnings for a product (each of its options for a product with options), as the WooCommerce products list * shows them: a short label ("Discount rule: the storefront shows 1.290 ₺") and the theme's full sentence. Empty while * the rule is off or the theme is not active. */ public static function tr_flags( \WC_Product $p ) : array { if ( !self::tr_available() ) { return array(); } $named = $p->is_type( 'variable' ); $out = array(); // Options in the same state share one explanation (gfirst marks the one row that shows it). // The flags are read once: each reading goes through every option's price record (admin speed, 28 Oct 2026). $grouped = array(); $seen = array(); $flags = storeruler_tr_price_flags( $p ); if ( function_exists( 'storeruler_tr_price_flag_groups' ) ) { foreach ( storeruler_tr_price_flag_groups( $flags ) as $group ) { $grouped[(int) $group['flag']['id']] = array( 'explain' => storeruler_tr_price_group_text( $group, 'explain' ), 'why' => storeruler_tr_price_group_text( $group, 'why' ), ); } } foreach ( $flags as $flag ) { $shown = html_entity_decode( wp_strip_all_tags( wc_price( $flag['shown'] ) ), ENT_QUOTES, 'UTF-8' ); $label = ( $flag['type'] === 'higher' ? I18n::format( 'Discount rule: the storefront shows %s', $shown ) : (( $flag['type'] === 'flat' ? I18n::t( 'Discount rule: no struck-through price' ) : I18n::t( 'Discount rule: price record incomplete' ) )) ); $out[] = array( 'type' => $flag['type'], 'id' => (int) $flag['id'], 'name' => (string) $flag['name'], 'option' => (string) ($flag['option'] ?? ''), 'regular' => (float) $flag['regular'], 'shown' => (float) $flag['shown'], 'days' => (int) ($flag['days'] ?? 10), 'mode' => (string) ($flag['mode'] ?? 'tr'), 'rule' => ( function_exists( 'storeruler_price_rule_label' ) ? storeruler_price_rule_label( (string) ($flag['mode'] ?? '') ) : '' ), 'label' => $label, 'text' => ( function_exists( 'storeruler_tr_price_flag_text' ) ? storeruler_tr_price_flag_text( $flag, $named ) : '' ), 'why' => ( function_exists( 'storeruler_tr_price_flag_why' ) ? storeruler_tr_price_flag_why( $flag ) : '' ), 'explain' => ( function_exists( 'storeruler_tr_price_flag_explain' ) ? storeruler_tr_price_flag_explain( $flag ) : '' ), 'gfirst' => isset( $grouped[(int) $flag['id']] ) || !$grouped, 'gexplain' => $grouped[(int) $flag['id']]['explain'] ?? '', 'gwhy' => $grouped[(int) $flag['id']]['why'] ?? '', ); } // The higher price first, as the WooCommerce list does (the theme sorts them: higher, flat, uncovered). return $out; } /* ---------- Saving ---------- */ /** * Saves the tab: $data id, version, request_key and any of video {url, attachment, poster, position}, unit {qty, unit, * base}, preorder {mode, date, custom} and options [{id, unit, preorder}]. Every value goes through the product screen's * own routine; a value it refuses stops the whole save (nothing is changed) with the reason. */ public static function save( array $data ) : array { self::product( $data ); return Store::once_request( 'product-extras', $data, static function () use($data) { global $wpdb; $p = self::product( $data ); $options = self::options( $p ); $ids = array_merge( array($p->get_id()), $options ); Store::lock_products( $ids ); // Store::once has read its key in this transaction: fresh locking reads, so the version compares what is saved now. $meta = $wpdb->get_results( $wpdb->prepare( "SELECT post_id,meta_key,meta_value FROM {$wpdb->postmeta} WHERE post_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') ORDER BY meta_id FOR UPDATE', $ids ), ARRAY_A ); $cache = array_fill_keys( $ids, array() ); foreach ( $meta as $m ) { $cache[(int) $m['post_id']][$m['meta_key']][] = $m['meta_value']; } foreach ( $ids as $id ) { clean_post_cache( $id ); wp_cache_set( $id, $cache[$id], 'post_meta' ); } $p = wc_get_product( $p->get_id() ); if ( !hash_equals( self::version( $p, $options ), Store::text( $data, 'version' ) ) ) { throw new \RuntimeException(I18n::t( 'The storefront details of this product changed in another screen. Refresh the window and review them.' )); } $rows = array(); foreach ( (array) ($data['options'] ?? array()) as $row ) { $id = ( is_array( $row ) ? absint( $row['id'] ?? 0 ) : 0 ); if ( !$id || !in_array( $id, $options, true ) || isset( $rows[$id] ) ) { throw new \InvalidArgumentException(I18n::t( 'The options changed. Refresh the list.' )); } $rows[$id] = $row; } $changed = array(); // 2. Net quantity and unit price base: UnitPricing::apply, the product screen's own cleaning. $objects = array(); $unit = static function ( int $id, $input, bool $option ) use(&$objects, &$changed, $p) : void { if ( !is_array( $input ) ) { return; } $item = $objects[$id] ?? wc_get_product( $id ); $objects[$id] = $item; $qty = ( is_scalar( $input['qty'] ?? '' ) ? trim( (string) ($input['qty'] ?? '') ) : '' ); $unit = ( is_scalar( $input['unit'] ?? '' ) ? (string) ($input['unit'] ?? '') : '' ); $base = ( is_scalar( $input['base'] ?? '' ) ? (string) ($input['base'] ?? '') : '' ); $name = ( $id !== $p->get_id() ? $item->get_name() . ': ' : '' ); if ( !$option && $qty !== '' && $unit === '' ) { throw new \InvalidArgumentException($name . I18n::t( 'Choose the unit of the net quantity.' )); } UnitPricing::apply( $item, $qty, $unit, $base, $option ); // apply() leaves out what it cannot use; here that is said instead of being dropped quietly. if ( $qty !== '' && (string) $item->get_meta( UnitPricing::QTY ) === '' ) { throw new \InvalidArgumentException($name . I18n::t( 'Enter the net quantity as a number above 0 and up to 1,000,000, for example 250 or 1.5.' )); } if ( $unit !== '' && (string) $item->get_meta( UnitPricing::UNIT ) === '' && ($option || $qty !== '') || $base !== '' && (string) $item->get_meta( UnitPricing::BASE ) === '' && ($option || $qty !== '') ) { throw new \InvalidArgumentException($name . I18n::t( 'Choose a unit from the list.' )); } $changed[] = 'unit:' . $id; }; $unit( $p->get_id(), $data['unit'] ?? null, $p->is_type( 'variation' ) ); foreach ( $rows as $id => $row ) { $unit( $id, $row['unit'] ?? null, true ); } // 3. The video last (a provider's cover may be copied into the media library): the theme's own save routine. $warnings = array(); if ( isset( $data['video'] ) && is_array( $data['video'] ) ) { if ( !self::video_available() ) { throw new \InvalidArgumentException(I18n::t( 'The product video is shown by the StoreRuler theme. Activate the theme to add a video.' )); } if ( $p->is_type( 'variation' ) ) { throw new \InvalidArgumentException(I18n::t( 'A video belongs to the product, not to one option. Open the product to add it.' )); } if ( !current_user_can( 'edit_product', $p->get_id() ) ) { throw new \RuntimeException(I18n::t( 'You do not have permission to edit this product.' )); } $item = $objects[$p->get_id()] ?? wc_get_product( $p->get_id() ); $objects[$p->get_id()] = $item; $v = $data['video']; $result = storeruler_video_apply( $item, array( 'url' => esc_url_raw( Store::text( $v, 'url' ) ), 'attachment' => absint( $v['attachment'] ?? 0 ), 'poster' => absint( $v['poster'] ?? 0 ), 'position' => ( sanitize_key( Store::text( $v, 'position' ) ) ?: 'second' ), ) ); if ( $result['error'] !== '' ) { throw new \InvalidArgumentException($result['error']); } $warnings = array_values( array_map( 'strval', $result['warnings'] ) ); $changed[] = 'video'; } foreach ( $objects as $item ) { $item->save(); } foreach ( $ids as $id ) { clean_post_cache( $id ); } if ( $p->is_type( 'variable' ) ) { \WC_Product_Variable::sync( $p->get_id() ); } wc_delete_product_transients( ( $p->get_parent_id() ?: $p->get_id() ) ); Store::audit( 'product', $p->get_id(), 'storefront_details_saved', array( 'changed' => $changed, ) ); return self::read( array( 'id' => $p->get_id(), ) ) + array( 'warnings' => $warnings, ); } ); } }