Explorar el Código

Merge branch '1.5.0.studiou-wc-product-cat-manage'

Dalibor Votruba hace 2 meses
padre
commit
a663ef0136

+ 24 - 4
studiou-wc-product-cat-manage/CLAUDE.md

@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
 
 **QDR - Studiou WC Export/Import Product Categories** is a WordPress plugin that extends WooCommerce to provide batch export/import functionality for both product categories and products, along with category manipulation tools.
 
-**Current Version:** 1.5.2
+**Current Version:** 1.5.3
 
 ## Requirements
 
@@ -53,6 +53,7 @@ The plugin follows a modular class-based architecture:
   - Delete products in category (permanently deletes products and variants)
   - Clear Media Categories (permanently deletes media files from media categories) (NEW in v1.5)
   - Remove Not Assigned Media (deletes unused uncategorized media) (NEW in v1.5.2)
+  - Update product variant prices (regular_price only, by category + attribute filter) (NEW in v1.5.3)
   - Uses direct database queries for reliable product detection
   - Batch processing for delete operations (10 items per batch)
   - Auto-detects media category taxonomy (any custom taxonomy registered for attachments)
@@ -98,6 +99,7 @@ The plugin follows a modular class-based architecture:
   - Delete products in category (checkbox list + "Delete" button + progress bar)
   - Clear Media Categories (checkbox list + "Delete" button + progress bar) (NEW in v1.5)
   - Remove Not Assigned Media ("Delete unused uncategorized media (N)" button + progress bar) (NEW in v1.5.2)
+  - Update product variant prices (checkbox list + 2 comboboxes + price input + Dry Run/Apply buttons + live affected-count label) (NEW in v1.5.3)
 - **product-import.php** - Product import page UI (NEW in v1.4)
   - File upload for CSV import
   - Progress bar for import operations
@@ -210,6 +212,7 @@ All pages are added under **Products** menu:
   - Delete products in category
   - Clear Media Categories (NEW in v1.5)
   - Remove Not Assigned Media (NEW in v1.5.2)
+  - Update product variant prices (NEW in v1.5.3)
 - **Product Import (slug: `studiou-product-import`)** - NEW in v1.4
 - **Product Export (slug: `studiou-product-export`)** - NEW in v1.4
 
@@ -302,6 +305,19 @@ All pages are added under **Products** menu:
 - Verify physical files and attachment posts are both deleted
 - Verify button count updates after page reload
 
+### Update Product Variant Prices Operations (NEW in v1.5.3)
+- Test happy path with a global taxonomy attribute (e.g. `pa_color = red`) — Dry Run shows correct count + sample, Apply commits new regular price
+- Test happy path with a custom non-taxonomy attribute (product-level)
+- Test multi-category selection — variations counted only once each
+- Test no-match filter — Apply button stays disabled, label shows 0
+- Test invalid price input (empty / negative / non-numeric) — server-side error, no DB write
+- Verify a user without `manage_woocommerce` cannot trigger AJAX
+- Verify nonce tampering returns "Security check failed"
+- **Order data integrity**: place a test order with a variation, run UPVP, verify the order line item retains the original price (only product-side price changes)
+- Verify parent variable product price-range cache is refreshed (`WC_Product_Variable::sync` + `wc_delete_product_transients`) — storefront price range reflects new price
+- Verify Czech translations render when site locale is `cs_CZ`
+- Verify asset version query string updates from `1.5.2` to `1.5.3` (note: studiou.cz strips `?ver=` — instruct user to hard-refresh)
+
 ### General
 - Verify translations are properly loaded (English/Czech)
 - Verify progress bars update correctly
@@ -323,11 +339,15 @@ The plugin registers the following AJAX actions:
 8. **studiou_wcpcm_clear_media_batch** - Processes media deletion in batches (NEW in v1.5)
 9. **studiou_wcpcm_remove_unassigned_media** - Gets IDs of unused uncategorized media (NEW in v1.5.2)
 10. **studiou_wcpcm_remove_unassigned_media_batch** - Processes unassigned media deletion in batches (NEW in v1.5.2)
+11. **studiou_wcpcm_upvp_get_attributes** - Lists variation attribute heads (NEW in v1.5.3)
+12. **studiou_wcpcm_upvp_get_attribute_values** - Lists values for the selected attribute, scoped to selected categories (NEW in v1.5.3)
+13. **studiou_wcpcm_upvp_count** - Counts and samples variations matching the current filter (NEW in v1.5.3)
+14. **studiou_wcpcm_upvp_apply** - Commits new regular price to matching variations via WC API (NEW in v1.5.3)
 
 ### Product Operations (NEW in v1.4)
-11. **studiou_wcpcm_product_import_parse** - Parses CSV file and stores in transient (Step 1)
-12. **studiou_wcpcm_product_import_batch** - Processes batch of 5 products (Step 2, called recursively)
-13. **studiou_wcpcm_product_export** - Generates product CSV export by category selection
+15. **studiou_wcpcm_product_import_parse** - Parses CSV file and stores in transient (Step 1)
+16. **studiou_wcpcm_product_import_batch** - Processes batch of 5 products (Step 2, called recursively)
+17. **studiou_wcpcm_product_export** - Generates product CSV export by category selection
 
 All AJAX handlers include:
 - Nonce verification (`studiou-wcpcm-nonce`)

+ 27 - 0
studiou-wc-product-cat-manage/assets/css/admin.css

@@ -281,4 +281,31 @@
 .studiou-wcpcm-clear-media-results ul li:last-child,
 .studiou-wcpcm-remove-unassigned-media-results ul li:last-child {
     border-bottom: none;
+}
+
+/* Update Product Variant Prices (UPVP) */
+.studiou-wcpcm-upvp-results {
+    background: #f9f9f9;
+    padding: 15px;
+    border: 1px solid #ddd;
+    border-radius: 4px;
+    margin-top: 30px;
+}
+
+#studiou-wcpcm-upvp-affected-count {
+    font-size: 1.2em;
+    color: #0073aa;
+}
+
+.studiou-wcpcm-upvp-preview-table {
+    width: 100%;
+    border-collapse: collapse;
+    margin-top: 10px;
+}
+
+.studiou-wcpcm-upvp-preview-table th,
+.studiou-wcpcm-upvp-preview-table td {
+    padding: 6px 10px;
+    border-bottom: 1px solid #eee;
+    text-align: left;
 }

+ 344 - 1
studiou-wc-product-cat-manage/assets/js/admin.js

@@ -19,7 +19,7 @@
     // Initialize admin scripts
     $(document).ready(function() {
         // Debug: Log that script is loaded
-        console.log('STUDIOU WC: Admin script loaded v1.5.1');
+        console.log('STUDIOU WC: Admin script loaded v1.5.3');
 
         if (typeof studiouWcpcm === 'undefined') {
             console.error('STUDIOU WC: studiouWcpcm object is NOT defined - AJAX will not work');
@@ -38,6 +38,7 @@
         safeInit('Sample download', '#studiou-wcpcm-download-sample', initSampleDownload);
         safeInit('Product import form', '#studiou-wcpcm-product-import-form', initProductImportForm);
         safeInit('Product export form', '#studiou-wcpcm-product-export-form', initProductExportForm);
+        safeInit('Update variant prices form', '#studiou-wcpcm-upvp-form', initUpdateVariantPricesForm);
     });
     
     /**
@@ -1350,6 +1351,348 @@
         console.log('STUDIOU WC: Product import form exists:', $('#studiou-wcpcm-product-import-form').length > 0);
         console.log('STUDIOU WC: Product export form exists:', $('#studiou-wcpcm-product-export-form').length > 0);
         console.log('STUDIOU WC: Clear media form exists:', $('#studiou-wcpcm-clear-media-form').length > 0);
+        console.log('STUDIOU WC: UPVP form exists:', $('#studiou-wcpcm-upvp-form').length > 0);
     });
 
+    /**
+     * Initialize Update Product Variant Prices form (UPVP) — v1.5.3
+     */
+    function initUpdateVariantPricesForm() {
+        var $form          = $('#studiou-wcpcm-upvp-form');
+        var $catList       = $('#upvp_categories_list');
+        var $attrSelect    = $('#upvp_attribute');
+        var $valueSelect   = $('#upvp_attribute_value');
+        var $newPrice      = $('#upvp_new_price');
+        var $countLabel    = $('#studiou-wcpcm-upvp-affected-count');
+        var $dryRunBtn     = $('#studiou-wcpcm-upvp-dry-run-button');
+        var $applyBtn      = $('#studiou-wcpcm-upvp-apply-button');
+        var $spinner       = $form.find('.spinner');
+        var $resultsBox    = $('.studiou-wcpcm-upvp-results');
+        var $resultsMsg    = $('#studiou-wcpcm-upvp-message');
+
+        var i18n = (studiouWcpcm && studiouWcpcm.i18n) ? studiouWcpcm.i18n : {};
+        var countDebounceHandle = null;
+        var countInFlight = false;
+
+        function getSelectedCategoryIds() {
+            var ids = [];
+            $catList.find('input[type="checkbox"]:checked').each(function() {
+                ids.push($(this).val());
+            });
+            return ids;
+        }
+
+        function setApplyEnabled(count) {
+            var enabled = (count > 0) && !countInFlight;
+            $dryRunBtn.prop('disabled', !enabled);
+            $applyBtn.prop('disabled', !enabled);
+        }
+
+        function loadAttributes() {
+            $attrSelect.prop('disabled', true).html(
+                '<option value="">' + (i18n.upvpLoadingAttributes || 'Loading attributes...') + '</option>'
+            );
+
+            $.ajax({
+                url: studiouWcpcm.ajaxUrl,
+                type: 'POST',
+                data: {
+                    action: 'studiou_wcpcm_upvp_get_attributes',
+                    nonce: studiouWcpcm.nonce
+                },
+                success: function(response) {
+                    $attrSelect.empty();
+                    $attrSelect.append('<option value="">' + (i18n.upvpSelectAttribute || '-- Select attribute --') + '</option>');
+                    if (response.success && response.data && response.data.attributes) {
+                        $.each(response.data.attributes, function(_, a) {
+                            $attrSelect.append(
+                                $('<option/>')
+                                    .val(a.key)
+                                    .attr('data-istax', a.is_taxonomy ? '1' : '0')
+                                    .text(a.label)
+                            );
+                        });
+                    }
+                    $attrSelect.prop('disabled', false);
+                },
+                error: function(xhr, status, error) {
+                    console.error('STUDIOU WC UPVP: get_attributes failed', status, error);
+                    $attrSelect.prop('disabled', false);
+                }
+            });
+        }
+
+        function loadValues() {
+            var key = $attrSelect.val();
+            $valueSelect.prop('disabled', true).empty();
+            $valueSelect.append('<option value="">' + (i18n.upvpLoadingValues || 'Loading values...') + '</option>');
+
+            if (!key) {
+                $valueSelect.empty();
+                $valueSelect.append('<option value="">' + (i18n.upvpSelectValue || '-- Select value --') + '</option>');
+                $valueSelect.prop('disabled', true);
+                refreshCount();
+                return;
+            }
+
+            $.ajax({
+                url: studiouWcpcm.ajaxUrl,
+                type: 'POST',
+                data: {
+                    action: 'studiou_wcpcm_upvp_get_attribute_values',
+                    nonce: studiouWcpcm.nonce,
+                    attribute_key: key,
+                    category_ids: getSelectedCategoryIds()
+                },
+                success: function(response) {
+                    $valueSelect.empty();
+                    $valueSelect.append('<option value="">' + (i18n.upvpSelectValue || '-- Select value --') + '</option>');
+                    if (response.success && response.data && response.data.values) {
+                        $.each(response.data.values, function(_, v) {
+                            $valueSelect.append($('<option/>').val(v.value).text(v.label));
+                        });
+                    }
+                    $valueSelect.prop('disabled', false);
+                    refreshCount();
+                },
+                error: function(xhr, status, error) {
+                    console.error('STUDIOU WC UPVP: get_attribute_values failed', status, error);
+                    $valueSelect.empty();
+                    $valueSelect.append('<option value="">' + (i18n.upvpSelectValue || '-- Select value --') + '</option>');
+                    $valueSelect.prop('disabled', false);
+                }
+            });
+        }
+
+        function refreshCount() {
+            if (countDebounceHandle) {
+                clearTimeout(countDebounceHandle);
+            }
+            countDebounceHandle = setTimeout(doRefreshCount, 300);
+        }
+
+        function doRefreshCount() {
+            var cats = getSelectedCategoryIds();
+            var key  = $attrSelect.val();
+            var val  = $valueSelect.val();
+
+            if (!cats.length || !key || !val) {
+                $countLabel.text('0');
+                setApplyEnabled(0);
+                return;
+            }
+
+            countInFlight = true;
+            setApplyEnabled(0);
+
+            $.ajax({
+                url: studiouWcpcm.ajaxUrl,
+                type: 'POST',
+                data: {
+                    action: 'studiou_wcpcm_upvp_count',
+                    nonce: studiouWcpcm.nonce,
+                    category_ids: cats,
+                    attribute_key: key,
+                    attribute_value: val
+                },
+                success: function(response) {
+                    countInFlight = false;
+                    if (response.success && response.data) {
+                        var c = parseInt(response.data.count, 10) || 0;
+                        $countLabel.text(c);
+                        setApplyEnabled(c);
+                    } else {
+                        $countLabel.text('0');
+                        setApplyEnabled(0);
+                    }
+                },
+                error: function() {
+                    countInFlight = false;
+                    $countLabel.text('0');
+                    setApplyEnabled(0);
+                }
+            });
+        }
+
+        function renderSampleTable(sample, newPriceLabel) {
+            if (!sample || !sample.length) return '';
+            var html = '<table class="studiou-wcpcm-upvp-preview-table">';
+            html += '<thead><tr>';
+            html += '<th>ID</th><th>SKU</th><th>Name</th><th>Parent</th><th>Current</th>';
+            if (newPriceLabel !== undefined) html += '<th>New</th>';
+            html += '</tr></thead><tbody>';
+            $.each(sample, function(_, r) {
+                html += '<tr>';
+                html += '<td>' + r.id + '</td>';
+                html += '<td>' + (r.sku || '') + '</td>';
+                html += '<td>' + (r.name || '') + '</td>';
+                html += '<td>' + (r.parent_name || '') + '</td>';
+                html += '<td>' + (r.current_price || '') + '</td>';
+                if (newPriceLabel !== undefined) html += '<td>' + newPriceLabel + '</td>';
+                html += '</tr>';
+            });
+            html += '</tbody></table>';
+            return html;
+        }
+
+        // Wire up Check All / Uncheck All
+        $('#studiou-wcpcm-upvp-check-all').on('click', function(e) {
+            e.preventDefault();
+            $catList.find('input[type="checkbox"]').prop('checked', true);
+            refreshCount();
+        });
+        $('#studiou-wcpcm-upvp-uncheck-all').on('click', function(e) {
+            e.preventDefault();
+            $catList.find('input[type="checkbox"]').prop('checked', false);
+            refreshCount();
+        });
+
+        $catList.on('change', 'input[type="checkbox"]', refreshCount);
+        $attrSelect.on('change', loadValues);
+        $valueSelect.on('change', refreshCount);
+
+        // Dry Run
+        $dryRunBtn.on('click', function(e) {
+            e.preventDefault();
+            var cats = getSelectedCategoryIds();
+            var key  = $attrSelect.val();
+            var val  = $valueSelect.val();
+
+            if (!cats.length) {
+                showNotice('error', i18n.selectAtLeastOneCategory || 'Please select at least one category');
+                return;
+            }
+            if (!key) {
+                showNotice('error', i18n.upvpSelectAttribute || 'Please select an attribute');
+                return;
+            }
+            if (!val) {
+                showNotice('error', i18n.upvpSelectValue || 'Please select an attribute value');
+                return;
+            }
+
+            $('.studiou-wcpcm-notice-area').empty();
+            $resultsBox.hide();
+            $spinner.css('visibility', 'visible');
+            $dryRunBtn.prop('disabled', true);
+            $applyBtn.prop('disabled', true);
+
+            $.ajax({
+                url: studiouWcpcm.ajaxUrl,
+                type: 'POST',
+                data: {
+                    action: 'studiou_wcpcm_upvp_count',
+                    nonce: studiouWcpcm.nonce,
+                    category_ids: cats,
+                    attribute_key: key,
+                    attribute_value: val
+                },
+                success: function(response) {
+                    if (response.success && response.data) {
+                        var c       = parseInt(response.data.count, 10) || 0;
+                        var sample  = response.data.sample || [];
+                        var price   = $newPrice.val();
+                        var msg     = '';
+                        if (c === 0) {
+                            msg = '<p>' + (i18n.upvpNoMatches || 'No variations match the current filter.') + '</p>';
+                        } else {
+                            var tpl = i18n.upvpDryRunComplete || 'Dry run complete: %d variations would be updated to %s.';
+                            msg = '<p>' + tpl.replace('%d', c).replace('%s', price !== '' ? price : '—') + '</p>';
+                            msg += renderSampleTable(sample, price !== '' ? price : undefined);
+                        }
+                        $resultsMsg.html(msg);
+                        $resultsBox.show();
+                        $countLabel.text(c);
+                        setApplyEnabled(c);
+                    } else {
+                        showNotice('error', (response.data && response.data.message) || 'Dry run failed');
+                    }
+                },
+                error: function(xhr, status, error) {
+                    showNotice('error', 'Dry run error: ' + error);
+                },
+                complete: function() {
+                    $spinner.css('visibility', 'hidden');
+                }
+            });
+        });
+
+        // Apply
+        $applyBtn.on('click', function(e) {
+            e.preventDefault();
+            var cats  = getSelectedCategoryIds();
+            var key   = $attrSelect.val();
+            var val   = $valueSelect.val();
+            var price = $newPrice.val();
+            var count = parseInt($countLabel.text(), 10) || 0;
+
+            if (!cats.length) {
+                showNotice('error', i18n.selectAtLeastOneCategory || 'Please select at least one category');
+                return;
+            }
+            if (!key) {
+                showNotice('error', i18n.upvpSelectAttribute || 'Please select an attribute');
+                return;
+            }
+            if (!val) {
+                showNotice('error', i18n.upvpSelectValue || 'Please select an attribute value');
+                return;
+            }
+            if (price === '' || isNaN(parseFloat(price)) || parseFloat(price) < 0) {
+                showNotice('error', i18n.upvpInvalidPrice || 'Please enter a valid new price');
+                return;
+            }
+
+            var confirmTpl = i18n.upvpConfirmApply || 'Are you sure you want to update %d variations?';
+            if (!confirm(confirmTpl.replace('%d', count))) {
+                return;
+            }
+
+            $('.studiou-wcpcm-notice-area').empty();
+            $resultsBox.hide();
+            $spinner.css('visibility', 'visible');
+            $dryRunBtn.prop('disabled', true);
+            $applyBtn.prop('disabled', true);
+
+            $.ajax({
+                url: studiouWcpcm.ajaxUrl,
+                type: 'POST',
+                data: {
+                    action: 'studiou_wcpcm_upvp_apply',
+                    nonce: studiouWcpcm.nonce,
+                    category_ids: cats,
+                    attribute_key: key,
+                    attribute_value: val,
+                    new_price: price
+                },
+                success: function(response) {
+                    if (response.success && response.data) {
+                        showNotice('success', i18n.upvpApplySuccess || 'Variant prices updated successfully');
+                        var html = '<p><strong>' + (response.data.message || '') + '</strong></p>';
+                        if (response.data.failed_ids && response.data.failed_ids.length) {
+                            html += '<p>Failed IDs: ' + response.data.failed_ids.join(', ') + '</p>';
+                        }
+                        $resultsMsg.html(html);
+                        $resultsBox.show();
+                        refreshCount();
+                    } else {
+                        showNotice('error', (response.data && response.data.message) || (i18n.upvpApplyError || 'Variant prices update error'));
+                    }
+                },
+                error: function(xhr, status, error) {
+                    showNotice('error', (i18n.upvpApplyError || 'Variant prices update error') + ': ' + error);
+                },
+                complete: function() {
+                    $spinner.css('visibility', 'hidden');
+                    $dryRunBtn.prop('disabled', false);
+                    $applyBtn.prop('disabled', false);
+                }
+            });
+        });
+
+        // Initial load
+        loadAttributes();
+        setApplyEnabled(0);
+    }
+
 })(jQuery);

+ 605 - 0
studiou-wc-product-cat-manage/includes/class-studiou-wc-product-cat-manage-manipulator.php

@@ -40,6 +40,10 @@ class Studiou_WC_Product_Cat_Manage_Manipulator {
         add_action('wp_ajax_studiou_wcpcm_clear_media_batch', array($this, 'handle_clear_media_batch_ajax'));
         add_action('wp_ajax_studiou_wcpcm_remove_unassigned_media', array($this, 'handle_remove_unassigned_media_ajax'));
         add_action('wp_ajax_studiou_wcpcm_remove_unassigned_media_batch', array($this, 'handle_remove_unassigned_media_batch_ajax'));
+        add_action('wp_ajax_studiou_wcpcm_upvp_get_attributes', array($this, 'handle_upvp_get_attributes_ajax'));
+        add_action('wp_ajax_studiou_wcpcm_upvp_get_attribute_values', array($this, 'handle_upvp_get_attribute_values_ajax'));
+        add_action('wp_ajax_studiou_wcpcm_upvp_count', array($this, 'handle_upvp_count_ajax'));
+        add_action('wp_ajax_studiou_wcpcm_upvp_apply', array($this, 'handle_upvp_apply_ajax'));
 
         // Add a test AJAX handler to verify AJAX is working
         add_action('wp_ajax_studiou_wcpcm_test', array($this, 'handle_test_ajax'));
@@ -1479,4 +1483,605 @@ class Studiou_WC_Product_Cat_Manage_Manipulator {
             ob_end_clean();
         }
     }
+
+    // ========================================================================
+    // Update Product Variant Prices (UPVP) — v1.5.3
+    // Bulk-update regular_price on product_variation records by category +
+    // attribute filter. Touches only product records; never order items, carts,
+    // or historical order data.
+    // ========================================================================
+
+    /**
+     * Get list of attribute "heads" usable for variations.
+     *
+     * Source 1: every wc_get_attribute_taxonomies() entry (global attributes)
+     *           — key = 'pa_<slug>', is_taxonomy = true.
+     * Source 2: distinct meta_key starting with 'attribute_' on product_variation
+     *           rows that are NOT global taxonomies (custom product-level
+     *           attributes flagged for variations) — key = '<slug>',
+     *           is_taxonomy = false.
+     *
+     * @return array [ ['key' => string, 'label' => string, 'is_taxonomy' => bool], ... ]
+     */
+    public function get_variation_attribute_heads() {
+        global $wpdb;
+
+        $heads = array();
+        $seen  = array();
+
+        // Source 1: global taxonomies.
+        $taxonomies = function_exists('wc_get_attribute_taxonomies') ? wc_get_attribute_taxonomies() : array();
+        if (!empty($taxonomies)) {
+            foreach ($taxonomies as $tax) {
+                $key = 'pa_' . $tax->attribute_name;
+                if (isset($seen[$key])) {
+                    continue;
+                }
+                $seen[$key] = true;
+                $heads[] = array(
+                    'key'         => $key,
+                    'label'       => !empty($tax->attribute_label) ? $tax->attribute_label : $tax->attribute_name,
+                    'is_taxonomy' => true,
+                );
+            }
+        }
+
+        // Source 2: existing variation postmeta (attribute_*).
+        $sql = "
+            SELECT DISTINCT pm.meta_key
+              FROM {$wpdb->postmeta} pm
+              INNER JOIN {$wpdb->posts} v ON v.ID = pm.post_id
+             WHERE v.post_type = 'product_variation'
+               AND v.post_status IN ('publish','private')
+               AND pm.meta_key LIKE %s
+        ";
+        $rows = $wpdb->get_col($wpdb->prepare($sql, $wpdb->esc_like('attribute_') . '%'));
+        if (!empty($rows)) {
+            foreach ($rows as $meta_key) {
+                $slug = substr($meta_key, strlen('attribute_'));
+                if ($slug === '' || isset($seen[$slug])) {
+                    continue;
+                }
+                $seen[$slug] = true;
+                $heads[] = array(
+                    'key'         => $slug,
+                    'label'       => wc_attribute_label($slug),
+                    'is_taxonomy' => (strpos($slug, 'pa_') === 0) || taxonomy_exists($slug),
+                );
+            }
+        }
+
+        // Source 3: variable parent products' _product_attributes postmeta.
+        // Catches attributes flagged "Used for variations" on the product's
+        // Attributes tab, even when (a) the attribute is a custom non-global
+        // entry, or (b) no variations have been generated yet.
+        $sql_parents = "
+            SELECT pm.meta_value
+              FROM {$wpdb->postmeta} pm
+              INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+             WHERE p.post_type = 'product'
+               AND p.post_status IN ('publish','private','draft')
+               AND pm.meta_key = '_product_attributes'
+        ";
+        $serialized_rows = $wpdb->get_col($sql_parents);
+        if (!empty($serialized_rows)) {
+            foreach ($serialized_rows as $serialized) {
+                $attrs = maybe_unserialize($serialized);
+                if (!is_array($attrs)) {
+                    continue;
+                }
+                foreach ($attrs as $attr_key => $attr_data) {
+                    if (!is_array($attr_data) || empty($attr_data['is_variation'])) {
+                        continue;
+                    }
+                    $is_taxonomy = !empty($attr_data['is_taxonomy']);
+                    $name = isset($attr_data['name']) ? $attr_data['name'] : $attr_key;
+                    $key  = $is_taxonomy ? $name : sanitize_title($name);
+                    if ($key === '' || isset($seen[$key])) {
+                        continue;
+                    }
+                    $seen[$key] = true;
+                    $heads[] = array(
+                        'key'         => $key,
+                        'label'       => $is_taxonomy ? wc_attribute_label($key) : $name,
+                        'is_taxonomy' => $is_taxonomy,
+                    );
+                }
+            }
+        }
+
+        // Sort alphabetically by label.
+        usort($heads, function($a, $b) {
+            return strcasecmp($a['label'], $b['label']);
+        });
+
+        return $heads;
+    }
+
+    /**
+     * Get distinct values for the given attribute key, scoped to existing
+     * variations (and optionally to a set of parent product categories).
+     *
+     * For taxonomy attributes (pa_*) the meta_value is the term slug; we
+     * resolve labels via get_term_by('slug', ..., $taxonomy).
+     *
+     * @param string $attribute_key  E.g. 'pa_color' or 'size'.
+     * @param int[]  $category_ids   Optional list of parent product_cat term IDs.
+     * @return array [ ['value' => string, 'label' => string], ... ]
+     */
+    public function get_variation_attribute_values($attribute_key, $category_ids = array()) {
+        global $wpdb;
+
+        $attribute_key = sanitize_text_field($attribute_key);
+        if ($attribute_key === '') {
+            return array();
+        }
+
+        $meta_key = 'attribute_' . wc_sanitize_taxonomy_name($attribute_key);
+
+        $params = array($meta_key);
+
+        $cat_join  = '';
+        $cat_where = '';
+        if (!empty($category_ids)) {
+            $cat_ids = array_map('intval', $category_ids);
+            $cat_ids = array_filter($cat_ids, function($v) { return $v > 0; });
+            if (!empty($cat_ids)) {
+                $placeholders = implode(',', array_fill(0, count($cat_ids), '%d'));
+                $cat_join  = "
+                    INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
+                    INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
+                ";
+                $cat_where = "
+                    AND tt.taxonomy = 'product_cat'
+                    AND tt.term_id IN ($placeholders)
+                ";
+                $params = array_merge($params, $cat_ids);
+            }
+        }
+
+        $sql = "
+            SELECT DISTINCT pm.meta_value
+              FROM {$wpdb->postmeta} pm
+              INNER JOIN {$wpdb->posts} v ON v.ID = pm.post_id
+                       AND v.post_type = 'product_variation'
+                       AND v.post_status IN ('publish','private')
+              INNER JOIN {$wpdb->posts} p ON p.ID = v.post_parent
+                       AND p.post_type = 'product'
+              $cat_join
+             WHERE pm.meta_key = %s
+               AND pm.meta_value <> ''
+               $cat_where
+        ";
+
+        $values = $wpdb->get_col($wpdb->prepare($sql, ...$params));
+
+        $is_taxonomy = (strpos($attribute_key, 'pa_') === 0) || taxonomy_exists($attribute_key);
+
+        // Fallback: if SQL returned no values but the attribute exists on a
+        // variable parent's _product_attributes (e.g. variations not generated
+        // yet, or custom attribute), parse pipe-separated values from there.
+        if (empty($values)) {
+            $sanitized_key = $is_taxonomy
+                ? (strpos($attribute_key, 'pa_') === 0 ? $attribute_key : 'pa_' . $attribute_key)
+                : sanitize_title($attribute_key);
+
+            $sql_parents = "
+                SELECT pm.meta_value
+                  FROM {$wpdb->postmeta} pm
+                  INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+                 WHERE p.post_type = 'product'
+                   AND p.post_status IN ('publish','private','draft')
+                   AND pm.meta_key = '_product_attributes'
+            ";
+            $rows = $wpdb->get_col($sql_parents);
+            $values = array();
+            if (!empty($rows)) {
+                foreach ($rows as $serialized) {
+                    $attrs = maybe_unserialize($serialized);
+                    if (!is_array($attrs) || !isset($attrs[$sanitized_key])) {
+                        continue;
+                    }
+                    $entry = $attrs[$sanitized_key];
+                    if (empty($entry['is_variation'])) {
+                        continue;
+                    }
+                    if ($is_taxonomy) {
+                        // Taxonomy custom values are stored as term IDs in 'value'.
+                        $term_ids = !empty($entry['value']) ? wp_parse_id_list($entry['value']) : array();
+                        if (empty($term_ids)) {
+                            // Or the parent uses all terms — pull them from the taxonomy.
+                            $tax_name = $sanitized_key;
+                            $terms = get_terms(array('taxonomy' => $tax_name, 'hide_empty' => false));
+                            if (!is_wp_error($terms)) {
+                                foreach ($terms as $t) {
+                                    $values[] = $t->slug;
+                                }
+                            }
+                        } else {
+                            foreach ($term_ids as $tid) {
+                                $term = get_term($tid, $sanitized_key);
+                                if ($term && !is_wp_error($term)) {
+                                    $values[] = $term->slug;
+                                }
+                            }
+                        }
+                    } else {
+                        $raw = isset($entry['value']) ? (string) $entry['value'] : '';
+                        if ($raw !== '') {
+                            $parts = array_map('trim', explode('|', $raw));
+                            foreach ($parts as $p) {
+                                if ($p !== '') {
+                                    $values[] = $p;
+                                }
+                            }
+                        }
+                    }
+                }
+                $values = array_values(array_unique($values));
+            }
+        }
+
+        if (empty($values)) {
+            return array();
+        }
+
+        $result = array();
+        foreach ($values as $value) {
+            $label = $value;
+            if ($is_taxonomy) {
+                $tax = (strpos($attribute_key, 'pa_') === 0) ? $attribute_key : 'pa_' . $attribute_key;
+                $term = get_term_by('slug', $value, $tax);
+                if ($term && !is_wp_error($term)) {
+                    $label = $term->name;
+                }
+            }
+            $result[] = array(
+                'value' => $value,
+                'label' => $label,
+            );
+        }
+
+        usort($result, function($a, $b) {
+            return strcasecmp($a['label'], $b['label']);
+        });
+
+        return $result;
+    }
+
+    /**
+     * Find variations matching the filter.
+     *
+     * @param int[]  $category_ids
+     * @param string $attribute_key
+     * @param string $attribute_value
+     * @return array ['count' => int, 'ids' => int[], 'sample' => array[]]
+     */
+    public function find_matching_variations($category_ids, $attribute_key, $attribute_value) {
+        global $wpdb;
+
+        $category_ids   = array_filter(array_map('intval', (array) $category_ids), function($v) { return $v > 0; });
+        $attribute_key  = sanitize_text_field($attribute_key);
+        $attribute_value = sanitize_text_field($attribute_value);
+
+        if (empty($category_ids) || $attribute_key === '' || $attribute_value === '') {
+            return array('count' => 0, 'ids' => array(), 'sample' => array());
+        }
+
+        $meta_key     = 'attribute_' . wc_sanitize_taxonomy_name($attribute_key);
+        $placeholders = implode(',', array_fill(0, count($category_ids), '%d'));
+
+        $sql = "
+            SELECT DISTINCT v.ID
+              FROM {$wpdb->posts} v
+              INNER JOIN {$wpdb->posts} p ON p.ID = v.post_parent
+              INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = v.ID
+              INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
+              INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
+             WHERE v.post_type = 'product_variation'
+               AND v.post_status IN ('publish','private')
+               AND p.post_type = 'product'
+               AND tt.taxonomy = 'product_cat'
+               AND tt.term_id IN ($placeholders)
+               AND pm.meta_key = %s
+               AND pm.meta_value = %s
+        ";
+
+        $params = array_merge($category_ids, array($meta_key, $attribute_value));
+        $ids    = $wpdb->get_col($wpdb->prepare($sql, ...$params));
+        $ids    = $ids ? array_map('intval', $ids) : array();
+
+        // Build a sample (up to 20).
+        $sample = array();
+        $slice  = array_slice($ids, 0, 20);
+        foreach ($slice as $id) {
+            $variation = wc_get_product($id);
+            if (!$variation) {
+                continue;
+            }
+            $parent_id   = $variation->get_parent_id();
+            $parent_name = '';
+            if ($parent_id) {
+                $parent = wc_get_product($parent_id);
+                if ($parent) {
+                    $parent_name = $parent->get_name();
+                }
+            }
+            $sample[] = array(
+                'id'            => $id,
+                'sku'           => $variation->get_sku(),
+                'name'          => $variation->get_name(),
+                'parent_name'   => $parent_name,
+                'current_price' => $variation->get_regular_price(),
+            );
+        }
+
+        return array(
+            'count'  => count($ids),
+            'ids'    => $ids,
+            'sample' => $sample,
+        );
+    }
+
+    /**
+     * Apply new regular price to a list of variation IDs.
+     *
+     * @param int[]  $variation_ids
+     * @param string $new_price
+     * @return array ['updated','failed','failed_ids','message']
+     */
+    public function apply_variant_price($variation_ids, $new_price) {
+        $variation_ids = array_filter(array_map('intval', (array) $variation_ids), function($v) { return $v > 0; });
+
+        // Validate price.
+        if (!is_numeric($new_price) || (float) $new_price < 0) {
+            return array(
+                'updated'    => 0,
+                'failed'     => 0,
+                'failed_ids' => array(),
+                'message'    => __('Please enter a valid new price', 'studiou-wc-product-cat-manage'),
+                'error'      => true,
+            );
+        }
+
+        $formatted = wc_format_decimal($new_price);
+
+        $updated     = 0;
+        $failed      = 0;
+        $failed_ids  = array();
+        $parent_ids  = array();
+
+        foreach ($variation_ids as $id) {
+            try {
+                $variation = wc_get_product($id);
+                if (!$variation || !$variation->is_type('variation')) {
+                    $failed++;
+                    $failed_ids[] = $id;
+                    error_log('STUDIOU WC UPVP: Variation ' . $id . ' not found or not a variation');
+                    continue;
+                }
+                $variation->set_regular_price($formatted);
+                $variation->save();
+                $updated++;
+                $parent_id = $variation->get_parent_id();
+                if ($parent_id) {
+                    $parent_ids[$parent_id] = true;
+                }
+            } catch (Exception $e) {
+                $failed++;
+                $failed_ids[] = $id;
+                error_log('STUDIOU WC UPVP: Exception updating variation ' . $id . ' - ' . $e->getMessage());
+            }
+        }
+
+        // Re-sync parent variable products so price-range cache reflects the new prices.
+        if (!empty($parent_ids) && class_exists('WC_Product_Variable')) {
+            foreach (array_keys($parent_ids) as $parent_id) {
+                try {
+                    WC_Product_Variable::sync($parent_id);
+                    if (function_exists('wc_delete_product_transients')) {
+                        wc_delete_product_transients($parent_id);
+                    }
+                } catch (Exception $e) {
+                    error_log('STUDIOU WC UPVP: Failed to sync parent ' . $parent_id . ' - ' . $e->getMessage());
+                }
+            }
+        }
+
+        $message = sprintf(
+            __('Successfully updated %d variations.', 'studiou-wc-product-cat-manage'),
+            $updated
+        );
+        if ($failed > 0) {
+            $message .= ' ' . sprintf(
+                __('Failed to update %d variations.', 'studiou-wc-product-cat-manage'),
+                $failed
+            );
+        }
+
+        return array(
+            'updated'    => $updated,
+            'failed'     => $failed,
+            'failed_ids' => $failed_ids,
+            'message'    => $message,
+        );
+    }
+
+    /**
+     * Common AJAX preflight: nonce + capability + output buffer setup.
+     * Returns false on failure (after sending JSON error), true on success.
+     */
+    private function upvp_preflight() {
+        while (ob_get_level()) {
+            ob_end_clean();
+        }
+        ob_start();
+
+        if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) {
+            ob_clean();
+            wp_send_json_error(array('message' => __('Security check failed', 'studiou-wc-product-cat-manage')));
+            return false;
+        }
+        if (!current_user_can('manage_woocommerce')) {
+            ob_clean();
+            wp_send_json_error(array('message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage')));
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * AJAX: list variation attribute heads.
+     */
+    public function handle_upvp_get_attributes_ajax() {
+        if (!$this->upvp_preflight()) return;
+
+        $original_error_reporting = error_reporting();
+        error_reporting(0);
+        try {
+            $heads = $this->get_variation_attribute_heads();
+            ob_clean();
+            wp_send_json_success(array('attributes' => $heads));
+        } catch (Exception $e) {
+            error_log('STUDIOU WC UPVP: get_attributes failed - ' . $e->getMessage());
+            ob_clean();
+            wp_send_json_error(array('message' => $e->getMessage()));
+        } finally {
+            error_reporting($original_error_reporting);
+            ob_end_clean();
+        }
+    }
+
+    /**
+     * AJAX: list values for the chosen attribute (scoped to selected categories).
+     */
+    public function handle_upvp_get_attribute_values_ajax() {
+        if (!$this->upvp_preflight()) return;
+
+        $original_error_reporting = error_reporting();
+        error_reporting(0);
+        try {
+            $attribute_key = isset($_POST['attribute_key']) ? sanitize_text_field(wp_unslash($_POST['attribute_key'])) : '';
+            $category_ids  = isset($_POST['category_ids']) ? array_map('intval', (array) $_POST['category_ids']) : array();
+
+            if ($attribute_key === '') {
+                ob_clean();
+                wp_send_json_error(array('message' => __('Please select an attribute', 'studiou-wc-product-cat-manage')));
+                return;
+            }
+
+            $values = $this->get_variation_attribute_values($attribute_key, $category_ids);
+            ob_clean();
+            wp_send_json_success(array('values' => $values));
+        } catch (Exception $e) {
+            error_log('STUDIOU WC UPVP: get_attribute_values failed - ' . $e->getMessage());
+            ob_clean();
+            wp_send_json_error(array('message' => $e->getMessage()));
+        } finally {
+            error_reporting($original_error_reporting);
+            ob_end_clean();
+        }
+    }
+
+    /**
+     * AJAX: count + sample of variations matching the current filter.
+     */
+    public function handle_upvp_count_ajax() {
+        if (!$this->upvp_preflight()) return;
+
+        $original_error_reporting = error_reporting();
+        error_reporting(0);
+        try {
+            $category_ids    = isset($_POST['category_ids']) ? array_map('intval', (array) $_POST['category_ids']) : array();
+            $attribute_key   = isset($_POST['attribute_key']) ? sanitize_text_field(wp_unslash($_POST['attribute_key'])) : '';
+            $attribute_value = isset($_POST['attribute_value']) ? sanitize_text_field(wp_unslash($_POST['attribute_value'])) : '';
+
+            if (empty($category_ids)) {
+                ob_clean();
+                wp_send_json_error(array('message' => __('Please select at least one category', 'studiou-wc-product-cat-manage')));
+                return;
+            }
+            if ($attribute_key === '') {
+                ob_clean();
+                wp_send_json_error(array('message' => __('Please select an attribute', 'studiou-wc-product-cat-manage')));
+                return;
+            }
+            if ($attribute_value === '') {
+                ob_clean();
+                wp_send_json_error(array('message' => __('Please select an attribute value', 'studiou-wc-product-cat-manage')));
+                return;
+            }
+
+            $result = $this->find_matching_variations($category_ids, $attribute_key, $attribute_value);
+
+            ob_clean();
+            wp_send_json_success(array(
+                'count'  => $result['count'],
+                'sample' => $result['sample'],
+            ));
+        } catch (Exception $e) {
+            error_log('STUDIOU WC UPVP: count failed - ' . $e->getMessage());
+            ob_clean();
+            wp_send_json_error(array('message' => $e->getMessage()));
+        } finally {
+            error_reporting($original_error_reporting);
+            ob_end_clean();
+        }
+    }
+
+    /**
+     * AJAX: apply new regular price to all matching variations.
+     */
+    public function handle_upvp_apply_ajax() {
+        if (!$this->upvp_preflight()) return;
+
+        $original_error_reporting = error_reporting();
+        error_reporting(0);
+        try {
+            $category_ids    = isset($_POST['category_ids']) ? array_map('intval', (array) $_POST['category_ids']) : array();
+            $attribute_key   = isset($_POST['attribute_key']) ? sanitize_text_field(wp_unslash($_POST['attribute_key'])) : '';
+            $attribute_value = isset($_POST['attribute_value']) ? sanitize_text_field(wp_unslash($_POST['attribute_value'])) : '';
+            $new_price       = isset($_POST['new_price']) ? sanitize_text_field(wp_unslash($_POST['new_price'])) : '';
+
+            if (empty($category_ids)) {
+                ob_clean();
+                wp_send_json_error(array('message' => __('Please select at least one category', 'studiou-wc-product-cat-manage')));
+                return;
+            }
+            if ($attribute_key === '' || $attribute_value === '') {
+                ob_clean();
+                wp_send_json_error(array('message' => __('Please select an attribute value', 'studiou-wc-product-cat-manage')));
+                return;
+            }
+            if ($new_price === '' || !is_numeric($new_price) || (float) $new_price < 0) {
+                ob_clean();
+                wp_send_json_error(array('message' => __('Please enter a valid new price', 'studiou-wc-product-cat-manage')));
+                return;
+            }
+
+            $matching = $this->find_matching_variations($category_ids, $attribute_key, $attribute_value);
+            if ($matching['count'] === 0) {
+                ob_clean();
+                wp_send_json_error(array('message' => __('No variations match the current filter.', 'studiou-wc-product-cat-manage')));
+                return;
+            }
+
+            $result = $this->apply_variant_price($matching['ids'], $new_price);
+
+            ob_clean();
+            wp_send_json_success(array(
+                'updated'    => $result['updated'],
+                'failed'     => $result['failed'],
+                'failed_ids' => $result['failed_ids'],
+                'message'    => $result['message'],
+            ));
+        } catch (Exception $e) {
+            error_log('STUDIOU WC UPVP: apply failed - ' . $e->getMessage());
+            ob_clean();
+            wp_send_json_error(array('message' => $e->getMessage()));
+        } finally {
+            error_reporting($original_error_reporting);
+            ob_end_clean();
+        }
+    }
 }

+ 430 - 0
studiou-wc-product-cat-manage/inplementation-plan-upvp.md

@@ -0,0 +1,430 @@
+# Implementation Plan — Update Product Variant Prices (UPVP)
+
+**Target version:** 1.5.3 (bump from current 1.5.2)
+**Scope:** Add a new section "Update product variant prices" to the existing **Category Manipulations** admin page.
+**Goal:** Allow admins to bulk-update the regular price of product *variations* by filtering on (a) one or more product categories and (b) a single variation attribute name + value pair. Provide a Dry Run preview, an Apply commit, and a live affected-count label. Operation must touch only `product_variation` records — never order items, carts, or order history.
+
+---
+
+## 1. Feature summary
+
+On the page **Products → Category Manipulations**, append a new card titled **"Update product variant prices"** placed **before** the *Important Notes* card and **after** the *Remove Not Assigned Media* card. It contains:
+
+1. **Categories checkbox list** — product-category tree (re-using the existing `studiou-wcpcm-categories-list-container` markup and styles). With `Check All` / `Uncheck All` buttons.
+2. **Variant attribute combobox** — populated with the list of attribute *names* (heads) that are used for variations across the WooCommerce store (global `pa_*` taxonomies + product-level custom attributes flagged "used for variations").
+3. **Variant value combobox** — populated dynamically based on the selected attribute (and scoped to the currently checked categories). Lists distinct values that exist on real variations.
+4. **New Price** textbox — numeric input (`type=number`, `step=0.01`, `min=0`); server normalises with `wc_format_decimal`.
+5. **Dry Run** button — counts and lists the variations that would be updated, without writing.
+6. **Apply** button — saves `set_regular_price()` to all matching variations via the WooCommerce API.
+7. **Affected-count label** — live counter; recalculated when filters change, after Dry Run, and after Apply.
+
+Operation only modifies `regular_price` on `product_variation` posts. Sale prices, order line items, cart sessions and historical order data are not touched.
+
+---
+
+## 2. Files to create / modify
+
+### 2.1 New files
+- *(none)* — all new logic fits into existing classes / views.
+
+### 2.2 Modified files
+
+| File | Change |
+| --- | --- |
+| `studiou-wc-product-cat-manage.php` | Bump `Version:` header and `STUDIOU_WCPCM_VERSION` to `1.5.3`. Extend the `i18n` map in `wp_localize_script` with new UPVP strings. |
+| `includes/class-studiou-wc-product-cat-manage-manipulator.php` | Add 4 new public AJAX handlers + 4 helper methods (see §3). Register the 4 actions in the constructor's `add_action(...)` block. |
+| `views/manipulations.php` | Append the new "Update product variant prices" card before the *Important Notes* card. |
+| `assets/css/admin.css` | Minor styling for the new section (results panel, affected-count label, preview table). |
+| `assets/js/admin.js` | Add `initUpdateVariantPricesForm()` wrapped in the existing `safeInit()` pattern. |
+| `languages/studiou-wc-product-cat-manage.pot` | Add new translation strings. |
+| `languages/studiou-wc-product-cat-manage-cs_CZ.po` | Add Czech translations. |
+| `languages/studiou-wc-product-cat-manage-cs_CZ.mo` | Regenerate (`msgfmt`). |
+| `readme.md` | Bump version, new section in *Features*, new usage section, **Version 1.5.3** changelog entry. |
+| `CLAUDE.md` | Bump *Current Version* to 1.5.3, add 4 new methods to manipulator bullets, add 4 new AJAX endpoints, add bullet under *Admin Menu Structure → Category Manipulations*. |
+| `directory-structure.txt` | No structural change — verify it still matches reality. |
+
+---
+
+## 3. Backend implementation
+
+All backend work goes into `class-studiou-wc-product-cat-manage-manipulator.php`. We extend the existing manipulator because UPVP is conceptually a category manipulation and shares helpers (`get_categories_with_counts`, AJAX boilerplate) with the existing manipulator. No new class.
+
+### 3.1 New AJAX action registrations
+
+Add inside `Studiou_WC_Product_Cat_Manage_Manipulator::__construct()`, alongside the existing `add_action('wp_ajax_studiou_wcpcm_*', ...)` lines:
+
+```php
+add_action('wp_ajax_studiou_wcpcm_upvp_get_attributes',       array($this, 'handle_upvp_get_attributes_ajax'));
+add_action('wp_ajax_studiou_wcpcm_upvp_get_attribute_values', array($this, 'handle_upvp_get_attribute_values_ajax'));
+add_action('wp_ajax_studiou_wcpcm_upvp_count',                array($this, 'handle_upvp_count_ajax'));
+add_action('wp_ajax_studiou_wcpcm_upvp_apply',                array($this, 'handle_upvp_apply_ajax'));
+```
+
+Each handler follows the established pattern in the file:
+- `while (ob_get_level()) ob_end_clean(); ob_start();`
+- `error_reporting(0)` save/restore.
+- `wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')`.
+- `current_user_can('manage_woocommerce')`.
+- `try { ... } catch(Exception $e) { ... } finally { restore + ob_end_clean(); }`.
+- Always-on error logging consistent with `STUDIOU WC MEDIA:` style — prefix `STUDIOU WC UPVP:`.
+
+### 3.2 New methods on `Studiou_WC_Product_Cat_Manage_Manipulator`
+
+```php
+/** Return list of attribute "heads" (names) usable for variations. */
+public function get_variation_attribute_heads(): array
+// Returns: [ ['key' => 'pa_color', 'label' => 'Color', 'is_taxonomy' => true], ... ]
+// Source 1: every wc_get_attribute_taxonomies() entry (global attributes) -> key = 'pa_<slug>'
+// Source 2: distinct meta_key starting with 'attribute_' on post_type='product_variation'
+//           (covers custom non-taxonomy attributes flagged for variations)
+//           -> key = substring after 'attribute_' (NOT prefixed with 'pa_')
+// De-duplicate by key, sort by human label asc.
+
+/** Return distinct values for a given attribute key, scoped to existing variations. */
+public function get_variation_attribute_values(string $attribute_key, array $category_ids = []): array
+// Returns: [ ['value' => 'red', 'label' => 'Red'], ... ] sorted by label.
+// SQL strategy:
+//   SELECT DISTINCT pm.meta_value
+//     FROM {$wpdb->postmeta} pm
+//     INNER JOIN {$wpdb->posts} v        ON v.ID = pm.post_id AND v.post_type = 'product_variation'
+//                                                              AND v.post_status IN ('publish','private')
+//     INNER JOIN {$wpdb->posts} p        ON p.ID = v.post_parent AND p.post_type = 'product'
+//     [INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
+//      INNER JOIN {$wpdb->term_taxonomy}     tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
+//      WHERE tt.taxonomy = 'product_cat' AND tt.term_id IN (...)]
+//    AND pm.meta_key = 'attribute_<sanitized key>'
+//    AND pm.meta_value <> ''
+// For taxonomy attributes (pa_*): meta_value is the term slug -> resolve labels via get_term_by('slug', ..., 'pa_<key>').
+// For non-taxonomy: meta_value is already the label.
+// All input goes through wc_sanitize_taxonomy_name() / sanitize_text_field. Use $wpdb->prepare with %d/%s placeholders.
+
+/** Count + list variations that match the filter. */
+public function find_matching_variations(array $category_ids, string $attribute_key, string $attribute_value): array
+// Returns:
+//   [
+//     'count'  => int,
+//     'ids'    => int[],          // all matching variation IDs
+//     'sample' => array[]         // up to 20 entries:
+//                                 //   [ 'id', 'sku', 'name', 'parent_name', 'current_price' ]
+//   ]
+// Same JOIN as above but selecting v.ID. Reads sample with WC API (wc_get_product) capped at 20.
+// Uses $wpdb->prepare with implode(',', array_fill(0, count($category_ids), '%d')) for the IN list.
+
+/** Apply new price to a list of variation IDs. */
+public function apply_variant_price(array $variation_ids, string $new_price): array
+// Returns ['updated'=>int, 'failed'=>int, 'failed_ids'=>int[], 'message'=>string].
+// Steps for each ID:
+//   $variation = wc_get_product($id);
+//   if (!$variation || !$variation->is_type('variation')) { failed[] = id; continue; }
+//   $variation->set_regular_price(wc_format_decimal($new_price));
+//   $variation->save();
+// After loop: foreach (unique parent_id) WC_Product_Variable::sync($parent_id);
+//             wc_delete_product_transients($parent_id);
+// Validation guard at entry: must be numeric, >= 0; otherwise return error.
+```
+
+### 3.3 Handler responsibilities (inputs → outputs)
+
+| Handler | Input ($_POST) | `data` on success |
+| --- | --- | --- |
+| `handle_upvp_get_attributes_ajax` | `nonce` | `attributes: [{key,label,is_taxonomy}]` |
+| `handle_upvp_get_attribute_values_ajax` | `nonce`, `attribute_key`, `category_ids[]` (optional) | `values: [{value,label}]` |
+| `handle_upvp_count_ajax` | `nonce`, `category_ids[]`, `attribute_key`, `attribute_value` | `count, sample[]` |
+| `handle_upvp_apply_ajax` | `nonce`, `category_ids[]`, `attribute_key`, `attribute_value`, `new_price` | `updated, failed, failed_ids, message` |
+
+All array inputs run through `array_map('intval', ...)`; strings through `sanitize_text_field`; `attribute_key` additionally through `wc_sanitize_taxonomy_name`. The price stays raw, normalised in `apply_variant_price`.
+
+### 3.4 Edge cases handled
+
+- Empty category list → error "Please select at least one category".
+- Empty attribute key → error.
+- Empty attribute value → error.
+- Non-numeric / negative price → error before any DB write.
+- Variation belongs to a non-variable parent (orphan) → counted as `failed`.
+- Attribute key collision between a taxonomy attribute and a same-labelled custom attribute — disambiguated by storing the full key (`pa_color` vs `color`) in the combobox `value`, not the label.
+- Order-line price preservation — operation never touches `woocommerce_order_itemmeta`. Document this explicitly in the *Important Notes* card and changelog.
+- Parent variable product price-range cache — re-synced via `WC_Product_Variable::sync($parent_id)` after the batch so the storefront price range stays correct.
+
+---
+
+## 4. Frontend implementation
+
+### 4.1 View — `views/manipulations.php`
+
+Insert a new card immediately **before** the existing `<!-- Important Notes -->` card:
+
+```php
+<!-- Update Product Variant Prices Operation -->
+<div class="studiou-wcpcm-card">
+    <h2><?php echo esc_html__('Update product variant prices', 'studiou-wc-product-cat-manage'); ?></h2>
+
+    <p><?php echo esc_html__('Bulk-update the regular price of product variations matching the selected categories and a chosen variation attribute. Only product records are modified — existing orders and cart contents are not affected.', 'studiou-wc-product-cat-manage'); ?></p>
+
+    <form id="studiou-wcpcm-upvp-form" method="post" onsubmit="return false;">
+        <table class="form-table">
+            <!-- 1. Category checkbox list -->
+            <tr>
+                <th scope="row">
+                    <label for="upvp_categories_list"><?php echo esc_html__('Select Categories', 'studiou-wc-product-cat-manage'); ?></label>
+                </th>
+                <td>
+                    <div class="studiou-wcpcm-categories-list-container">
+                        <div class="studiou-wcpcm-list-actions">
+                            <button type="button" id="studiou-wcpcm-upvp-check-all" class="button">
+                                <?php echo esc_html__('Check All', 'studiou-wc-product-cat-manage'); ?>
+                            </button>
+                            <button type="button" id="studiou-wcpcm-upvp-uncheck-all" class="button">
+                                <?php echo esc_html__('Uncheck All', 'studiou-wc-product-cat-manage'); ?>
+                            </button>
+                        </div>
+                        <div class="studiou-wcpcm-categories-list" id="upvp_categories_list">
+                            <?php foreach ($categories as $category): ?>
+                                <label class="studiou-wcpcm-category-item">
+                                    <input type="checkbox" name="upvp_categories[]" value="<?php echo esc_attr($category['id']); ?>">
+                                    <span><?php echo esc_html($category['display_name']); ?></span>
+                                </label>
+                            <?php endforeach; ?>
+                        </div>
+                    </div>
+                </td>
+            </tr>
+
+            <!-- 2. Variant attribute combobox -->
+            <tr>
+                <th scope="row">
+                    <label for="upvp_attribute"><?php echo esc_html__('Variant attribute', 'studiou-wc-product-cat-manage'); ?></label>
+                </th>
+                <td>
+                    <select id="upvp_attribute" name="upvp_attribute" class="regular-text">
+                        <option value=""><?php echo esc_html__('-- Select attribute --', 'studiou-wc-product-cat-manage'); ?></option>
+                    </select>
+                </td>
+            </tr>
+
+            <!-- 3. Variant value combobox -->
+            <tr>
+                <th scope="row">
+                    <label for="upvp_attribute_value"><?php echo esc_html__('Variant attribute value', 'studiou-wc-product-cat-manage'); ?></label>
+                </th>
+                <td>
+                    <select id="upvp_attribute_value" name="upvp_attribute_value" class="regular-text" disabled>
+                        <option value=""><?php echo esc_html__('-- Select value --', 'studiou-wc-product-cat-manage'); ?></option>
+                    </select>
+                </td>
+            </tr>
+
+            <!-- 4. New price -->
+            <tr>
+                <th scope="row">
+                    <label for="upvp_new_price"><?php echo esc_html__('New Price', 'studiou-wc-product-cat-manage'); ?></label>
+                </th>
+                <td>
+                    <input type="number" step="0.01" min="0" id="upvp_new_price" name="upvp_new_price" class="regular-text">
+                </td>
+            </tr>
+
+            <!-- 6. Affected-count label -->
+            <tr>
+                <th scope="row">
+                    <?php echo esc_html__('Affected variations', 'studiou-wc-product-cat-manage'); ?>
+                </th>
+                <td>
+                    <strong id="studiou-wcpcm-upvp-affected-count">0</strong>
+                </td>
+            </tr>
+        </table>
+
+        <!-- 5. Buttons -->
+        <div class="submit-buttons">
+            <button type="button" id="studiou-wcpcm-upvp-dry-run-button" class="button">
+                <?php echo esc_html__('Dry Run', 'studiou-wc-product-cat-manage'); ?>
+            </button>
+            <button type="button" id="studiou-wcpcm-upvp-apply-button" class="button button-primary">
+                <?php echo esc_html__('Apply', 'studiou-wc-product-cat-manage'); ?>
+            </button>
+            <span class="spinner"></span>
+        </div>
+    </form>
+
+    <div class="studiou-wcpcm-upvp-results" style="display: none;">
+        <h3><?php echo esc_html__('Results', 'studiou-wc-product-cat-manage'); ?></h3>
+        <div id="studiou-wcpcm-upvp-message"></div>
+    </div>
+</div>
+```
+
+`$categories` is already populated above via `$manipulator->get_categories_with_counts()`.
+
+### 4.2 JS — `assets/js/admin.js`
+
+Add a single `initUpdateVariantPricesForm()` and register it inside the existing `$(document).ready()` block via the existing `safeInit()` wrapper:
+
+```js
+safeInit('upvpForm', function() {
+    if ($('#studiou-wcpcm-upvp-form').length) initUpdateVariantPricesForm();
+});
+```
+
+Logic:
+
+1. **On init** — request `studiou_wcpcm_upvp_get_attributes`; populate `#upvp_attribute` with `<option value="key" data-istax="0|1">label</option>`.
+2. **Check All / Uncheck All** — same pattern as the existing batch-description and delete-products handlers.
+3. **On `#upvp_attribute change`** — clear `#upvp_attribute_value`; if non-empty attribute, call `studiou_wcpcm_upvp_get_attribute_values` (passing currently-checked categories so the value list is scoped); enable the value combobox after population. Trigger affected-count refresh.
+4. **On any filter change** (categories checkboxes, attribute, value) — debounced (300 ms) call to `studiou_wcpcm_upvp_count`; update `#studiou-wcpcm-upvp-affected-count`. Disable both Dry Run and Apply buttons while a count request is in-flight or while the count is `0`.
+5. **Dry Run click** — call count endpoint with full filter; render a results panel containing a small `<table class="studiou-wcpcm-upvp-preview-table">` listing the sample variations: ID, SKU, Name, Parent, Current → New Price.
+6. **Apply click** — JS `confirm()` ("This will update X variations. Continue?"); on accept, POST `studiou_wcpcm_upvp_apply` with the same filter + new price; on success render summary (updated, failed, failed_ids[]) and refresh the affected-count.
+
+All XHR calls follow the existing AJAX boilerplate: spinner, notice area, `console.log` debug lines, `showNotice()` helper.
+
+### 4.3 CSS — `assets/css/admin.css`
+
+Append:
+
+```css
+.studiou-wcpcm-upvp-results {
+    background: #f9f9f9;
+    padding: 15px;
+    border: 1px solid #ddd;
+    border-radius: 4px;
+    margin-top: 30px;
+}
+
+#studiou-wcpcm-upvp-affected-count {
+    font-size: 1.2em;
+    color: #0073aa;
+}
+
+.studiou-wcpcm-upvp-preview-table {
+    width: 100%;
+    border-collapse: collapse;
+    margin-top: 10px;
+}
+
+.studiou-wcpcm-upvp-preview-table th,
+.studiou-wcpcm-upvp-preview-table td {
+    padding: 6px 10px;
+    border-bottom: 1px solid #eee;
+    text-align: left;
+}
+```
+
+---
+
+## 5. Translations
+
+Add the following new English → Czech strings; register in the `.pot`, the `cs_CZ.po`, and recompile `cs_CZ.mo` with `msgfmt`.
+
+| English | Czech |
+| --- | --- |
+| Update product variant prices | Aktualizace cen variant produktů |
+| Bulk-update the regular price of product variations matching the selected categories and a chosen variation attribute. Only product records are modified — existing orders and cart contents are not affected. | Hromadná aktualizace běžné ceny variant produktů odpovídajících vybraným kategoriím a zvolené vlastnosti varianty. Mění se pouze záznamy produktů — existující objednávky a obsah košíku nejsou dotčeny. |
+| Variant attribute | Vlastnost varianty |
+| Variant attribute value | Hodnota vlastnosti varianty |
+| -- Select attribute -- | -- Vyberte vlastnost -- |
+| -- Select value -- | -- Vyberte hodnotu -- |
+| New Price | Nová cena |
+| Dry Run | Zkušební běh |
+| Apply | Provést |
+| Affected variations | Dotčené varianty |
+| Results | Výsledky |
+| No variations match the current filter. | Žádné varianty neodpovídají vybranému filtru. |
+| Successfully updated %d variations. | Úspěšně aktualizováno %d variant. |
+| Failed to update %d variations. | Nepodařilo se aktualizovat %d variant. |
+| Are you sure you want to update %d variations? | Opravdu chcete aktualizovat %d variant? |
+| Please select an attribute | Vyberte prosím vlastnost |
+| Please select an attribute value | Vyberte prosím hodnotu vlastnosti |
+| Please enter a valid new price | Zadejte prosím platnou novou cenu |
+| Loading attributes... | Načítání vlastností... |
+| Loading values... | Načítání hodnot... |
+| Dry run complete: %d variations would be updated to %s. | Zkušební běh dokončen: %d variant bude aktualizováno na %s. |
+| Variant prices updated successfully | Ceny variant úspěšně aktualizovány |
+| Variant prices update error | Chyba aktualizace cen variant |
+| The update product variant prices operation only modifies the regular price of product variations. Order line items, cart contents, and historical order data are never modified. | Operace aktualizace cen variant mění pouze běžnou cenu variant produktů. Položky objednávek, obsah košíku ani historická data objednávek nejsou nikdy měněny. |
+
+Add matching keys to the `i18n` array in `wp_localize_script(... 'studiouWcpcm', ...)` in `studiou-wc-product-cat-manage.php`:
+`upvpConfirmApply`, `upvpSelectAttribute`, `upvpSelectValue`, `upvpInvalidPrice`, `upvpLoadingAttributes`, `upvpLoadingValues`, `upvpDryRunComplete`, `upvpApplySuccess`, `upvpApplyError`, `upvpNoMatches`.
+
+---
+
+## 6. Documentation updates
+
+### 6.1 `readme.md`
+- Bump `**Version: 1.5.2**` → `**Version: 1.5.3**`.
+- Under **Features → Category Management**, add: `**Update product variant prices** - bulk-update regular price of variations by category + attribute filter (NEW in v1.5.3)`.
+- Under **Menu Structure → Category Management → Category Manipulations** sub-list, add: `Update product variant prices (NEW in v1.5.3)`.
+- Add a new usage section **"Updating Product Variant Prices"** mirroring the structure of the existing Manipulation usage sections, with the 6 controls listed and notes:
+  - Operation modifies only `regular_price` of `product_variation` records.
+  - Order line items, carts and historical data are **not** affected.
+  - Always run **Dry Run** first to preview the affected count and sample list.
+  - Parent variable products are re-synced (`WC_Product_Variable::sync`) after the batch so the storefront price range cache stays correct.
+- Append changelog entry **Version 1.5.3** above 1.5.2:
+  ```
+  ### Version 1.5.3
+  - **NEW: Update product variant prices** - bulk-update regular price of product variations
+    - Filter by selected product categories (with Check All / Uncheck All)
+    - Variant attribute combobox (heads), populated from variation-eligible attributes
+    - Variant value combobox, dynamically scoped to the chosen attribute and selected categories
+    - New Price textbox with numeric validation
+    - Dry Run button shows affected count + sample preview, no DB writes
+    - Apply button commits via WooCommerce API (`set_regular_price` + `WC_Product_Variable::sync`)
+    - Live affected-count label updates on every filter change
+  - 4 new AJAX handlers: `studiou_wcpcm_upvp_get_attributes`, `studiou_wcpcm_upvp_get_attribute_values`, `studiou_wcpcm_upvp_count`, `studiou_wcpcm_upvp_apply`
+  - Operation never touches order items, cart sessions, or historical order data
+  - Added 23 new translation strings (English/Czech)
+  ```
+
+### 6.2 `CLAUDE.md`
+- Bump *Current Version* `1.5.2` → `1.5.3`.
+- Under `class-studiou-wc-product-cat-manage-manipulator.php` bullet list, add: `Update product variant prices (regular_price only, by category + attribute filter) (NEW in v1.5.3)`.
+- Under *Views (views/) → manipulations.php*, add: `Update product variant prices (checkbox list + 2 comboboxes + price input + Dry Run/Apply buttons + live affected-count label) (NEW in v1.5.3)`.
+- Under *Admin Menu Structure → Category Manipulations*, add bullet: `Update product variant prices (NEW in v1.5.3)`.
+- Under *AJAX Handlers → Category Operations*, append entries 11–14:
+  - `studiou_wcpcm_upvp_get_attributes` — list variation attribute heads (NEW in v1.5.3)
+  - `studiou_wcpcm_upvp_get_attribute_values` — list values for selected attribute (NEW in v1.5.3)
+  - `studiou_wcpcm_upvp_count` — count + sample of variations matching current filter (NEW in v1.5.3)
+  - `studiou_wcpcm_upvp_apply` — commit new regular price to matching variations (NEW in v1.5.3)
+- Renumber the *Product Operations* section (currently starts at 11) accordingly.
+- Add *Testing Notes → Update Product Variant Prices Operations (NEW in v1.5.3)* section listing the test cases from §7 below.
+
+### 6.3 `directory-structure.txt`
+- No structural change. Verify it is still accurate (existing classes get methods added; no new files).
+
+---
+
+## 7. Step-by-step execution order
+
+1. Bump version constant + plugin header to `1.5.3`.
+2. Implement `get_variation_attribute_heads`, `get_variation_attribute_values`, `find_matching_variations`, `apply_variant_price` in `Studiou_WC_Product_Cat_Manage_Manipulator` (no AJAX yet).
+3. Add the 4 AJAX handler methods + register them in the constructor.
+4. Append the UPVP card to `views/manipulations.php` (before *Important Notes*).
+5. Add CSS rules.
+6. Add `initUpdateVariantPricesForm()` to `admin.js`, hook through existing `safeInit()`.
+7. Extend `wp_localize_script` `i18n` map with new keys.
+8. Update `.pot`, `cs_CZ.po`; regenerate `cs_CZ.mo` with `msgfmt`.
+9. Update `readme.md`, `CLAUDE.md`. Verify `directory-structure.txt`.
+10. Manual smoke test — see §8.
+11. Hard-refresh in browser (studiou.cz strips `?ver=` query strings — note in commit message).
+
+---
+
+## 8. Test plan
+
+Manual UI tests on a dev WC store with at least one variable product per fixture below:
+
+1. **Happy path (taxonomy attribute)** — Pick 1 category containing variable products with `pa_color = red`. Pick attribute *Color*, value *Red*, price `19.99`. Affected-count updates as filters change. Dry Run shows correct count + sample table. Apply → updated count matches; spot-check a variation in WP admin shows new regular price; parent variable product price range reflects the update.
+2. **Happy path (custom non-taxonomy attribute)** — Same flow on a product whose Size attribute is product-level (not global).
+3. **Multi-category** — Select two sibling categories; variations counted only once each.
+4. **No matches** — Filter that yields zero variations: Apply button stays disabled, label shows `0`.
+5. **Invalid price** — Empty / negative / non-numeric → error notice, no DB write.
+6. **Permission** — User without `manage_woocommerce` cannot trigger AJAX.
+7. **Nonce** — Tampered nonce → `Security check failed`.
+8. **Order data integrity** — Place a test order with a variation, run UPVP, confirm the order line item retains the original price.
+9. **Translations** — Switch site to `cs_CZ`, confirm UPVP labels render in Czech.
+10. **Cache busting** — Asset version query string updates from `1.5.2` to `1.5.3`. Hard-refresh on studiou.cz.
+11. **Parent re-sync** — After Apply, variable-product price range on storefront reflects the new regular price (sync transient cleared via `wc_delete_product_transients`).
+
+---
+
+## 9. Rollback
+
+Purely additive change — no schema migrations, no destructive operations, no changes to existing handlers. To roll back: revert the listed files to 1.5.2 and re-enqueue assets. No data cleanup required because UPVP only writes to existing `product_variation` records' `_regular_price` postmeta via the WC API.

BIN
studiou-wc-product-cat-manage/languages/studiou-wc-product-cat-manage-cs_CZ.mo


+ 99 - 2
studiou-wc-product-cat-manage/languages/studiou-wc-product-cat-manage-cs_CZ.po

@@ -3,7 +3,7 @@
 # This file is distributed under the same license as the QDR - Studiou WC Export/Import Product Categories package.
 msgid ""
 msgstr ""
-"Project-Id-Version: QDR - Studiou WC Export/Import Product Categories 1.5.2\n"
+"Project-Id-Version: QDR - Studiou WC Export/Import Product Categories 1.5.3\n"
 "Report-Msgid-Bugs-To: https://www.quadarax.com/plugins/studiou-wc-product-"
 "cat-manage\n"
 "POT-Creation-Date: 2026-04-01 12:00+0000\n"
@@ -991,4 +991,101 @@ msgstr "Operace odstranění nepřiřazených médií trvale smaže všechny med
 
 #: includes/class-studiou-wc-product-cat-manage-manipulator.php
 msgid "Found %d unused uncategorized media files to delete"
-msgstr "Nalezeno %d nepoužívaných nekategorizovaných mediálních souborů ke smazání"
+msgstr "Nalezeno %d nepoužívaných nekategorizovaných mediálních souborů ke smazání"
+
+# UPVP — Update Product Variant Prices (v1.5.3)
+#: views/manipulations.php
+msgid "Update product variant prices"
+msgstr "Aktualizace cen variant produktů"
+
+#: views/manipulations.php
+msgid "Bulk-update the regular price of product variations matching the selected categories and a chosen variation attribute. Only product records are modified — existing orders and cart contents are not affected."
+msgstr "Hromadná aktualizace běžné ceny variant produktů odpovídajících vybraným kategoriím a zvolené vlastnosti varianty. Mění se pouze záznamy produktů — existující objednávky a obsah košíku nejsou dotčeny."
+
+#: views/manipulations.php
+msgid "Variant attribute"
+msgstr "Vlastnost varianty"
+
+#: views/manipulations.php
+msgid "Variant attribute value"
+msgstr "Hodnota vlastnosti varianty"
+
+#: studiou-wc-product-cat-manage.php views/manipulations.php
+msgid "-- Select attribute --"
+msgstr "-- Vyberte vlastnost --"
+
+#: studiou-wc-product-cat-manage.php views/manipulations.php
+msgid "-- Select value --"
+msgstr "-- Vyberte hodnotu --"
+
+#: views/manipulations.php
+msgid "New Price"
+msgstr "Nová cena"
+
+#: views/manipulations.php
+msgid "Dry Run"
+msgstr "Zkušební běh"
+
+#: views/manipulations.php
+msgid "Apply"
+msgstr "Provést"
+
+#: views/manipulations.php
+msgid "Affected variations"
+msgstr "Dotčené varianty"
+
+#: views/manipulations.php
+msgid "Results"
+msgstr "Výsledky"
+
+#: includes/class-studiou-wc-product-cat-manage-manipulator.php
+msgid "No variations match the current filter."
+msgstr "Žádné varianty neodpovídají vybranému filtru."
+
+#: includes/class-studiou-wc-product-cat-manage-manipulator.php
+msgid "Successfully updated %d variations."
+msgstr "Úspěšně aktualizováno %d variant."
+
+#: includes/class-studiou-wc-product-cat-manage-manipulator.php
+msgid "Failed to update %d variations."
+msgstr "Nepodařilo se aktualizovat %d variant."
+
+#: studiou-wc-product-cat-manage.php
+msgid "Are you sure you want to update %d variations?"
+msgstr "Opravdu chcete aktualizovat %d variant?"
+
+#: includes/class-studiou-wc-product-cat-manage-manipulator.php
+msgid "Please select an attribute"
+msgstr "Vyberte prosím vlastnost"
+
+#: includes/class-studiou-wc-product-cat-manage-manipulator.php
+msgid "Please select an attribute value"
+msgstr "Vyberte prosím hodnotu vlastnosti"
+
+#: studiou-wc-product-cat-manage.php includes/class-studiou-wc-product-cat-manage-manipulator.php
+msgid "Please enter a valid new price"
+msgstr "Zadejte prosím platnou novou cenu"
+
+#: studiou-wc-product-cat-manage.php
+msgid "Loading attributes..."
+msgstr "Načítání vlastností..."
+
+#: studiou-wc-product-cat-manage.php
+msgid "Loading values..."
+msgstr "Načítání hodnot..."
+
+#: studiou-wc-product-cat-manage.php
+msgid "Dry run complete: %d variations would be updated to %s."
+msgstr "Zkušební běh dokončen: %d variant bude aktualizováno na %s."
+
+#: studiou-wc-product-cat-manage.php
+msgid "Variant prices updated successfully"
+msgstr "Ceny variant úspěšně aktualizovány"
+
+#: studiou-wc-product-cat-manage.php
+msgid "Variant prices update error"
+msgstr "Chyba aktualizace cen variant"
+
+#: views/manipulations.php
+msgid "The update product variant prices operation only modifies the regular price of product variations. Order line items, cart contents, and historical order data are never modified."
+msgstr "Operace aktualizace cen variant mění pouze běžnou cenu variant produktů. Položky objednávek, obsah košíku ani historická data objednávek nejsou nikdy měněny."

+ 98 - 1
studiou-wc-product-cat-manage/languages/studiou-wc-product-cat-manage.po

@@ -2,7 +2,7 @@
 # This file is distributed under the same license as the QDR - Studiou WC Export/Import Product Categories package.
 msgid ""
 msgstr ""
-"Project-Id-Version: QDR - Studiou WC Export/Import Product Categories 1.5.2\n"
+"Project-Id-Version: QDR - Studiou WC Export/Import Product Categories 1.5.3\n"
 "Report-Msgid-Bugs-To: https://www.quadarax.com/plugins/studiou-wc-product-cat-manage\n"
 "POT-Creation-Date: 2026-04-01 12:00+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
@@ -957,4 +957,101 @@ msgstr ""
 
 #: includes/class-studiou-wc-product-cat-manage-manipulator.php
 msgid "Found %d unused uncategorized media files to delete"
+msgstr ""
+
+# UPVP — Update Product Variant Prices (v1.5.3)
+#: views/manipulations.php
+msgid "Update product variant prices"
+msgstr ""
+
+#: views/manipulations.php
+msgid "Bulk-update the regular price of product variations matching the selected categories and a chosen variation attribute. Only product records are modified — existing orders and cart contents are not affected."
+msgstr ""
+
+#: views/manipulations.php
+msgid "Variant attribute"
+msgstr ""
+
+#: views/manipulations.php
+msgid "Variant attribute value"
+msgstr ""
+
+#: studiou-wc-product-cat-manage.php views/manipulations.php
+msgid "-- Select attribute --"
+msgstr ""
+
+#: studiou-wc-product-cat-manage.php views/manipulations.php
+msgid "-- Select value --"
+msgstr ""
+
+#: views/manipulations.php
+msgid "New Price"
+msgstr ""
+
+#: views/manipulations.php
+msgid "Dry Run"
+msgstr ""
+
+#: views/manipulations.php
+msgid "Apply"
+msgstr ""
+
+#: views/manipulations.php
+msgid "Affected variations"
+msgstr ""
+
+#: views/manipulations.php
+msgid "Results"
+msgstr ""
+
+#: includes/class-studiou-wc-product-cat-manage-manipulator.php
+msgid "No variations match the current filter."
+msgstr ""
+
+#: includes/class-studiou-wc-product-cat-manage-manipulator.php
+msgid "Successfully updated %d variations."
+msgstr ""
+
+#: includes/class-studiou-wc-product-cat-manage-manipulator.php
+msgid "Failed to update %d variations."
+msgstr ""
+
+#: studiou-wc-product-cat-manage.php
+msgid "Are you sure you want to update %d variations?"
+msgstr ""
+
+#: includes/class-studiou-wc-product-cat-manage-manipulator.php
+msgid "Please select an attribute"
+msgstr ""
+
+#: includes/class-studiou-wc-product-cat-manage-manipulator.php
+msgid "Please select an attribute value"
+msgstr ""
+
+#: studiou-wc-product-cat-manage.php includes/class-studiou-wc-product-cat-manage-manipulator.php
+msgid "Please enter a valid new price"
+msgstr ""
+
+#: studiou-wc-product-cat-manage.php
+msgid "Loading attributes..."
+msgstr ""
+
+#: studiou-wc-product-cat-manage.php
+msgid "Loading values..."
+msgstr ""
+
+#: studiou-wc-product-cat-manage.php
+msgid "Dry run complete: %d variations would be updated to %s."
+msgstr ""
+
+#: studiou-wc-product-cat-manage.php
+msgid "Variant prices updated successfully"
+msgstr ""
+
+#: studiou-wc-product-cat-manage.php
+msgid "Variant prices update error"
+msgstr ""
+
+#: views/manipulations.php
+msgid "The update product variant prices operation only modifies the regular price of product variations. Order line items, cart contents, and historical order data are never modified."
 msgstr ""

+ 40 - 1
studiou-wc-product-cat-manage/readme.md

@@ -1,6 +1,6 @@
 # QDR - Studiou WC Export/Import Product Categories
 
-**Version: 1.5.2**
+**Version: 1.5.3**
 
 A WordPress plugin that extends WooCommerce to provide batch export and import functionality for both product categories and products, along with category manipulation tools.
 
@@ -27,6 +27,7 @@ The plugin provides a complete solution for bulk product and category management
 - **Delete products in category** - permanently delete all products and variants from selected categories
 - **Clear Media Categories** - permanently delete all media files from selected media categories (NEW in v1.5)
 - **Remove Not Assigned Media** - permanently delete all unused uncategorized media files (NEW in v1.5.2)
+- **Update product variant prices** - bulk-update regular price of variations by category + attribute filter (NEW in v1.5.3)
 
 ### Product Management (NEW in v1.4)
 - **Export products to CSV** - Export variable products and variations by category selection
@@ -265,6 +266,29 @@ The plugin uses CSV files with the following columns for products:
 - A real-time progress bar shows the deletion progress
 - The page will automatically reload after successful deletion to update the count
 
+#### Update Product Variant Prices (NEW in v1.5.3)
+
+1. Go to Products > Category Manipulations
+2. Scroll to the "Update product variant prices" section
+3. Select one or more product categories using the checkboxes
+   - Use "Check All" to select all categories
+   - Use "Uncheck All" to deselect all categories
+4. Pick a **Variant attribute** from the dropdown (heads such as Color, Size — populated from variation-eligible attributes in the store)
+5. Pick a **Variant attribute value** from the dropdown (values are scoped to the chosen attribute and selected categories)
+6. Enter the **New Price**
+7. Watch the **Affected variations** label — it updates live as you change filters
+8. Click **Dry Run** to preview the count and a sample of variations that would be updated (no DB writes)
+9. Click **Apply** to commit the new regular price; you will be asked to confirm
+10. Review the results showing updated / failed counts and any failed variation IDs
+
+**Important Notes about Update Product Variant Prices:**
+- The operation modifies only the `regular_price` of `product_variation` records
+- **Order line items, cart contents, and historical order data are never modified** — past orders keep their original prices
+- All updates go through the WooCommerce API (`set_regular_price` + `WC_Product_Variable::sync` on the parent variable products)
+- Parent variable products are re-synced after the batch so the storefront price-range cache stays correct
+- Always run **Dry Run** first to verify the affected count
+- If a variation belongs to a non-variable parent (orphan), it is counted as failed and listed in the result
+
 ## Sample CSVs
 
 ### Sample Product Category CSV
@@ -305,6 +329,8 @@ After activation, the plugin adds the following menu items under **Products** in
   - Delete products in category
   - Clear Media Categories (NEW in v1.5)
   - Remove Not Assigned Media (NEW in v1.5.2)
+  - Update product variant prices (NEW in v1.5.3)
+  - Update product variant prices (NEW in v1.5.3)
 
 ### Product Management (NEW in v1.4)
 - **Product Import** - Batch import variable products and variations
@@ -325,6 +351,19 @@ For support, please visit [https://www.quadarax.com/plugins/studiou-wc-product-c
 
 ## Changelog
 
+### Version 1.5.3
+- **NEW: Update product variant prices** - bulk-update regular price of product variations
+  - Filter by selected product categories (with Check All / Uncheck All)
+  - Variant attribute combobox (heads), populated from variation-eligible attributes
+  - Variant value combobox, dynamically scoped to the chosen attribute and selected categories
+  - New Price textbox with numeric validation
+  - Dry Run button shows affected count + sample preview, no DB writes
+  - Apply button commits via WooCommerce API (`set_regular_price` + `WC_Product_Variable::sync`)
+  - Live affected-count label updates on every filter change
+- 4 new AJAX handlers: `studiou_wcpcm_upvp_get_attributes`, `studiou_wcpcm_upvp_get_attribute_values`, `studiou_wcpcm_upvp_count`, `studiou_wcpcm_upvp_apply`
+- Operation never touches order items, cart sessions, or historical order data
+- Added 23 new translation strings (English/Czech)
+
 ### Version 1.5.2
 - **NEW: Remove Not Assigned Media** - permanently delete all unused uncategorized media files
   - Button with count of unused uncategorized media

+ 12 - 2
studiou-wc-product-cat-manage/studiou-wc-product-cat-manage.php

@@ -3,7 +3,7 @@
  * Plugin Name: QDR - Studiou WC Export/Import Product Categories
  * Plugin URI: https://www.quadarax.com/plugins/studiou-wc-product-cat-manage
  * Description: Allows batch import and export WooCommerce product categories and products
- * Version: 1.5.2
+ * Version: 1.5.3
  * Requires at least: 6.8.1
  * Requires PHP: 8.2
  * Author: Dalibor Votruba
@@ -23,7 +23,7 @@ if (!defined('WPINC')) {
 }
 
 // Define plugin constants
-if (!defined('STUDIOU_WCPCM_VERSION')) define('STUDIOU_WCPCM_VERSION', '1.5.2');
+if (!defined('STUDIOU_WCPCM_VERSION')) define('STUDIOU_WCPCM_VERSION', '1.5.3');
 if (!defined('STUDIOU_WCPCM_PLUGIN_DIR')) define('STUDIOU_WCPCM_PLUGIN_DIR', plugin_dir_path(__FILE__));
 if (!defined('STUDIOU_WCPCM_PLUGIN_URL')) define('STUDIOU_WCPCM_PLUGIN_URL', plugin_dir_url(__FILE__));
 if (!defined('STUDIOU_WCPCM_PLUGIN_BASENAME')) define('STUDIOU_WCPCM_PLUGIN_BASENAME', plugin_basename(__FILE__));
@@ -281,6 +281,16 @@ class Studiou_WC_Product_Cat_Manage {
                     'removeUnassignedSuccess' => __('Remove unassigned media operation successful', 'studiou-wc-product-cat-manage'),
                     'removeUnassignedError' => __('Remove unassigned media operation error', 'studiou-wc-product-cat-manage'),
                     'noUnassignedMedia' => __('No unused uncategorized media files found', 'studiou-wc-product-cat-manage'),
+                    'upvpSelectAttribute' => __('-- Select attribute --', 'studiou-wc-product-cat-manage'),
+                    'upvpSelectValue' => __('-- Select value --', 'studiou-wc-product-cat-manage'),
+                    'upvpInvalidPrice' => __('Please enter a valid new price', 'studiou-wc-product-cat-manage'),
+                    'upvpLoadingAttributes' => __('Loading attributes...', 'studiou-wc-product-cat-manage'),
+                    'upvpLoadingValues' => __('Loading values...', 'studiou-wc-product-cat-manage'),
+                    'upvpDryRunComplete' => __('Dry run complete: %d variations would be updated to %s.', 'studiou-wc-product-cat-manage'),
+                    'upvpApplySuccess' => __('Variant prices updated successfully', 'studiou-wc-product-cat-manage'),
+                    'upvpApplyError' => __('Variant prices update error', 'studiou-wc-product-cat-manage'),
+                    'upvpNoMatches' => __('No variations match the current filter.', 'studiou-wc-product-cat-manage'),
+                    'upvpConfirmApply' => __('Are you sure you want to update %d variations?', 'studiou-wc-product-cat-manage'),
                 )
             ));
             

+ 89 - 0
studiou-wc-product-cat-manage/views/manipulations.php

@@ -301,6 +301,94 @@ $unassigned_media_count = $manipulator->get_unassigned_unused_media_count();
         </div>
     </div>
 
+    <!-- Update Product Variant Prices Operation -->
+    <div class="studiou-wcpcm-card">
+        <h2><?php echo esc_html__('Update product variant prices', 'studiou-wc-product-cat-manage'); ?></h2>
+
+        <p><?php echo esc_html__('Bulk-update the regular price of product variations matching the selected categories and a chosen variation attribute. Only product records are modified — existing orders and cart contents are not affected.', 'studiou-wc-product-cat-manage'); ?></p>
+
+        <form id="studiou-wcpcm-upvp-form" method="post" onsubmit="return false;">
+            <table class="form-table">
+                <tr>
+                    <th scope="row">
+                        <label for="upvp_categories_list"><?php echo esc_html__('Select Categories', 'studiou-wc-product-cat-manage'); ?></label>
+                    </th>
+                    <td>
+                        <div class="studiou-wcpcm-categories-list-container">
+                            <div class="studiou-wcpcm-list-actions">
+                                <button type="button" id="studiou-wcpcm-upvp-check-all" class="button">
+                                    <?php echo esc_html__('Check All', 'studiou-wc-product-cat-manage'); ?>
+                                </button>
+                                <button type="button" id="studiou-wcpcm-upvp-uncheck-all" class="button">
+                                    <?php echo esc_html__('Uncheck All', 'studiou-wc-product-cat-manage'); ?>
+                                </button>
+                            </div>
+                            <div class="studiou-wcpcm-categories-list" id="upvp_categories_list">
+                                <?php foreach ($categories as $category): ?>
+                                    <label class="studiou-wcpcm-category-item">
+                                        <input type="checkbox" name="upvp_categories[]" value="<?php echo esc_attr($category['id']); ?>">
+                                        <span><?php echo esc_html($category['display_name']); ?></span>
+                                    </label>
+                                <?php endforeach; ?>
+                            </div>
+                        </div>
+                    </td>
+                </tr>
+                <tr>
+                    <th scope="row">
+                        <label for="upvp_attribute"><?php echo esc_html__('Variant attribute', 'studiou-wc-product-cat-manage'); ?></label>
+                    </th>
+                    <td>
+                        <select id="upvp_attribute" name="upvp_attribute" class="regular-text">
+                            <option value=""><?php echo esc_html__('-- Select attribute --', 'studiou-wc-product-cat-manage'); ?></option>
+                        </select>
+                    </td>
+                </tr>
+                <tr>
+                    <th scope="row">
+                        <label for="upvp_attribute_value"><?php echo esc_html__('Variant attribute value', 'studiou-wc-product-cat-manage'); ?></label>
+                    </th>
+                    <td>
+                        <select id="upvp_attribute_value" name="upvp_attribute_value" class="regular-text" disabled>
+                            <option value=""><?php echo esc_html__('-- Select value --', 'studiou-wc-product-cat-manage'); ?></option>
+                        </select>
+                    </td>
+                </tr>
+                <tr>
+                    <th scope="row">
+                        <label for="upvp_new_price"><?php echo esc_html__('New Price', 'studiou-wc-product-cat-manage'); ?></label>
+                    </th>
+                    <td>
+                        <input type="number" step="0.01" min="0" id="upvp_new_price" name="upvp_new_price" class="regular-text">
+                    </td>
+                </tr>
+                <tr>
+                    <th scope="row">
+                        <?php echo esc_html__('Affected variations', 'studiou-wc-product-cat-manage'); ?>
+                    </th>
+                    <td>
+                        <strong id="studiou-wcpcm-upvp-affected-count">0</strong>
+                    </td>
+                </tr>
+            </table>
+
+            <div class="submit-buttons">
+                <button type="button" id="studiou-wcpcm-upvp-dry-run-button" class="button">
+                    <?php echo esc_html__('Dry Run', 'studiou-wc-product-cat-manage'); ?>
+                </button>
+                <button type="button" id="studiou-wcpcm-upvp-apply-button" class="button button-primary">
+                    <?php echo esc_html__('Apply', 'studiou-wc-product-cat-manage'); ?>
+                </button>
+                <span class="spinner"></span>
+            </div>
+        </form>
+
+        <div class="studiou-wcpcm-upvp-results" style="display: none;">
+            <h3><?php echo esc_html__('Results', 'studiou-wc-product-cat-manage'); ?></h3>
+            <div id="studiou-wcpcm-upvp-message"></div>
+        </div>
+    </div>
+
     <!-- Important Notes -->
     <div class="studiou-wcpcm-card">
         <h2><?php echo esc_html__('Important Notes', 'studiou-wc-product-cat-manage'); ?></h2>
@@ -313,6 +401,7 @@ $unassigned_media_count = $manipulator->get_unassigned_unused_media_count();
             <li><strong><?php echo esc_html__('The delete products operation will permanently delete all products and variants from the selected categories. This action cannot be undone!', 'studiou-wc-product-cat-manage'); ?></strong></li>
             <li><strong><?php echo esc_html__('The clear media categories operation will permanently delete all media files from the selected media categories and remove them from the server. This action cannot be undone!', 'studiou-wc-product-cat-manage'); ?></strong></li>
             <li><strong><?php echo esc_html__('The remove not assigned media operation will permanently delete all media files that are not categorized and not used anywhere. This action cannot be undone!', 'studiou-wc-product-cat-manage'); ?></strong></li>
+            <li><?php echo esc_html__('The update product variant prices operation only modifies the regular price of product variations. Order line items, cart contents, and historical order data are never modified.', 'studiou-wc-product-cat-manage'); ?></li>
         </ul>
     </div>
 </div>