Просмотр исходного кода

Add per-variation quantity discount tiers — v1.2.3 -> v1.3.4

Introduces Studiou_WC_FPP_Pricing: admin editor on each variation's
pricing panel, sanitised save via woocommerce_save_product_variation,
and runtime discount via woocommerce_before_calculate_totals (priority
20) using set_price() on the cart item's product instance. Tier meta
lives in _studiou_fpp_qty_tiers on each product_variation post.

Frontend is designed around the Product Variant Table for WooCommerce
(pvtfw) plugin, which is now a hard dependency declared via the
Requires Plugins header and enforced at runtime by an admin notice.
Tier data for all variations is emitted as window.studiouFppTierData
in wp_footer; the JS matches it to pvtfw rows by data-variant, appends
a compact tier strip under the price, and updates the row's price and
a green "save" badge live when qty changes. Native variations_form
handling kept as a fallback branch.

Also fixes a race in the chunked upload handler where the WC session
was populated by a follow-up AJAX call that could still be in flight
when the user clicked Add to Cart — session is now written server-side
in the same request that assembles the file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dalibor Votruba 3 месяцев назад
Родитель
Сommit
fe647c82b7

+ 7 - 2
studiou-wc-free-photo-product/CLAUDE.md

@@ -2,7 +2,7 @@
 
 
 ## Project Overview
 ## Project Overview
 
 
-WordPress/WooCommerce plugin called **studiou-wc-free-photo-product** (QDR - Studiou WC Free Photo Product) v1.2.3.
+WordPress/WooCommerce plugin called **studiou-wc-free-photo-product** (QDR - Studiou WC Free Photo Product) v1.3.4.
 Allows publishing special variable products where customers upload custom raw images to be printed for a price.
 Allows publishing special variable products where customers upload custom raw images to be printed for a price.
 
 
 ## Initial Requirements
 ## Initial Requirements
@@ -16,6 +16,7 @@ Allows publishing special variable products where customers upload custom raw im
 ## Tech Stack
 ## Tech Stack
 
 
 - PHP 8.2+, WordPress 6.9.4+, WooCommerce 10.0+
 - PHP 8.2+, WordPress 6.9.4+, WooCommerce 10.0+
+- Hard plugin dependency: **Product Variant Table for WooCommerce** (`pvtfw`, folder slug `product-variant-table-for-woocommerce`) — renders variants as a custom `<table class="variant">` with per-row qty inputs and its own `pvtfw_woocommerce_ajax_add_to_cart` handler. Declared via the `Requires Plugins:` header and enforced at runtime by `Studiou_WC_Free_Photo_Product::is_pvtfw_active()`.
 - jQuery for frontend/admin JS
 - jQuery for frontend/admin JS
 - WordPress AJAX (admin-ajax.php) for chunked upload
 - WordPress AJAX (admin-ajax.php) for chunked upload
 - WC session storage for passing file data between upload and add-to-cart (theme-agnostic)
 - WC session storage for passing file data between upload and add-to-cart (theme-agnostic)
@@ -40,9 +41,12 @@ Allows publishing special variable products where customers upload custom raw im
 - `Studiou_WC_FPP_Shortcode` - `[studiou_free_photo_product id="X"]` renders product with WC native variation form
 - `Studiou_WC_FPP_Shortcode` - `[studiou_free_photo_product id="X"]` renders product with WC native variation form
 - `Studiou_WC_FPP_Cart` - Reads file data from WC session via `woocommerce_add_cart_item_data`, replaces cart/order item thumbnails (classic cart via `woocommerce_cart_item_thumbnail`, block cart via `woocommerce_product_get_image_id`/`woocommerce_product_variation_get_image_id`), order item meta, order linking
 - `Studiou_WC_FPP_Cart` - Reads file data from WC session via `woocommerce_add_cart_item_data`, replaces cart/order item thumbnails (classic cart via `woocommerce_cart_item_thumbnail`, block cart via `woocommerce_product_get_image_id`/`woocommerce_product_variation_get_image_id`), order item meta, order linking
 - `Studiou_WC_FPP_Admin` - Summary page with filters/pagination, status management, ZIP download, file download with auto-status-change, bulk actions with confirmation dialogs
 - `Studiou_WC_FPP_Admin` - Summary page with filters/pagination, status management, ZIP download, file download with auto-status-change, bulk actions with confirmation dialogs
+- `Studiou_WC_FPP_Pricing` - Per-variation quantity discount tiers. Admin editor on `woocommerce_variation_options_pricing`, save via `woocommerce_save_product_variation`, runtime discount via `woocommerce_before_calculate_totals` (priority 20) using `set_price()` on the cart item's product instance. Tier meta stored as a PHP array in `_studiou_fpp_qty_tiers` on each `product_variation` post. Feature is gated to FPP-enabled parent products. Frontend: pre-selection notice on `woocommerce_single_product_summary@25`, inline tier-data JSON emitted via `wp_footer` as `window.studiouFppTierData`, consumed by `initVariantTableTiers()` in `assets/js/frontend.js` to wire up per-row qty→price updates on the pvtfw variant table. Native WC `variations_form` handling is kept as a fallback path.
 
 
 ## Frontend Flow
 ## Frontend Flow
 
 
+Product page is rendered by the **Product Variant Table for WooCommerce** (`pvtfw`) plugin, which is a hard dependency. Each variation appears as a `<tr>` in `<table class="variant">` with its own `input.qty`, `.woocommerce-Price-amount`, and `.pvtfw_variant_table_cart_btn[data-variant="{variation_id}"]`. There is no WC native `form.variations_form` on the page.
+
 1. Upload area rendered below variation table via `woocommerce_single_product_summary` (priority 35)
 1. Upload area rendered below variation table via `woocommerce_single_product_summary` (priority 35)
 2. On page load, existing session state restored (preview, gallery image)
 2. On page load, existing session state restored (preview, gallery image)
 3. Customer uploads photo via chunked AJAX
 3. Customer uploads photo via chunked AJAX
@@ -57,6 +61,7 @@ Allows publishing special variable products where customers upload custom raw im
 12. On checkout, file record linked to order via `woocommerce_checkout_order_processed` (classic), `woocommerce_store_api_checkout_order_processed` (block), `woocommerce_thankyou` (fallback)
 12. On checkout, file record linked to order via `woocommerce_checkout_order_processed` (classic), `woocommerce_store_api_checkout_order_processed` (block), `woocommerce_thankyou` (fallback)
 13. Session only cleared when user clicks Remove
 13. Session only cleared when user clicks Remove
 14. Admin photo files view: download marks "ready" -> "downloaded", bulk actions with confirmations, pagination preserves filters
 14. Admin photo files view: download marks "ready" -> "downloaded", bulk actions with confirmations, pagination preserves filters
+15. **Quantity discount UI**: the pricing class emits `window.studiouFppTierData` in `wp_footer`; `initVariantTableTiers()` finds each row by `data-variant`, appends a tier strip under the price cell, and binds `input.qty` / `.qty-count` clicks to recompute the displayed price. Server-side `woocommerce_before_calculate_totals` (priority 20) remains authoritative and calls `set_price()` on each matching cart item regardless of how it was added.
 
 
 ## Coding Conventions
 ## Coding Conventions
 
 
@@ -79,4 +84,4 @@ Allows publishing special variable products where customers upload custom raw im
 
 
 ## Version Bumping
 ## Version Bumping
 
 
-When changing JS or CSS files, always bump the plugin version in the main PHP file header AND the `STUDIOU_WCFPP_VERSION` constant to bust browser cache.
+When changing JS or CSS files, always bump the plugin version in the main PHP file header AND the `STUDIOU_WCFPP_VERSION` constant to bust browser cache. Also bump the version string in `languages/studiou-wc-free-photo-product.pot` and `languages/studiou-wc-free-photo-product-cs_CZ.po` header, and update the `v…` reference at the top of this CLAUDE.md.

+ 61 - 0
studiou-wc-free-photo-product/assets/css/admin.css

@@ -153,3 +153,64 @@
 .studiou-fpp-status-solved {
 .studiou-fpp-status-solved {
     color: #00a32a;
     color: #00a32a;
 }
 }
+
+/* Variation quantity discount tiers editor */
+.studiou-fpp-qty-tiers-field {
+    margin-top: 10px;
+}
+
+.studiou-fpp-qty-tiers-field > label {
+    display: block;
+    font-weight: 600;
+    margin-bottom: 6px;
+}
+
+.studiou-fpp-qty-tiers-table {
+    max-width: 420px;
+    border: 1px solid #ddd;
+    margin-bottom: 6px;
+}
+
+.studiou-fpp-qty-tiers-table thead th {
+    background: #f7f7f7;
+    font-weight: 600;
+    padding: 6px 8px;
+    text-align: left;
+    font-size: 12px;
+}
+
+.studiou-fpp-qty-tiers-table tbody td {
+    padding: 4px 6px;
+    border-top: 1px solid #eee;
+}
+
+.studiou-fpp-qty-tiers-table tbody tr:first-child td {
+    border-top: none;
+}
+
+.studiou-fpp-qty-tiers-table input[type="number"] {
+    width: 100%;
+    min-width: 0;
+    max-width: 110px;
+}
+
+.studiou-fpp-qty-col-from,
+.studiou-fpp-qty-col-pct {
+    width: 45%;
+}
+
+.studiou-fpp-qty-col-remove {
+    width: 10%;
+    text-align: center;
+}
+
+.studiou-fpp-qty-tier-remove {
+    min-height: 26px;
+    line-height: 1 !important;
+    padding: 0 8px !important;
+    color: #b32d2e !important;
+}
+
+.studiou-fpp-qty-tier-add {
+    margin-top: 4px !important;
+}

+ 126 - 0
studiou-wc-free-photo-product/assets/css/frontend.css

@@ -274,6 +274,132 @@
     font-style: italic;
     font-style: italic;
 }
 }
 
 
+/* Quantity discount pre-selection notice (above variations form) */
+.studiou-fpp-qty-tiers-notice {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 10px 14px;
+    margin: 0 0 15px;
+    background: #fff7e6;
+    border: 1px solid #f0c36d;
+    border-left: 4px solid #dba617;
+    border-radius: 4px;
+    color: #6a4b00;
+    font-size: 0.95em;
+}
+
+.studiou-fpp-qty-tiers-notice .dashicons {
+    color: #dba617;
+    font-size: 20px;
+    width: 20px;
+    height: 20px;
+}
+
+/* Quantity discount tier display */
+.studiou-fpp-qty-tiers-display {
+    margin: 15px 0;
+}
+
+.studiou-fpp-qty-tiers-box {
+    border: 2px solid #2271b1;
+    border-radius: 6px;
+    padding: 14px 18px;
+    background: #f0f7ff;
+}
+
+.studiou-fpp-qty-tiers-box h4 {
+    margin: 0 0 12px;
+    font-size: 1.05em;
+    font-weight: 600;
+    color: #2271b1;
+    display: flex;
+    align-items: center;
+    gap: 6px;
+}
+
+.studiou-fpp-qty-tiers-box h4 .dashicons {
+    font-size: 20px;
+    width: 20px;
+    height: 20px;
+}
+
+.studiou-fpp-qty-tiers-table-display {
+    width: 100%;
+    border-collapse: collapse;
+    font-size: 0.9em;
+}
+
+.studiou-fpp-qty-tiers-table-display th,
+.studiou-fpp-qty-tiers-table-display td {
+    padding: 6px 10px;
+    text-align: left;
+    border-bottom: 1px solid #eee;
+}
+
+.studiou-fpp-qty-tiers-table-display thead th {
+    font-weight: 600;
+    color: #666;
+    border-bottom: 2px solid #ddd;
+}
+
+.studiou-fpp-qty-tiers-table-display tbody tr:last-child td {
+    border-bottom: none;
+}
+
+.studiou-fpp-qty-tiers-table-display tbody tr.studiou-fpp-qty-tier-active {
+    background: #e8f4fd;
+    font-weight: 600;
+}
+
+.studiou-fpp-qty-tiers-table-display tbody tr.studiou-fpp-qty-tier-active td {
+    color: #2271b1;
+}
+
+/* Per-row tier display for custom variant-table plugins (pvtfw) */
+.studiou-fpp-row-tiers {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    margin-top: 6px;
+    font-size: 0.8em;
+    color: #555;
+    flex-wrap: wrap;
+}
+
+.studiou-fpp-row-tiers .dashicons {
+    font-size: 14px;
+    width: 14px;
+    height: 14px;
+    color: #dba617;
+}
+
+.studiou-fpp-row-tiers-list {
+    display: inline-flex;
+    gap: 6px;
+    flex-wrap: wrap;
+}
+
+.studiou-fpp-row-tier {
+    background: #fff7e6;
+    border: 1px solid #f0c36d;
+    border-radius: 3px;
+    padding: 1px 6px;
+    white-space: nowrap;
+}
+
+.studiou-fpp-row-save-badge {
+    display: inline-block;
+    margin-left: 6px;
+    padding: 2px 7px;
+    background: #00a32a;
+    color: #fff;
+    border-radius: 3px;
+    font-size: 0.75em;
+    font-weight: 700;
+    vertical-align: middle;
+}
+
 /* Responsive */
 /* Responsive */
 @media (max-width: 600px) {
 @media (max-width: 600px) {
     .studiou-fpp-product-header {
     .studiou-fpp-product-header {

+ 46 - 0
studiou-wc-free-photo-product/assets/js/admin.js

@@ -10,6 +10,11 @@
             initProductTab();
             initProductTab();
         }
         }
 
 
+        // ========================================
+        // Product edit page: Variation quantity discount tiers
+        // ========================================
+        initVariationQtyTiers();
+
         // ========================================
         // ========================================
         // Admin summary page
         // Admin summary page
         // ========================================
         // ========================================
@@ -17,6 +22,47 @@
             initSummaryPage();
             initSummaryPage();
         }
         }
 
 
+        function initVariationQtyTiers() {
+            // Add tier row
+            $(document).on('click', '.studiou-fpp-qty-tier-add', function (e) {
+                e.preventDefault();
+                var $btn = $(this);
+                var loop = $btn.data('loop');
+                var $tbody = $btn.closest('.studiou-fpp-qty-tiers-field').find('.studiou-fpp-qty-tiers-table tbody');
+                var idx = Date.now() + '_' + Math.floor(Math.random() * 10000);
+                var row =
+                    '<tr class="studiou-fpp-qty-tier-row">' +
+                        '<td><input type="number" min="1" step="1" name="studiou_fpp_qty_tiers[' + loop + '][' + idx + '][from_qty]" value="" /></td>' +
+                        '<td><input type="number" min="0" max="100" step="0.01" name="studiou_fpp_qty_tiers[' + loop + '][' + idx + '][percent]" value="" /></td>' +
+                        '<td><button type="button" class="button studiou-fpp-qty-tier-remove">&times;</button></td>' +
+                    '</tr>';
+                $tbody.append(row);
+                markVariationChanged($btn);
+            });
+
+            // Remove tier row
+            $(document).on('click', '.studiou-fpp-qty-tier-remove', function (e) {
+                e.preventDefault();
+                var $btn = $(this);
+                $btn.closest('tr').remove();
+                markVariationChanged($btn);
+            });
+
+            // Mark variation as modified on any input change inside the tier table
+            $(document).on('change input', '.studiou-fpp-qty-tiers-table input', function () {
+                markVariationChanged($(this));
+            });
+
+            function markVariationChanged($el) {
+                var $variation = $el.closest('.woocommerce_variation');
+                if ($variation.length) {
+                    $variation.addClass('variation-needs-update');
+                    $('button.cancel-variation-changes, button.save-variation-changes').removeAttr('disabled');
+                    $('#variable_product_options').trigger('woocommerce_variations_input_changed');
+                }
+            }
+        }
+
         function initProductTab() {
         function initProductTab() {
             // Show/hide new media category form
             // Show/hide new media category form
             $('.studiou-fpp-add-media-cat-btn').on('click', function () {
             $('.studiou-fpp-add-media-cat-btn').on('click', function () {

+ 290 - 0
studiou-wc-free-photo-product/assets/js/frontend.js

@@ -326,5 +326,295 @@
             div.appendChild(document.createTextNode(str));
             div.appendChild(document.createTextNode(str));
             return div.innerHTML;
             return div.innerHTML;
         }
         }
+
+        // ==========================================
+        // Quantity discount tier display + live price update
+        // ==========================================
+        initQtyTiers();
+        initVariantTableTiers();
+
+        function initVariantTableTiers() {
+            // Handles custom variant-table plugins (e.g. pvtfw / product-variant-table-for-woocommerce)
+            // where each variation is rendered as a <tr> with its own qty input and price.
+            var tierData = (typeof window.studiouFppTierData === 'object' && window.studiouFppTierData) ? window.studiouFppTierData : null;
+            if (!tierData) {
+                return;
+            }
+            var $table = $('table.variant').first();
+            if (!$table.length) {
+                return;
+            }
+
+            var i18n = studiouWcfppFront.i18n;
+
+            $table.find('tbody > tr').each(function () {
+                var $row = $(this);
+                var $cartBtn = $row.find('.pvtfw_variant_table_cart_btn').first();
+                var variantId = $cartBtn.length ? String($cartBtn.data('variant')) : '';
+                if (!variantId) {
+                    var $qtyInput = $row.find('input.qty').first();
+                    variantId = $qtyInput.attr('id') || '';
+                }
+                if (!variantId || !tierData[variantId]) {
+                    return;
+                }
+
+                var cfg = tierData[variantId];
+                var tiers = cfg.tiers || [];
+                var basePrice = parseFloat(cfg.base_price) || 0;
+                if (tiers.length === 0 || basePrice <= 0) {
+                    return;
+                }
+
+                var $priceCell = $row.find('td[data-title="Price"]').first();
+                if (!$priceCell.length) $priceCell = $row.find('.woocommerce-Price-amount').first().closest('td');
+                var $priceEl = $priceCell.find('.woocommerce-Price-amount').first();
+                var $qty = $row.find('input.qty').first();
+                if (!$priceEl.length || !$qty.length) {
+                    return;
+                }
+
+                // Append a compact tier summary beneath the price
+                var tierListHtml = '<div class="studiou-fpp-row-tiers" title="' + escHtml(i18n.qtyTiersTitle) + '">';
+                tierListHtml += '<span class="dashicons dashicons-tag"></span>';
+                tierListHtml += '<span class="studiou-fpp-row-tiers-list">';
+                for (var i = 0; i < tiers.length; i++) {
+                    var t = tiers[i];
+                    tierListHtml += '<span class="studiou-fpp-row-tier">' + t.from_qty + '+: −' + formatPercent(t.percent) + '</span>';
+                }
+                tierListHtml += '</span></div>';
+                $priceCell.append(tierListHtml);
+
+                // Store the original price HTML so we can revert when qty falls below the first tier
+                if (!$priceEl.data('studiou-fpp-base-html')) {
+                    $priceEl.data('studiou-fpp-base-html', $priceEl.html());
+                }
+
+                // Bind qty change handlers to update this row's price display
+                var handler = function () {
+                    updateRowPrice($row, tiers, basePrice, $priceEl, $qty);
+                };
+                $qty.on('input change keyup', handler);
+                // pvtfw ± buttons sit next to the input; they dispatch change events on the input,
+                // but some themes intercept them — bind click too
+                $row.find('.qty-count').on('click', function () {
+                    setTimeout(handler, 0);
+                });
+
+                // Initial update (qty=1 usually, just to install the "Save X%" indicator if applicable)
+                updateRowPrice($row, tiers, basePrice, $priceEl, $qty);
+            });
+        }
+
+        function updateRowPrice($row, tiers, basePrice, $priceEl, $qty) {
+            var qty = parseInt($qty.val(), 10) || 1;
+            var tier = resolveTierSimple(tiers, qty);
+            $row.find('.studiou-fpp-row-save-badge').remove();
+
+            if (!tier || tier.percent <= 0) {
+                // Revert to original HTML
+                var base = $priceEl.data('studiou-fpp-base-html');
+                if (base) {
+                    $priceEl.html(base);
+                }
+                return;
+            }
+
+            var unit = basePrice * (1 - tier.percent / 100);
+            $priceEl.html(formatPriceInner(unit));
+
+            var badge = '<span class="studiou-fpp-row-save-badge">−' + formatPercent(tier.percent) + '</span>';
+            $priceEl.after(badge);
+        }
+
+        function resolveTierSimple(tiers, qty) {
+            var match = null;
+            for (var i = 0; i < tiers.length; i++) {
+                if (tiers[i].from_qty <= qty) match = tiers[i];
+                else break;
+            }
+            return match;
+        }
+
+        function formatPercent(percent) {
+            var p = parseFloat(percent) || 0;
+            var str = p.toFixed(2).replace(/\.?0+$/, '');
+            return str + ' %';
+        }
+
+        function formatPriceInner(amount) {
+            var curr = studiouWcfppFront.currency || {};
+            var decimals = parseInt(curr.decimals, 10);
+            if (isNaN(decimals)) decimals = 2;
+            var decSep = curr.decimalSeparator || '.';
+            var thouSep = curr.thousandSeparator || ',';
+            var fmt = curr.priceFormat || '%1$s%2$s';
+            var symbol = curr.symbol || '';
+
+            var fixed = parseFloat(amount).toFixed(decimals);
+            var parts = fixed.split('.');
+            var intPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thouSep);
+            var decPart = parts.length > 1 ? parts[1] : '';
+            var numStr = decPart ? intPart + decSep + decPart : intPart;
+
+            var symbolHtml = '<span class="woocommerce-Price-currencySymbol">' + symbol + '</span>';
+            return fmt.replace('%1$s', symbolHtml).replace('%2$s', numStr);
+        }
+
+        function initQtyTiers() {
+            var $container = $('.studiou-fpp-qty-tiers-display');
+            var $form = $('form.variations_form').first();
+            if (!$container.length || !$form.length) {
+                return;
+            }
+
+            var currentTiers = [];
+            var currentBasePrice = 0;
+
+            $form.on('found_variation', function (event, variation) {
+                currentTiers = (variation && variation.studiou_fpp_qty_tiers) ? variation.studiou_fpp_qty_tiers : [];
+                currentBasePrice = (variation && variation.studiou_fpp_base_price) ? parseFloat(variation.studiou_fpp_base_price) : 0;
+                // WC re-renders .single_variation on every selection, so defer a tick to run after it
+                setTimeout(function () {
+                    renderTierTable();
+                    updateDisplayedPrice();
+                }, 0);
+            });
+
+            $form.on('reset_data', function () {
+                currentTiers = [];
+                currentBasePrice = 0;
+                $container.hide().empty();
+            });
+
+            // Live price update on quantity change — broad listener so themes with non-standard
+            // qty input placement still trigger the update.
+            $(document).on('input change keyup', 'input.qty, input[name="quantity"]', function () {
+                updateDisplayedPrice();
+            });
+
+            function renderTierTable() {
+                if (!currentTiers || currentTiers.length === 0) {
+                    $container.hide().empty();
+                    return;
+                }
+
+                var i18n = studiouWcfppFront.i18n;
+                var html = '<div class="studiou-fpp-qty-tiers-box">';
+                html += '<h4><span class="dashicons dashicons-tag"></span> ' + escHtml(i18n.qtyTiersTitle || 'Quantity Discount') + '</h4>';
+                html += '<table class="studiou-fpp-qty-tiers-table-display"><thead><tr>';
+                html += '<th>' + escHtml(i18n.qtyTiersFromQty || 'From Qty') + '</th>';
+                html += '<th>' + escHtml(i18n.qtyTiersDiscount || 'Discount') + '</th>';
+                html += '<th>' + escHtml(i18n.qtyTiersUnitPrice || 'Unit Price') + '</th>';
+                html += '</tr></thead><tbody>';
+
+                for (var i = 0; i < currentTiers.length; i++) {
+                    var t = currentTiers[i];
+                    var unit = computeDiscounted(currentBasePrice, t.percent);
+                    html += '<tr data-from-qty="' + t.from_qty + '">';
+                    html += '<td>' + t.from_qty + '+</td>';
+                    html += '<td>' + formatPercent(t.percent) + '</td>';
+                    html += '<td>' + formatPrice(unit) + '</td>';
+                    html += '</tr>';
+                }
+                html += '</tbody></table></div>';
+
+                $container.html(html).show();
+            }
+
+            function findPriceElement() {
+                var selectors = [
+                    '.single_variation .woocommerce-variation-price .woocommerce-Price-amount',
+                    'form.variations_form .woocommerce-variation-price .woocommerce-Price-amount',
+                    '.single_variation_wrap .woocommerce-Price-amount',
+                    'form.variations_form .price .woocommerce-Price-amount'
+                ];
+                for (var i = 0; i < selectors.length; i++) {
+                    var $el = $(selectors[i]).first();
+                    if ($el.length) return $el;
+                }
+                return $();
+            }
+
+            function findQtyInput() {
+                var $qty = $form.find('input.qty').first();
+                if (!$qty.length) $qty = $('input.qty').first();
+                if (!$qty.length) $qty = $('input[name="quantity"]').first();
+                return $qty;
+            }
+
+            function updateDisplayedPrice() {
+                if (!currentBasePrice || !currentTiers || currentTiers.length === 0) {
+                    highlightActiveTierRow(0);
+                    return;
+                }
+                var $qty = findQtyInput();
+                var qty = $qty.length ? (parseInt($qty.val(), 10) || 1) : 1;
+                var tier = resolveTier(currentTiers, qty);
+                var unit = tier ? computeDiscounted(currentBasePrice, tier.percent) : currentBasePrice;
+
+                var $wcPrice = findPriceElement();
+                if ($wcPrice.length) {
+                    $wcPrice.html(formatPriceInner(unit));
+                }
+
+                highlightActiveTierRow(tier ? tier.from_qty : 0);
+            }
+
+            function highlightActiveTierRow(fromQty) {
+                $container.find('tr').removeClass('studiou-fpp-qty-tier-active');
+                if (fromQty > 0) {
+                    $container.find('tr[data-from-qty="' + fromQty + '"]').addClass('studiou-fpp-qty-tier-active');
+                }
+            }
+
+            function resolveTier(tiers, qty) {
+                var match = null;
+                for (var i = 0; i < tiers.length; i++) {
+                    if (tiers[i].from_qty <= qty) {
+                        match = tiers[i];
+                    } else {
+                        break;
+                    }
+                }
+                return match;
+            }
+
+            function computeDiscounted(basePrice, percent) {
+                var p = parseFloat(percent) || 0;
+                if (p <= 0) return basePrice;
+                return basePrice * (1 - p / 100);
+            }
+
+            function formatPercent(percent) {
+                var p = parseFloat(percent) || 0;
+                // Strip trailing zeros
+                var str = p.toFixed(2).replace(/\.?0+$/, '');
+                return str + ' %';
+            }
+
+            function formatPrice(amount) {
+                return '<span class="woocommerce-Price-amount amount">' + formatPriceInner(amount) + '</span>';
+            }
+
+            function formatPriceInner(amount) {
+                var curr = studiouWcfppFront.currency || {};
+                var decimals = parseInt(curr.decimals, 10);
+                if (isNaN(decimals)) decimals = 2;
+                var decSep = curr.decimalSeparator || '.';
+                var thouSep = curr.thousandSeparator || ',';
+                var fmt = curr.priceFormat || '%1$s%2$s';
+                var symbol = curr.symbol || '';
+
+                var fixed = parseFloat(amount).toFixed(decimals);
+                var parts = fixed.split('.');
+                var intPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thouSep);
+                var decPart = parts.length > 1 ? parts[1] : '';
+                var numStr = decPart ? intPart + decSep + decPart : intPart;
+
+                var symbolHtml = '<span class="woocommerce-Price-currencySymbol">' + symbol + '</span>';
+                return fmt.replace('%1$s', symbolHtml).replace('%2$s', numStr);
+            }
+        }
     });
     });
 })(jQuery);
 })(jQuery);

+ 342 - 0
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-pricing.php

@@ -0,0 +1,342 @@
+<?php
+if (!defined('WPINC')) {
+    die;
+}
+
+class Studiou_WC_FPP_Pricing {
+
+    const META_KEY = '_studiou_fpp_qty_tiers';
+
+    public function __construct() {
+        // Admin: render tier editor inside each variation panel
+        add_action('woocommerce_variation_options_pricing', array($this, 'render_variation_tier_editor'), 20, 3);
+
+        // Admin: save tiers per variation
+        add_action('woocommerce_save_product_variation', array($this, 'save_variation_tiers'), 10, 2);
+
+        // Frontend: expose tiers on the variations data emitted for the variations form
+        add_filter('woocommerce_available_variation', array($this, 'inject_variation_tier_data'), 10, 3);
+
+        // Frontend: render placeholder container on the single product page
+        add_action('woocommerce_single_product_summary', array($this, 'render_tier_display_placeholder'), 34);
+
+        // Frontend: render pre-selection notice — uses summary hook so it works with any
+        // add-to-cart UI (WC native variations form, pvtfw/product-variant-table, etc.)
+        add_action('woocommerce_single_product_summary', array($this, 'render_preform_notice'), 25);
+
+        // Frontend: emit tier data as an inline JSON blob so JS can match rows of custom
+        // variant-table plugins to their tier configuration.
+        add_action('wp_footer', array($this, 'emit_tier_data_script'));
+
+        // Cart runtime: apply discounted unit price based on line quantity
+        add_action('woocommerce_before_calculate_totals', array($this, 'apply_cart_discount'), 20, 1);
+    }
+
+    // ============================================================
+    // Data access
+    // ============================================================
+
+    public static function get_tiers($variation_id) {
+        $raw = get_post_meta((int) $variation_id, self::META_KEY, true);
+        if (empty($raw) || !is_array($raw)) {
+            return array();
+        }
+        return self::normalize_tiers($raw);
+    }
+
+    private static function normalize_tiers(array $tiers) {
+        $out = array();
+        foreach ($tiers as $row) {
+            if (!is_array($row)) {
+                continue;
+            }
+            $from = isset($row['from_qty']) ? (int) $row['from_qty'] : 0;
+            if ($from < 1) {
+                continue;
+            }
+            $pct = isset($row['percent']) ? (float) $row['percent'] : 0.0;
+            $pct = max(0.0, min(100.0, $pct));
+            $out[$from] = array('from_qty' => $from, 'percent' => $pct);
+        }
+        ksort($out);
+        return array_values($out);
+    }
+
+    public static function resolve_tier(array $tiers, $qty) {
+        $qty = (int) $qty;
+        if ($qty < 1 || empty($tiers)) {
+            return null;
+        }
+        $match = null;
+        foreach ($tiers as $tier) {
+            if ($tier['from_qty'] <= $qty) {
+                $match = $tier;
+            } else {
+                break;
+            }
+        }
+        return $match;
+    }
+
+    public static function compute_discounted_price($base_price, array $tiers, $qty) {
+        $base = (float) $base_price;
+        if ($base <= 0.0 || empty($tiers)) {
+            return $base;
+        }
+        $tier = self::resolve_tier($tiers, $qty);
+        if (!$tier || $tier['percent'] <= 0.0) {
+            return $base;
+        }
+        $discounted = $base * (1.0 - ($tier['percent'] / 100.0));
+        return round($discounted, wc_get_price_decimals());
+    }
+
+    // ============================================================
+    // Admin UI: variation editor
+    // ============================================================
+
+    public function render_variation_tier_editor($loop, $variation_data, $variation) {
+        $parent_id = (int) $variation->post_parent;
+        if (!Studiou_WC_FPP_Product::is_fpp_product($parent_id)) {
+            return;
+        }
+
+        $tiers = self::get_tiers($variation->ID);
+        ?>
+        <div class="studiou-fpp-qty-tiers-field form-row form-row-full" data-loop="<?php echo esc_attr($loop); ?>">
+            <label>
+                <?php esc_html_e('Quantity Discount Tiers', 'studiou-wc-free-photo-product'); ?>
+                <?php echo wc_help_tip(__('Define quantity thresholds and percentage discounts. The tier with the largest "From Qty" that is less than or equal to the cart quantity is applied to the entire line. Leave empty to use the explicit variation price.', 'studiou-wc-free-photo-product')); ?>
+            </label>
+            <table class="studiou-fpp-qty-tiers-table widefat">
+                <thead>
+                    <tr>
+                        <th class="studiou-fpp-qty-col-from"><?php esc_html_e('From Qty', 'studiou-wc-free-photo-product'); ?></th>
+                        <th class="studiou-fpp-qty-col-pct"><?php esc_html_e('Discount %', 'studiou-wc-free-photo-product'); ?></th>
+                        <th class="studiou-fpp-qty-col-remove"></th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <?php foreach ($tiers as $idx => $tier) : ?>
+                        <tr class="studiou-fpp-qty-tier-row">
+                            <td>
+                                <input type="number" min="1" step="1"
+                                    name="studiou_fpp_qty_tiers[<?php echo esc_attr($loop); ?>][<?php echo esc_attr($idx); ?>][from_qty]"
+                                    value="<?php echo esc_attr($tier['from_qty']); ?>" />
+                            </td>
+                            <td>
+                                <input type="number" min="0" max="100" step="0.01"
+                                    name="studiou_fpp_qty_tiers[<?php echo esc_attr($loop); ?>][<?php echo esc_attr($idx); ?>][percent]"
+                                    value="<?php echo esc_attr($tier['percent']); ?>" />
+                            </td>
+                            <td>
+                                <button type="button" class="button studiou-fpp-qty-tier-remove"
+                                    title="<?php esc_attr_e('Remove row', 'studiou-wc-free-photo-product'); ?>">&times;</button>
+                            </td>
+                        </tr>
+                    <?php endforeach; ?>
+                </tbody>
+            </table>
+            <p>
+                <button type="button" class="button studiou-fpp-qty-tier-add" data-loop="<?php echo esc_attr($loop); ?>">
+                    <?php esc_html_e('+ Add tier', 'studiou-wc-free-photo-product'); ?>
+                </button>
+            </p>
+        </div>
+        <?php
+    }
+
+    public function save_variation_tiers($variation_id, $i) {
+        $parent_id = wp_get_post_parent_id($variation_id);
+        if (!Studiou_WC_FPP_Product::is_fpp_product($parent_id)) {
+            delete_post_meta($variation_id, self::META_KEY);
+            return;
+        }
+
+        $raw = array();
+        if (isset($_POST['studiou_fpp_qty_tiers'][$i]) && is_array($_POST['studiou_fpp_qty_tiers'][$i])) {
+            $raw = wp_unslash($_POST['studiou_fpp_qty_tiers'][$i]);
+        }
+
+        $sanitized = array();
+        foreach ($raw as $row) {
+            if (!is_array($row)) {
+                continue;
+            }
+            $from = isset($row['from_qty']) ? absint($row['from_qty']) : 0;
+            if ($from < 1) {
+                continue;
+            }
+            $pct = isset($row['percent']) ? (float) $row['percent'] : 0.0;
+            $pct = max(0.0, min(100.0, $pct));
+            $sanitized[] = array('from_qty' => $from, 'percent' => $pct);
+        }
+
+        $sanitized = self::normalize_tiers($sanitized);
+
+        if (empty($sanitized)) {
+            delete_post_meta($variation_id, self::META_KEY);
+        } else {
+            update_post_meta($variation_id, self::META_KEY, $sanitized);
+        }
+    }
+
+    // ============================================================
+    // Frontend: expose tiers on variation data + placeholder container
+    // ============================================================
+
+    public function inject_variation_tier_data($variation_data, $product, $variation) {
+        $parent_id = $variation->get_parent_id();
+        if (!Studiou_WC_FPP_Product::is_fpp_product($parent_id)) {
+            return $variation_data;
+        }
+
+        $tiers = self::get_tiers($variation->get_id());
+        $base_price = get_post_meta($variation->get_id(), '_price', true);
+
+        $variation_data['studiou_fpp_qty_tiers'] = $tiers;
+        $variation_data['studiou_fpp_base_price'] = ($base_price !== '' && $base_price !== false) ? (float) $base_price : 0.0;
+
+        return $variation_data;
+    }
+
+    public function render_tier_display_placeholder() {
+        global $product;
+        if (!$product || !Studiou_WC_FPP_Product::is_fpp_product($product->get_id())) {
+            return;
+        }
+        echo '<div class="studiou-fpp-qty-tiers-display" style="display:none;"></div>';
+    }
+
+    /**
+     * Render a persistent notice above the variations form summarising tier availability.
+     * Only shown if at least one variation of the current product has tiers configured.
+     */
+    public function render_preform_notice() {
+        global $product;
+        if (!$product || !Studiou_WC_FPP_Product::is_fpp_product($product->get_id())) {
+            return;
+        }
+        if (!$product->is_type('variable')) {
+            return;
+        }
+
+        $has_tiers = false;
+        $max_discount = 0.0;
+        foreach ($product->get_children() as $variation_id) {
+            $tiers = self::get_tiers($variation_id);
+            if (!empty($tiers)) {
+                $has_tiers = true;
+                foreach ($tiers as $tier) {
+                    if ($tier['percent'] > $max_discount) {
+                        $max_discount = $tier['percent'];
+                    }
+                }
+            }
+        }
+
+        if (!$has_tiers) {
+            return;
+        }
+
+        $label = ($max_discount > 0)
+            ? sprintf(
+                /* translators: %s: maximum discount percentage */
+                __('Quantity discount available — save up to %s%%. Select a variant to see the full tier table.', 'studiou-wc-free-photo-product'),
+                rtrim(rtrim(number_format($max_discount, 2, '.', ''), '0'), '.')
+            )
+            : __('Quantity discount available — select a variant to see the full tier table.', 'studiou-wc-free-photo-product');
+        ?>
+        <div class="studiou-fpp-qty-tiers-notice">
+            <span class="dashicons dashicons-tag"></span>
+            <span class="studiou-fpp-qty-tiers-notice-text"><?php echo esc_html($label); ?></span>
+        </div>
+        <?php
+    }
+
+    /**
+     * Emit tier data for all variations of the current FPP product as an inline JSON
+     * object keyed by variation ID. Consumed by frontend JS to wire up custom variant
+     * table plugins (pvtfw) where the native variations form is absent.
+     */
+    public function emit_tier_data_script() {
+        if (!is_product()) {
+            return;
+        }
+        global $product;
+        if (!$product || !is_a($product, 'WC_Product')) {
+            $product = wc_get_product(get_queried_object_id());
+        }
+        if (!$product || !Studiou_WC_FPP_Product::is_fpp_product($product->get_id())) {
+            return;
+        }
+        if (!$product->is_type('variable')) {
+            return;
+        }
+
+        $data = array();
+        foreach ($product->get_children() as $variation_id) {
+            $tiers = self::get_tiers($variation_id);
+            if (empty($tiers)) {
+                continue;
+            }
+            $base_price = get_post_meta($variation_id, '_price', true);
+            $data[(string) $variation_id] = array(
+                'tiers'      => $tiers,
+                'base_price' => ($base_price !== '' && $base_price !== false) ? (float) $base_price : 0.0,
+            );
+        }
+
+        if (empty($data)) {
+            return;
+        }
+
+        echo '<script>window.studiouFppTierData = ' . wp_json_encode($data) . ';</script>';
+    }
+
+    // ============================================================
+    // Cart runtime
+    // ============================================================
+
+    public function apply_cart_discount($cart) {
+        if (is_admin() && !defined('DOING_AJAX')) {
+            return;
+        }
+        if (!$cart || !is_a($cart, 'WC_Cart')) {
+            return;
+        }
+
+        foreach ($cart->get_cart() as $cart_item) {
+            if (empty($cart_item['variation_id'])) {
+                continue;
+            }
+            $product = isset($cart_item['data']) ? $cart_item['data'] : null;
+            if (!$product) {
+                continue;
+            }
+
+            $parent_id = $product->get_parent_id();
+            if (!Studiou_WC_FPP_Product::is_fpp_product($parent_id)) {
+                continue;
+            }
+
+            $variation_id = (int) $cart_item['variation_id'];
+            $tiers = self::get_tiers($variation_id);
+            if (empty($tiers)) {
+                continue;
+            }
+
+            // Read base price from meta to avoid compounding if the hook fires twice per request
+            $base_price = get_post_meta($variation_id, '_price', true);
+            if ($base_price === '' || $base_price === false) {
+                continue;
+            }
+
+            $qty = (int) $cart_item['quantity'];
+            $discounted = self::compute_discounted_price((float) $base_price, $tiers, $qty);
+            if ($discounted > 0.0) {
+                $product->set_price($discounted);
+            }
+        }
+    }
+}

+ 10 - 0
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-upload.php

@@ -113,6 +113,16 @@ class Studiou_WC_FPP_Upload {
                 return;
                 return;
             }
             }
 
 
+            // Store the upload reference in the WC session immediately, in the same request.
+            // This avoids a race with a follow-up "set_upload_session" AJAX call that could
+            // still be in flight when the user clicks Add to Cart.
+            if (function_exists('WC') && WC()->session) {
+                WC()->session->set('studiou_fpp_attachment_id', $result['attachment_id']);
+                WC()->session->set('studiou_fpp_file_record_id', $result['file_record_id']);
+                WC()->session->set('studiou_fpp_file_name', $result['file_name']);
+                WC()->session->set('studiou_fpp_thumb_url', $result['thumbnail_url']);
+            }
+
             wp_send_json_success(array(
             wp_send_json_success(array(
                 'complete'       => true,
                 'complete'       => true,
                 'attachment_id'  => $result['attachment_id'],
                 'attachment_id'  => $result['attachment_id'],

BIN
studiou-wc-free-photo-product/languages/studiou-wc-free-photo-product-cs_CZ.mo


+ 37 - 1
studiou-wc-free-photo-product/languages/studiou-wc-free-photo-product-cs_CZ.po

@@ -3,7 +3,7 @@
 # This file is distributed under the GPL v2 or later.
 # This file is distributed under the GPL v2 or later.
 msgid ""
 msgid ""
 msgstr ""
 msgstr ""
-"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.2.3\n"
+"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.3.4\n"
 "Report-Msgid-Bugs-To: https://www.quadarax.com\n"
 "Report-Msgid-Bugs-To: https://www.quadarax.com\n"
 "POT-Creation-Date: 2026-04-02 00:00+0000\n"
 "POT-Creation-Date: 2026-04-02 00:00+0000\n"
 "PO-Revision-Date: 2026-04-02 00:00+0000\n"
 "PO-Revision-Date: 2026-04-02 00:00+0000\n"
@@ -19,6 +19,9 @@ msgstr ""
 msgid "QDR - Studiou WC Free Photo Product requires WooCommerce to be installed and activated."
 msgid "QDR - Studiou WC Free Photo Product requires WooCommerce to be installed and activated."
 msgstr "QDR - Studiou WC Free Photo Product vyžaduje nainstalovaný a aktivovaný WooCommerce."
 msgstr "QDR - Studiou WC Free Photo Product vyžaduje nainstalovaný a aktivovaný WooCommerce."
 
 
+msgid "QDR - Studiou WC Free Photo Product requires the \"Product Variant Table for WooCommerce\" plugin to be installed and activated."
+msgstr "QDR - Studiou WC Free Photo Product vyžaduje nainstalovaný a aktivovaný plugin „Product Variant Table for WooCommerce“."
+
 msgid "Free Photo Files"
 msgid "Free Photo Files"
 msgstr "Soubory fotografií"
 msgstr "Soubory fotografií"
 
 
@@ -360,3 +363,36 @@ msgstr "Bezpečnostní kontrola selhala."
 
 
 msgid "Add New Category"
 msgid "Add New Category"
 msgstr "Přidat novou kategorii"
 msgstr "Přidat novou kategorii"
+
+msgid "Quantity Discount Tiers"
+msgstr "Množstevní slevy"
+
+msgid "Define quantity thresholds and percentage discounts. The tier with the largest \"From Qty\" that is less than or equal to the cart quantity is applied to the entire line. Leave empty to use the explicit variation price."
+msgstr "Definujte množstevní prahy a procentuální slevy. Uplatní se úroveň s největší hodnotou „Od množství“, která je menší nebo rovna počtu kusů v košíku, a to na celou položku. Ponechte prázdné pro použití výchozí ceny varianty."
+
+msgid "From Qty"
+msgstr "Od množství"
+
+msgid "Discount %"
+msgstr "Sleva %"
+
+msgid "Remove row"
+msgstr "Odstranit řádek"
+
+msgid "+ Add tier"
+msgstr "+ Přidat úroveň"
+
+msgid "Quantity Discount"
+msgstr "Množstevní sleva"
+
+msgid "Discount"
+msgstr "Sleva"
+
+msgid "Unit Price"
+msgstr "Cena za kus"
+
+msgid "Quantity discount available — save up to %s%%. Select a variant to see the full tier table."
+msgstr "Je dostupná množstevní sleva — ušetříte až %s %%. Vyberte variantu pro zobrazení celé tabulky."
+
+msgid "Quantity discount available — select a variant to see the full tier table."
+msgstr "Je dostupná množstevní sleva — vyberte variantu pro zobrazení celé tabulky."

+ 49 - 1
studiou-wc-free-photo-product/languages/studiou-wc-free-photo-product.pot

@@ -2,7 +2,7 @@
 # This file is distributed under the GPL v2 or later.
 # This file is distributed under the GPL v2 or later.
 msgid ""
 msgid ""
 msgstr ""
 msgstr ""
-"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.2.3\n"
+"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.3.4\n"
 "Report-Msgid-Bugs-To: https://www.quadarax.com\n"
 "Report-Msgid-Bugs-To: https://www.quadarax.com\n"
 "POT-Creation-Date: 2026-04-02 00:00+0000\n"
 "POT-Creation-Date: 2026-04-02 00:00+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
@@ -18,6 +18,10 @@ msgstr ""
 msgid "QDR - Studiou WC Free Photo Product requires WooCommerce to be installed and activated."
 msgid "QDR - Studiou WC Free Photo Product requires WooCommerce to be installed and activated."
 msgstr ""
 msgstr ""
 
 
+#: studiou-wc-free-photo-product.php
+msgid "QDR - Studiou WC Free Photo Product requires the \"Product Variant Table for WooCommerce\" plugin to be installed and activated."
+msgstr ""
+
 #: studiou-wc-free-photo-product.php
 #: studiou-wc-free-photo-product.php
 msgid "Free Photo Files"
 msgid "Free Photo Files"
 msgstr ""
 msgstr ""
@@ -472,3 +476,47 @@ msgstr ""
 #: includes/class-studiou-wc-fpp-admin.php
 #: includes/class-studiou-wc-fpp-admin.php
 msgid "Security check failed."
 msgid "Security check failed."
 msgstr ""
 msgstr ""
+
+#: includes/class-studiou-wc-fpp-pricing.php
+msgid "Quantity Discount Tiers"
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-pricing.php
+msgid "Define quantity thresholds and percentage discounts. The tier with the largest \"From Qty\" that is less than or equal to the cart quantity is applied to the entire line. Leave empty to use the explicit variation price."
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-pricing.php
+msgid "From Qty"
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-pricing.php
+msgid "Discount %"
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-pricing.php
+msgid "Remove row"
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-pricing.php
+msgid "+ Add tier"
+msgstr ""
+
+#: studiou-wc-free-photo-product.php
+msgid "Quantity Discount"
+msgstr ""
+
+#: studiou-wc-free-photo-product.php
+msgid "Discount"
+msgstr ""
+
+#: studiou-wc-free-photo-product.php
+msgid "Unit Price"
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-pricing.php
+msgid "Quantity discount available — save up to %s%%. Select a variant to see the full tier table."
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-pricing.php
+msgid "Quantity discount available — select a variant to see the full tier table."
+msgstr ""

+ 39 - 1
studiou-wc-free-photo-product/readme.md

@@ -8,6 +8,7 @@ WordPress (6.9.4+) / WooCommerce (10.6.2+) plugin that allows publishing special
 - WooCommerce 10.0+
 - WooCommerce 10.0+
 - PHP 8.2+
 - PHP 8.2+
 - PHP ZipArchive extension (for admin ZIP download)
 - PHP ZipArchive extension (for admin ZIP download)
+- **Product Variant Table for WooCommerce** plugin (folder slug: `product-variant-table-for-woocommerce`) — used to render variants as a table with per-row quantity inputs. Declared via the plugin header `Requires Plugins:` field and enforced at runtime by an admin notice if the plugin is missing or deactivated.
 
 
 ## Installation
 ## Installation
 
 
@@ -40,6 +41,32 @@ When a variable product has Free Photo enabled, the upload area automatically ap
 
 
 The uploaded file reference is stored in the WooCommerce session, so it works with any add-to-cart method (form POST, AJAX buttons, individual variation buttons). The session persists across add-to-cart actions, allowing the same photo to be used for multiple variants. The session is only cleared when the user explicitly removes the uploaded photo.
 The uploaded file reference is stored in the WooCommerce session, so it works with any add-to-cart method (form POST, AJAX buttons, individual variation buttons). The session persists across add-to-cart actions, allowing the same photo to be used for multiple variants. The session is only cleared when the user explicitly removes the uploaded photo.
 
 
+### Quantity Discount Tiers (per variation)
+
+Each variation can optionally define a **Quantity Discount Tiers** table in its admin edit panel. Each row is a pair:
+
+- **From Qty** — minimum quantity threshold (integer ≥ 1)
+- **Discount %** — percentage discount (0–100) applied to the entire cart line
+
+The tier with the largest `From Qty` that is still less than or equal to the line's quantity wins (flat step function). When no row matches (or no tiers are defined), the variation's explicit WooCommerce price is used unchanged.
+
+Rules:
+- Tiers are evaluated **per cart line** — each line uses its own quantity, no cross-line summing. This matches the typical case where each line has its own uploaded photo.
+- Tiers are stored as post meta `_studiou_fpp_qty_tiers` on the `product_variation` post (PHP-serialized array, one sorted list per variation).
+- The feature is gated to FPP-enabled parent products. Non-FPP variable products are not affected.
+- The discounted unit price flows automatically into the cart subtotal, checkout, order line item, emails, invoices, and refunds — no special-casing anywhere else.
+
+**Frontend display** — designed for the **Product Variant Table for WooCommerce** (`pvtfw`) plugin, which is a hard dependency of this plugin:
+
+- Tier data for all variations of the current product is emitted as an inline `<script>window.studiouFppTierData = {…}</script>` via `wp_footer`.
+- A persistent amber **"Quantity discount available — save up to X%"** notice is rendered above the variants table on any FPP product that has at least one tiered variation (hooked on `woocommerce_single_product_summary` priority 25).
+- For each row of the `<table class="variant">` rendered by pvtfw, the frontend JS:
+  - Appends a compact tier summary strip beneath the price cell (e.g. `5+: −5% | 10+: −10% | 50+: −20%`).
+  - Binds the row's `input.qty` and `.qty-count` ± buttons so that changing the quantity updates the row's `.woocommerce-Price-amount` with the discounted unit price.
+  - Shows a green `−X%` "save" badge next to the price when a tier is active, and reverts the price back to the original HTML when the quantity drops below the smallest tier.
+- The frontend JS also contains a fallback branch that listens for WC's native `found_variation` / `reset_data` events on `form.variations_form`, so the plugin still degrades to sensible behavior if pvtfw is temporarily unavailable.
+- Per-row price updates are **display-only**. The authoritative pricing happens server-side in `woocommerce_before_calculate_totals` (priority 20), which calls `set_price()` on the cart item's product instance. The pvtfw AJAX add-to-cart handler only forwards `product_id`, `variation_id`, and `quantity` — it does not send the displayed price — so there is no risk of client-side tampering.
+
 Add-to-cart is blocked server-side until a photo is uploaded:
 Add-to-cart is blocked server-side until a photo is uploaded:
 - **Validation**: `woocommerce_add_to_cart_validation` (priority 1) checks the WC session for uploaded file data and blocks with an error notice if missing.
 - **Validation**: `woocommerce_add_to_cart_validation` (priority 1) checks the WC session for uploaded file data and blocks with an error notice if missing.
 - **Safety net**: `woocommerce_add_to_cart` action removes any FPP cart item that was added without a photo (catches edge cases where validation was bypassed).
 - **Safety net**: `woocommerce_add_to_cart` action removes any FPP cart item that was added without a photo (catches edge cases where validation was bypassed).
@@ -173,7 +200,8 @@ studiou-wc-free-photo-product/
 │   ├── class-studiou-wc-fpp-shortcode.php   Shortcode renderer
 │   ├── class-studiou-wc-fpp-shortcode.php   Shortcode renderer
 │   ├── class-studiou-wc-fpp-single-product.php  Product page upload area
 │   ├── class-studiou-wc-fpp-single-product.php  Product page upload area
 │   ├── class-studiou-wc-fpp-cart.php        Cart/order integration (native WC flow)
 │   ├── class-studiou-wc-fpp-cart.php        Cart/order integration (native WC flow)
-│   └── class-studiou-wc-fpp-admin.php       Admin summary page
+│   ├── class-studiou-wc-fpp-admin.php       Admin summary page
+│   └── class-studiou-wc-fpp-pricing.php     Per-variation quantity discount tiers
 ├── views/
 ├── views/
 │   ├── frontend-product.php                 Shortcode template
 │   ├── frontend-product.php                 Shortcode template
 │   ├── single-product-upload.php            Upload area for product page
 │   ├── single-product-upload.php            Upload area for product page
@@ -225,6 +253,10 @@ studiou-wc-free-photo-product/
 | `woocommerce_product_data_tabs` | Filter | Adds Free Photo product tab |
 | `woocommerce_product_data_tabs` | Filter | Adds Free Photo product tab |
 | `woocommerce_product_data_panels` | Action | Renders Free Photo settings panel |
 | `woocommerce_product_data_panels` | Action | Renders Free Photo settings panel |
 | `woocommerce_process_product_meta` | Action | Saves Free Photo product meta |
 | `woocommerce_process_product_meta` | Action | Saves Free Photo product meta |
+| `woocommerce_variation_options_pricing` (priority 20) | Action | Renders the quantity discount tier editor on each variation panel |
+| `woocommerce_save_product_variation` | Action | Sanitizes and saves quantity discount tiers per variation |
+| `woocommerce_available_variation` | Filter | Injects tier data into the variations form JSON so the frontend JS can render the tier table and live price |
+| `woocommerce_before_calculate_totals` (priority 20) | Action | Applies the quantity discount to the cart item's product via `set_price()` |
 
 
 ### Product Meta Keys
 ### Product Meta Keys
 
 
@@ -238,6 +270,12 @@ studiou-wc-free-photo-product/
 | `_studiou_fpp_max_width` | Max image width in px (0 = no limit) |
 | `_studiou_fpp_max_width` | Max image width in px (0 = no limit) |
 | `_studiou_fpp_max_height` | Max image height in px (0 = no limit) |
 | `_studiou_fpp_max_height` | Max image height in px (0 = no limit) |
 
 
+### Variation Meta Keys
+
+| Key | Description |
+|---|---|
+| `_studiou_fpp_qty_tiers` | Array of `{from_qty, percent}` rows defining the quantity discount tier table for the variation (sorted ascending by `from_qty`) |
+
 ### Order Item Meta Keys
 ### Order Item Meta Keys
 
 
 | Key | Description |
 | Key | Description |

+ 50 - 2
studiou-wc-free-photo-product/studiou-wc-free-photo-product.php

@@ -3,9 +3,10 @@
  * Plugin Name: QDR - Studiou WC Free Photo Product
  * Plugin Name: QDR - Studiou WC Free Photo Product
  * Plugin URI: https://www.quadarax.com/plugins/studiou-wc-free-photo-product
  * Plugin URI: https://www.quadarax.com/plugins/studiou-wc-free-photo-product
  * Description: Allows publishing special WooCommerce products with variants and custom raw image upload for photo printing
  * Description: Allows publishing special WooCommerce products with variants and custom raw image upload for photo printing
- * Version: 1.2.3
+ * Version: 1.3.4
  * Requires at least: 6.9.4
  * Requires at least: 6.9.4
  * Requires PHP: 8.2
  * Requires PHP: 8.2
+ * Requires Plugins: woocommerce, product-variant-table-for-woocommerce
  * Author: Dalibor Votruba
  * Author: Dalibor Votruba
  * Author URI: https://www.quadarax.com
  * Author URI: https://www.quadarax.com
  * License: GPL v2 or later
  * License: GPL v2 or later
@@ -21,7 +22,7 @@ if (!defined('WPINC')) {
     die;
     die;
 }
 }
 
 
-if (!defined('STUDIOU_WCFPP_VERSION')) define('STUDIOU_WCFPP_VERSION', '1.2.3');
+if (!defined('STUDIOU_WCFPP_VERSION')) define('STUDIOU_WCFPP_VERSION', '1.3.4');
 if (!defined('STUDIOU_WCFPP_PLUGIN_DIR')) define('STUDIOU_WCFPP_PLUGIN_DIR', plugin_dir_path(__FILE__));
 if (!defined('STUDIOU_WCFPP_PLUGIN_DIR')) define('STUDIOU_WCFPP_PLUGIN_DIR', plugin_dir_path(__FILE__));
 if (!defined('STUDIOU_WCFPP_PLUGIN_URL')) define('STUDIOU_WCFPP_PLUGIN_URL', plugin_dir_url(__FILE__));
 if (!defined('STUDIOU_WCFPP_PLUGIN_URL')) define('STUDIOU_WCFPP_PLUGIN_URL', plugin_dir_url(__FILE__));
 if (!defined('STUDIOU_WCFPP_PLUGIN_BASENAME')) define('STUDIOU_WCFPP_PLUGIN_BASENAME', plugin_basename(__FILE__));
 if (!defined('STUDIOU_WCFPP_PLUGIN_BASENAME')) define('STUDIOU_WCFPP_PLUGIN_BASENAME', plugin_basename(__FILE__));
@@ -50,6 +51,9 @@ class Studiou_WC_Free_Photo_Product {
     /** @var Studiou_WC_FPP_Admin */
     /** @var Studiou_WC_FPP_Admin */
     private $admin;
     private $admin;
 
 
+    /** @var Studiou_WC_FPP_Pricing */
+    private $pricing;
+
     public function __construct() {
     public function __construct() {
         $this->load_dependencies();
         $this->load_dependencies();
         add_action('admin_init', array($this, 'check_required_plugins'));
         add_action('admin_init', array($this, 'check_required_plugins'));
@@ -68,6 +72,7 @@ class Studiou_WC_Free_Photo_Product {
         require_once STUDIOU_WCFPP_PLUGIN_DIR . 'includes/class-studiou-wc-fpp-single-product.php';
         require_once STUDIOU_WCFPP_PLUGIN_DIR . 'includes/class-studiou-wc-fpp-single-product.php';
         require_once STUDIOU_WCFPP_PLUGIN_DIR . 'includes/class-studiou-wc-fpp-cart.php';
         require_once STUDIOU_WCFPP_PLUGIN_DIR . 'includes/class-studiou-wc-fpp-cart.php';
         require_once STUDIOU_WCFPP_PLUGIN_DIR . 'includes/class-studiou-wc-fpp-admin.php';
         require_once STUDIOU_WCFPP_PLUGIN_DIR . 'includes/class-studiou-wc-fpp-admin.php';
+        require_once STUDIOU_WCFPP_PLUGIN_DIR . 'includes/class-studiou-wc-fpp-pricing.php';
     }
     }
 
 
     private function init_components() {
     private function init_components() {
@@ -78,6 +83,7 @@ class Studiou_WC_Free_Photo_Product {
         $this->single_product = new Studiou_WC_FPP_Single_Product();
         $this->single_product = new Studiou_WC_FPP_Single_Product();
         $this->cart = new Studiou_WC_FPP_Cart($this->db);
         $this->cart = new Studiou_WC_FPP_Cart($this->db);
         $this->admin = new Studiou_WC_FPP_Admin($this->db);
         $this->admin = new Studiou_WC_FPP_Admin($this->db);
+        $this->pricing = new Studiou_WC_FPP_Pricing();
     }
     }
 
 
     public function check_required_plugins() {
     public function check_required_plugins() {
@@ -88,6 +94,37 @@ class Studiou_WC_Free_Photo_Product {
                 echo '</p></div>';
                 echo '</p></div>';
             });
             });
         }
         }
+
+        if (!self::is_pvtfw_active()) {
+            add_action('admin_notices', function () {
+                echo '<div class="notice notice-error"><p>';
+                echo esc_html__('QDR - Studiou WC Free Photo Product requires the "Product Variant Table for WooCommerce" plugin to be installed and activated.', 'studiou-wc-free-photo-product');
+                echo '</p></div>';
+            });
+        }
+    }
+
+    /**
+     * Detects whether the Product Variant Table for WooCommerce (pvtfw) plugin is active,
+     * regardless of its main file name, by matching the active-plugins list on the folder slug.
+     */
+    public static function is_pvtfw_active() {
+        $slug = 'product-variant-table-for-woocommerce/';
+        $active = (array) get_option('active_plugins', array());
+        foreach ($active as $plugin) {
+            if (strpos($plugin, $slug) === 0) {
+                return true;
+            }
+        }
+        if (is_multisite()) {
+            $network = (array) get_site_option('active_sitewide_plugins', array());
+            foreach (array_keys($network) as $plugin) {
+                if (strpos($plugin, $slug) === 0) {
+                    return true;
+                }
+            }
+        }
+        return false;
     }
     }
 
 
     public function declare_hpos_compatibility() {
     public function declare_hpos_compatibility() {
@@ -199,6 +236,13 @@ class Studiou_WC_Free_Photo_Product {
             'nonce' => wp_create_nonce('studiou-wcfpp-front-nonce'),
             'nonce' => wp_create_nonce('studiou-wcfpp-front-nonce'),
             'chunkSize' => STUDIOU_WCFPP_CHUNK_SIZE,
             'chunkSize' => STUDIOU_WCFPP_CHUNK_SIZE,
             'cartUrl' => wc_get_cart_url(),
             'cartUrl' => wc_get_cart_url(),
+            'currency' => array(
+                'symbol'            => function_exists('get_woocommerce_currency_symbol') ? get_woocommerce_currency_symbol() : '',
+                'priceFormat'       => function_exists('get_woocommerce_price_format') ? get_woocommerce_price_format() : '%1$s%2$s',
+                'decimalSeparator'  => function_exists('wc_get_price_decimal_separator') ? wc_get_price_decimal_separator() : '.',
+                'thousandSeparator' => function_exists('wc_get_price_thousand_separator') ? wc_get_price_thousand_separator() : ',',
+                'decimals'          => function_exists('wc_get_price_decimals') ? wc_get_price_decimals() : 2,
+            ),
             'i18n' => array(
             'i18n' => array(
                 'uploading' => __('Uploading...', 'studiou-wc-free-photo-product'),
                 'uploading' => __('Uploading...', 'studiou-wc-free-photo-product'),
                 'uploadComplete' => __('Upload complete', 'studiou-wc-free-photo-product'),
                 'uploadComplete' => __('Upload complete', 'studiou-wc-free-photo-product'),
@@ -211,6 +255,10 @@ class Studiou_WC_Free_Photo_Product {
                 'addToCartError' => __('Could not add to cart. Please try again.', 'studiou-wc-free-photo-product'),
                 'addToCartError' => __('Could not add to cart. Please try again.', 'studiou-wc-free-photo-product'),
                 'dragDropText' => __('Drag & drop your file here or click to browse', 'studiou-wc-free-photo-product'),
                 'dragDropText' => __('Drag & drop your file here or click to browse', 'studiou-wc-free-photo-product'),
                 'removeFile' => __('Remove', 'studiou-wc-free-photo-product'),
                 'removeFile' => __('Remove', 'studiou-wc-free-photo-product'),
+                'qtyTiersTitle' => __('Quantity Discount', 'studiou-wc-free-photo-product'),
+                'qtyTiersFromQty' => __('From Qty', 'studiou-wc-free-photo-product'),
+                'qtyTiersDiscount' => __('Discount', 'studiou-wc-free-photo-product'),
+                'qtyTiersUnitPrice' => __('Unit Price', 'studiou-wc-free-photo-product'),
             ),
             ),
         ));
         ));
     }
     }