diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dd95b0..a568328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,96 @@ All notable changes to WooCommerce Tier and Package Prices will be documented in The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] - 2025-12-29 + +### Added - Variable Product Support + +**Major Feature**: Complete support for WooCommerce variable products with variation-level pricing + +- Variable products can now have tier and package pricing configured independently for each variation +- Admin UI: Each variation displays tier/package pricing fields in the variation edit panel +- Frontend: Pricing tables load dynamically via AJAX when customer selects a variation +- Cart: Variation-specific pricing correctly applied during checkout +- Quantity restrictions work per-variation (not just per-product) +- Catalog buttons: "View Options" appears for variable products with restricted variations + +### Changed + +- **Admin Templates**: Converted tier/package row templates from `
` to `` table structure for better layout +- **Admin UI**: Simple product pricing fields now use table layout for consistency with variations +- **Frontend Display**: Variable products show placeholder container; pricing appears on variation selection +- **Cart Logic**: All cart methods now use "effective ID" pattern (variation ID when present, product ID otherwise) +- **Template System**: Added `field_prefix` parameter support to admin templates for variation field naming + +### Technical Details + +#### Backend Changes + +- **class-wc-tpp-cart.php**: Added variation ID resolution throughout; updated all meta lookups to use effective ID +- **class-wc-tpp-frontend.php**: + - Updated `get_tier_price()` and `get_package_price()` to accept `variation_id` parameter + - Added AJAX endpoint `ajax_get_variation_pricing()` for fetching variation pricing data + - Updated `display_pricing_table()` to detect variable products and show placeholder + - Fixed `modify_catalog_add_to_cart_button()` to check variations for restrictions +- **class-wc-tpp-product-meta.php**: + - Added hooks: `woocommerce_variation_options_pricing`, `woocommerce_save_product_variation` + - New method: `add_variation_pricing_fields()` - renders pricing UI in variation panels + - New method: `save_variation_pricing_fields()` - saves variation-specific pricing data + - New methods: `render_variation_tier_row()`, `render_variation_package_row()` - variation rendering helpers + +#### Frontend Changes + +- **frontend.js**: + - Added variation selector integration listening to `found_variation` and `reset_data` events + - Implemented AJAX fetching of variation pricing when variation selected + - Dynamic quantity restriction handling per-variation + - Re-initialization of event handlers for dynamically loaded pricing tables +- **admin.js**: + - Separated simple product and variation handlers + - Variation-specific add/remove tier/package row management + - Context-aware template selection using variation loop index + +#### Template Changes + +- **tier-row.twig**: Added `field_prefix` variable for variation field naming; changed to `` structure +- **package-row.twig**: Added `field_prefix` variable for variation field naming; changed to `` structure + +#### Data Storage + +- Meta keys remain the same: `_wc_tpp_tiers`, `_wc_tpp_packages`, `_wc_tpp_restrict_to_packages` +- Simple products: Stored on product post meta +- Variations: Stored on variation post meta (independent per-variation) + +### Backward Compatibility + +- **100% backward compatible** - No breaking changes +- Simple products continue working exactly as before +- Existing tier/package data unaffected +- No database migrations required +- Templates remain compatible (field_prefix optional) + +### Migration Notes + +- Existing installations can upgrade seamlessly +- Variable products simply gain new functionality +- No action required for existing simple product configurations + +### Performance Considerations + +- AJAX requests only made when variation selected (not on page load) +- Pricing data fetched per-variation (not all variations at once) +- Nonce verification on all AJAX requests for security +- Template rendering server-side for SEO/performance + +### Testing Performed + +- Simple products: All existing functionality verified +- Variable products: Tier pricing, package pricing, restrictions tested per-variation +- Mixed carts: Simple + variable products working correctly +- WooCommerce Blocks: Cart block, mini-cart block, checkout block compatibility verified +- Admin UI: Add/remove rows working for both simple products and variations +- Quantity restrictions: Enforced correctly per-variation in cart and checkout + ## [1.1.22] - 2025-12-23 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 41a30d4..2f900a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # WooCommerce Tier and Package Prices - AI Context Document -**Last Updated:** 2025-12-23 -**Current Version:** 1.1.22 +**Last Updated:** 2025-12-29 +**Current Version:** 1.2.0 **Author:** Marco Graetsch **Project Status:** Production-ready WordPress plugin @@ -549,11 +549,12 @@ Based on v1.1.22 release experience, here's the complete workflow: The is a hierarchical list for upcoming features and can be considered as a Roadmap for the upcoming development. -#### Version 1.1.x +#### Version 1.1.x (Completed) 1. ~~Add translations for `de_CH`, `de_DE_informal`, `fr_CH`, `it_CH`~~ ✅ **COMPLETED in v1.1.21** 2. ~~The double-install bug is back again. A new version of the plugin is installed as new plugin instead of updating the previous version.~~ ✅ **DOCUMENTED in v1.1.22** - Added workaround to CHANGELOG. Root cause: No automatic update mechanism (requires WordPress.org repository or custom update server). 3. ~~Make the label fields in the backend for tierprices and package-prices twice as long as it is.~~ ✅ **COMPLETED in v1.1.22** +4. ~~Make the plugin work with variable products~~ ✅ **COMPLETED in v1.2.0** - Full variation-level pricing support with independent configuration per variation, AJAX-based frontend display, and complete WooCommerce Blocks compatibility. #### Version 1.2.x diff --git a/README.md b/README.md index 25ec75e..b2ce554 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ A: The price will automatically recalculate based on the new quantity. A: Yes, each product can have its own tier and package pricing configuration. **Q: Does this work with variable products?** -A: Currently, this plugin is designed for simple products. Variable product support may be added in future versions. +A: Yes! Since version 1.2.0, the plugin fully supports variable products. Each variation can have its own independent tier and package pricing configuration. ## Support @@ -183,9 +183,22 @@ This plugin is licensed under the GPL v2 or later. ## Changelog -### Version 1.1.20 - 2025-12-23 +### Version 1.2.0 - 2025-12-29 -**Current Release** - Latest stable version with full WooCommerce Blocks support +__Current Release__ - Variable Product Support + +- __New__: Full support for WooCommerce variable products with variation-level pricing +- __New__: Each variation can have independent tier and package pricing configuration +- __New__: AJAX-powered dynamic pricing table display when variations are selected +- __Changed__: Admin templates converted to table structure for better layout +- __Fixed__: Quantity restrictions now work correctly per-variation +- 100% backward compatible - no breaking changes + +See [CHANGELOG.md](CHANGELOG.md) for complete details. + +### Version 1.1.22 - 2025-12-23 + +- Increased width of label input fields in admin interface #### Fixed - **CRITICAL:** WooCommerce Blocks fatal error in mini-cart and cart blocks diff --git a/assets/js/admin.js b/assets/js/admin.js index ba7fab1..a4c3c82 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -6,18 +6,72 @@ 'use strict'; $(document).ready(function() { - let tierIndex = $('.wc-tpp-tier-row').length; - let packageIndex = $('.wc-tpp-package-row').length; + // Initialize indexes for simple products + let tierIndex = $('.wc-tpp-tier-pricing .wc-tpp-tier-row').length; + let packageIndex = $('.wc-tpp-package-pricing .wc-tpp-package-row').length; - // Add tier - $('.wc-tpp-add-tier').on('click', function(e) { + // ======================================== + // Simple Product Handlers + // ======================================== + + // Add tier (simple products) + $('.wc-tpp-tier-pricing .wc-tpp-add-tier').on('click', function(e) { e.preventDefault(); const template = $('#wc-tpp-tier-row-template').html(); const newRow = template.replace(/\{\{INDEX\}\}/g, tierIndex); - $('.wc-tpp-tiers-container').append(newRow); + $('.wc-tpp-tier-pricing .wc-tpp-tiers-container').append(newRow); tierIndex++; }); + // Add package (simple products) + $('.wc-tpp-package-pricing .wc-tpp-add-package').on('click', function(e) { + e.preventDefault(); + const template = $('#wc-tpp-package-row-template').html(); + const newRow = template.replace(/\{\{INDEX\}\}/g, packageIndex); + $('.wc-tpp-package-pricing .wc-tpp-packages-container').append(newRow); + packageIndex++; + }); + + // ======================================== + // Variable Product Variation Handlers + // ======================================== + + // Add tier (variations) + $(document).on('click', '.wc-tpp-variation-pricing .wc-tpp-add-tier', function(e) { + e.preventDefault(); + const $button = $(this); + const loop = $button.data('loop'); + const $container = $button.closest('.wc-tpp-variation-pricing'); + const $tbody = $container.find('.wc-tpp-variation-tiers .wc-tpp-tiers-container'); + const template = $('#wc-tpp-variation-tier-row-template-' + loop).html(); + + // Count existing rows to get next index + const currentIndex = $tbody.find('tr').length; + const newRow = template.replace(/\{\{INDEX\}\}/g, currentIndex); + + $tbody.append(newRow); + }); + + // Add package (variations) + $(document).on('click', '.wc-tpp-variation-pricing .wc-tpp-add-package', function(e) { + e.preventDefault(); + const $button = $(this); + const loop = $button.data('loop'); + const $container = $button.closest('.wc-tpp-variation-pricing'); + const $tbody = $container.find('.wc-tpp-variation-packages .wc-tpp-packages-container'); + const template = $('#wc-tpp-variation-package-row-template-' + loop).html(); + + // Count existing rows to get next index + const currentIndex = $tbody.find('tr').length; + const newRow = template.replace(/\{\{INDEX\}\}/g, currentIndex); + + $tbody.append(newRow); + }); + + // ======================================== + // Common Handlers (both simple and variations) + // ======================================== + // Remove tier $(document).on('click', '.wc-tpp-remove-tier', function(e) { e.preventDefault(); @@ -26,15 +80,6 @@ } }); - // Add package - $('.wc-tpp-add-package').on('click', function(e) { - e.preventDefault(); - const template = $('#wc-tpp-package-row-template').html(); - const newRow = template.replace(/\{\{INDEX\}\}/g, packageIndex); - $('.wc-tpp-packages-container').append(newRow); - packageIndex++; - }); - // Remove package $(document).on('click', '.wc-tpp-remove-package', function(e) { e.preventDefault(); @@ -43,7 +88,7 @@ } }); - // Validate inputs + // Validate quantity inputs $(document).on('input', 'input[name*="[min_qty]"], input[name*="[qty]"]', function() { const value = parseInt($(this).val()); if (value < 1) { @@ -51,6 +96,7 @@ } }); + // Validate price inputs $(document).on('input', 'input[name*="[price]"]', function() { const value = parseFloat($(this).val()); if (value < 0) { diff --git a/assets/js/frontend.js b/assets/js/frontend.js index 8e36491..6a8614a 100644 --- a/assets/js/frontend.js +++ b/assets/js/frontend.js @@ -244,6 +244,114 @@ if ($quantityInput.length > 0 && $addToCartButton.length > 0) { updateAddToCartButton(); } + + // ======================================== + // Variable Product Support + // ======================================== + + const $variationsForm = $('.variations_form'); + const $pricingTableContainer = $('.wc-tpp-pricing-table-container'); + + if ($variationsForm.length && $pricingTableContainer.length) { + // Handle variation selection + $variationsForm.on('found_variation', function(event, variation) { + if (!variation.variation_id) { + return; + } + + // Show loading state + $pricingTableContainer.html('
Loading pricing...
').show(); + + // Fetch variation pricing via AJAX + $.ajax({ + url: wcTppData.ajax_url, + type: 'POST', + data: { + action: 'wc_tpp_get_variation_pricing', + nonce: wcTppData.nonce, + variation_id: variation.variation_id + }, + success: function(response) { + if (response.success && response.data.has_pricing) { + // Display the pricing table HTML + $pricingTableContainer.html(response.data.html).show(); + + // Re-initialize event handlers for the new content + initializePricingHandlers(); + + // Handle quantity restrictions + if (response.data.restrict_to_packages) { + $('input.qty').hide().closest('.quantity').hide(); + $('').appendTo('head'); + } else { + $('input.qty').show().closest('.quantity').show(); + $('style:contains(".quantity { display: none")').remove(); + } + } else { + // No pricing for this variation + $pricingTableContainer.html('').hide(); + $('input.qty').show().closest('.quantity').show(); + $('style:contains(".quantity { display: none")').remove(); + } + }, + error: function() { + $pricingTableContainer.html('').hide(); + } + }); + }); + + // Handle variation reset + $variationsForm.on('reset_data', function() { + $pricingTableContainer.html('').hide(); + $('input.qty').show().closest('.quantity').show(); + $('style:contains(".quantity { display: none")').remove(); + }); + + // Initialize pricing handlers for dynamically loaded content + function initializePricingHandlers() { + // Re-attach package selection handlers + $('.wc-tpp-select-package').off('click').on('click', function(e) { + e.preventDefault(); + const $package = $(this).closest('.wc-tpp-package'); + const qty = parseInt($package.data('qty')); + const $qtyInput = $('input.qty'); + + if ($qtyInput.length === 0 || $qtyInput.is(':hidden')) { + // Create hidden input for restricted products + if ($('.qty-hidden-input').length === 0) { + $('.single_add_to_cart_button').before(''); + } + $('.qty-hidden-input').val(qty); + } else { + $qtyInput.val(qty).trigger('change'); + } + + // Highlight selected package + $('.wc-tpp-package').removeClass('wc-tpp-selected'); + $package.addClass('wc-tpp-selected'); + + // Scroll to add to cart button + $('html, body').animate({ + scrollTop: $('.single_add_to_cart_button').offset().top - 100 + }, 500); + }); + + // Re-attach tier row click handlers + $('.wc-tpp-tier-pricing-table tbody tr').off('click').on('click', function() { + const minQty = parseInt($(this).data('min-qty')); + const $qtyInput = $('input.qty'); + + if ($qtyInput.length > 0 && $qtyInput.is(':visible')) { + $qtyInput.val(minQty).trigger('change'); + + // Scroll to quantity input + $('html, body').animate({ + scrollTop: $qtyInput.offset().top - 100 + }, 300); + } + }); + } + } }); })(jQuery); diff --git a/composer.json b/composer.json index ace9eb6..7ec7bf1 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "magdev/wc-tier-package-prices", "description": "WooCommerce plugin for tier pricing and package prices with Twig templates", - "version": "1.1.22", + "version": "1.2.0", "type": "wordpress-plugin", "license": "GPL-2.0-or-later", "authors": [ diff --git a/includes/class-wc-tpp-cart.php b/includes/class-wc-tpp-cart.php index 96487fc..2ca95fc 100644 --- a/includes/class-wc-tpp-cart.php +++ b/includes/class-wc-tpp-cart.php @@ -40,6 +40,8 @@ if (!class_exists('WC_TPP_Cart')) { foreach ($cart->get_cart() as $cart_item_key => $cart_item) { $product_id = $cart_item['product_id']; + $variation_id = isset($cart_item['variation_id']) ? absint($cart_item['variation_id']) : 0; + $effective_id = $variation_id > 0 ? $variation_id : $product_id; $quantity = $cart_item['quantity']; $product = $cart_item['data']; @@ -48,10 +50,10 @@ if (!class_exists('WC_TPP_Cart')) { continue; } - // Check for exact package match first + // Check for exact package match first (use effective ID for variations) $package_price = null; if (get_option('wc_tpp_enable_package_pricing') === 'yes') { - $package_price = WC_TPP_Frontend::get_package_price($product_id, $quantity); + $package_price = WC_TPP_Frontend::get_package_price($effective_id, $quantity, $variation_id); } if ($package_price !== null) { @@ -62,9 +64,9 @@ if (!class_exists('WC_TPP_Cart')) { WC()->cart->cart_contents[$cart_item_key]['wc_tpp_pricing_type'] = 'package'; WC()->cart->cart_contents[$cart_item_key]['wc_tpp_total_price'] = $package_price; } else { - // Apply tier pricing if no package match + // Apply tier pricing if no package match (use effective ID for variations) if (get_option('wc_tpp_enable_tier_pricing') === 'yes') { - $tier_price = WC_TPP_Frontend::get_tier_price($product_id, $quantity); + $tier_price = WC_TPP_Frontend::get_tier_price($effective_id, $quantity, $variation_id); if ($tier_price !== null) { $product->set_price($tier_price); // Store pricing information in cart item for display @@ -99,16 +101,20 @@ if (!class_exists('WC_TPP_Cart')) { } public function validate_package_quantity($passed, $product_id, $quantity) { - // Check if restriction is enabled globally or for this product + // Check for variation ID in request (for variable products) + $variation_id = isset($_REQUEST['variation_id']) ? absint($_REQUEST['variation_id']) : 0; + $effective_id = $variation_id > 0 ? $variation_id : $product_id; + + // Check if restriction is enabled globally or for this product/variation $global_restrict = get_option('wc_tpp_restrict_package_quantities', 'no') === 'yes'; - $product_restrict = get_post_meta($product_id, '_wc_tpp_restrict_to_packages', true) === 'yes'; + $product_restrict = get_post_meta($effective_id, '_wc_tpp_restrict_to_packages', true) === 'yes'; if (!$global_restrict && !$product_restrict) { return $passed; } - // Get packages for this product - $packages = get_post_meta($product_id, '_wc_tpp_packages', true); + // Get packages for this product/variation + $packages = get_post_meta($effective_id, '_wc_tpp_packages', true); if (empty($packages) || !is_array($packages)) { return $passed; @@ -147,18 +153,20 @@ if (!class_exists('WC_TPP_Cart')) { public function maybe_hide_cart_quantity_input($product_quantity, $cart_item_key, $cart_item) { $product_id = $cart_item['product_id']; + $variation_id = isset($cart_item['variation_id']) ? absint($cart_item['variation_id']) : 0; + $effective_id = $variation_id > 0 ? $variation_id : $product_id; - // Check if restriction is enabled globally or for this product + // Check if restriction is enabled globally or for this product/variation $global_restrict = get_option('wc_tpp_restrict_package_quantities', 'no') === 'yes'; - $product_restrict = get_post_meta($product_id, '_wc_tpp_restrict_to_packages', true) === 'yes'; + $product_restrict = get_post_meta($effective_id, '_wc_tpp_restrict_to_packages', true) === 'yes'; - // Get packages for this product - $packages = get_post_meta($product_id, '_wc_tpp_packages', true); + // Get packages for this product/variation + $packages = get_post_meta($effective_id, '_wc_tpp_packages', true); // If restriction is enabled and packages exist, show quantity as text only if (($global_restrict || $product_restrict) && !empty($packages)) { return sprintf('%s', - $product_id, + $effective_id, $cart_item['quantity'] ); } @@ -168,18 +176,20 @@ if (!class_exists('WC_TPP_Cart')) { public function maybe_hide_mini_cart_quantity_input($product_quantity, $cart_item, $cart_item_key) { $product_id = $cart_item['product_id']; + $variation_id = isset($cart_item['variation_id']) ? absint($cart_item['variation_id']) : 0; + $effective_id = $variation_id > 0 ? $variation_id : $product_id; - // Check if restriction is enabled globally or for this product + // Check if restriction is enabled globally or for this product/variation $global_restrict = get_option('wc_tpp_restrict_package_quantities', 'no') === 'yes'; - $product_restrict = get_post_meta($product_id, '_wc_tpp_restrict_to_packages', true) === 'yes'; + $product_restrict = get_post_meta($effective_id, '_wc_tpp_restrict_to_packages', true) === 'yes'; - // Get packages for this product - $packages = get_post_meta($product_id, '_wc_tpp_packages', true); + // Get packages for this product/variation + $packages = get_post_meta($effective_id, '_wc_tpp_packages', true); // If restriction is enabled and packages exist, show quantity as text only if (($global_restrict || $product_restrict) && !empty($packages)) { return sprintf('%s ×', - $product_id, + $effective_id, $cart_item['quantity'] ); } @@ -196,12 +206,15 @@ if (!class_exists('WC_TPP_Cart')) { $restricted_products = array(); foreach (WC()->cart->get_cart() as $cart_item) { $product_id = $cart_item['product_id']; + $variation_id = isset($cart_item['variation_id']) ? absint($cart_item['variation_id']) : 0; + $effective_id = $variation_id > 0 ? $variation_id : $product_id; + $global_restrict = get_option('wc_tpp_restrict_package_quantities', 'no') === 'yes'; - $product_restrict = get_post_meta($product_id, '_wc_tpp_restrict_to_packages', true) === 'yes'; - $packages = get_post_meta($product_id, '_wc_tpp_packages', true); + $product_restrict = get_post_meta($effective_id, '_wc_tpp_restrict_to_packages', true) === 'yes'; + $packages = get_post_meta($effective_id, '_wc_tpp_packages', true); if (($global_restrict || $product_restrict) && !empty($packages)) { - $restricted_products[] = $product_id; + $restricted_products[] = $effective_id; } } @@ -226,7 +239,7 @@ if (!class_exists('WC_TPP_Cart')) { * Make quantity non-editable for restricted products in WooCommerce blocks * * @param bool $editable Whether the quantity is editable - * @param WC_Product $product Product object + * @param WC_Product $product Product object (can be variation) * @return bool */ public function block_quantity_editable($editable, $product) { @@ -241,9 +254,12 @@ if (!class_exists('WC_TPP_Cart')) { return $editable; } + // For variations, use the variation ID directly (get_id() returns variation ID for WC_Product_Variation) + $effective_id = $product_id; + $global_restrict = get_option('wc_tpp_restrict_package_quantities', 'no') === 'yes'; - $product_restrict = get_post_meta($product_id, '_wc_tpp_restrict_to_packages', true) === 'yes'; - $packages = get_post_meta($product_id, '_wc_tpp_packages', true); + $product_restrict = get_post_meta($effective_id, '_wc_tpp_restrict_to_packages', true) === 'yes'; + $packages = get_post_meta($effective_id, '_wc_tpp_packages', true); // If restriction is enabled and packages exist, make quantity non-editable if (($global_restrict || $product_restrict) && !empty($packages)) { diff --git a/includes/class-wc-tpp-frontend.php b/includes/class-wc-tpp-frontend.php index ec22a99..5dc6cad 100644 --- a/includes/class-wc-tpp-frontend.php +++ b/includes/class-wc-tpp-frontend.php @@ -19,6 +19,10 @@ if (!class_exists('WC_TPP_Frontend')) { // Modify catalog add to cart button for restricted products add_filter('woocommerce_loop_add_to_cart_link', array($this, 'modify_catalog_add_to_cart_button'), 10, 2); + + // AJAX endpoints for variation pricing + add_action('wp_ajax_wc_tpp_get_variation_pricing', array($this, 'ajax_get_variation_pricing')); + add_action('wp_ajax_nopriv_wc_tpp_get_variation_pricing', array($this, 'ajax_get_variation_pricing')); } public function enqueue_scripts() { @@ -31,8 +35,10 @@ if (!class_exists('WC_TPP_Frontend')) { if (is_product()) { wp_enqueue_script('wc-tpp-frontend', WC_TPP_PLUGIN_URL . 'assets/js/frontend.js', array('jquery'), WC_TPP_VERSION, true); - // Localize script with currency settings + // Localize script with currency settings and AJAX data wp_localize_script('wc-tpp-frontend', 'wcTppData', array( + 'ajax_url' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('wc_tpp_variation_pricing'), 'currency_symbol' => esc_js(get_woocommerce_currency_symbol()), 'currency_position' => esc_js(get_option('woocommerce_currency_pos', 'left')), 'price_decimals' => absint(wc_get_price_decimals()), @@ -67,6 +73,11 @@ if (!class_exists('WC_TPP_Frontend')) { return; } + // For variable products, quantity hiding is handled per-variation via JS + if ($product->is_type('variable')) { + return; + } + $product_id = $product->get_id(); $global_restrict = get_option('wc_tpp_restrict_package_quantities', 'no') === 'yes'; $product_restrict = get_post_meta($product_id, '_wc_tpp_restrict_to_packages', true) === 'yes'; @@ -85,6 +96,13 @@ if (!class_exists('WC_TPP_Frontend')) { return; } + // For variable products, show a placeholder that will be populated by JS when variation is selected + if ($product->is_type('variable')) { + echo ''; + return; + } + + // For simple products, display pricing table directly $product_id = $product->get_id(); $tiers = get_post_meta($product_id, '_wc_tpp_tiers', true); $packages = get_post_meta($product_id, '_wc_tpp_packages', true); @@ -103,8 +121,17 @@ if (!class_exists('WC_TPP_Frontend')) { )); } - public static function get_tier_price($product_id, $quantity) { - $tiers = get_post_meta($product_id, '_wc_tpp_tiers', true); + /** + * Get tier price for a product or variation + * + * @param int $product_id Product ID (parent for simple, parent for variable) + * @param int $quantity Quantity + * @param int $variation_id Variation ID (0 for simple products) + * @return float|null Tier price or null if not applicable + */ + public static function get_tier_price($product_id, $quantity, $variation_id = 0) { + $effective_id = $variation_id > 0 ? $variation_id : $product_id; + $tiers = get_post_meta($effective_id, '_wc_tpp_tiers', true); if (empty($tiers) || !is_array($tiers)) { return null; @@ -120,8 +147,17 @@ if (!class_exists('WC_TPP_Frontend')) { return $applicable_price; } - public static function get_package_price($product_id, $quantity) { - $packages = get_post_meta($product_id, '_wc_tpp_packages', true); + /** + * Get package price for a product or variation + * + * @param int $product_id Product ID (parent for simple, parent for variable) + * @param int $quantity Quantity + * @param int $variation_id Variation ID (0 for simple products) + * @return float|null Package price or null if not applicable + */ + public static function get_package_price($product_id, $quantity, $variation_id = 0) { + $effective_id = $variation_id > 0 ? $variation_id : $product_id; + $packages = get_post_meta($effective_id, '_wc_tpp_packages', true); if (empty($packages) || !is_array($packages)) { return null; @@ -164,8 +200,24 @@ if (!class_exists('WC_TPP_Frontend')) { $product_id = $product->get_id(); - // Check if product has quantity restrictions - if (!self::has_quantity_restriction($product_id)) { + // For variable products, check if ANY variation has restrictions + // For simple products, check the product itself + $has_restriction = false; + + if ($product->is_type('variable')) { + // Check if any variation has package restrictions + $variations = $product->get_available_variations(); + foreach ($variations as $variation_data) { + if (self::has_quantity_restriction($variation_data['variation_id'])) { + $has_restriction = true; + break; + } + } + } else { + $has_restriction = self::has_quantity_restriction($product_id); + } + + if (!$has_restriction) { return $html; } @@ -173,15 +225,72 @@ if (!class_exists('WC_TPP_Frontend')) { $product_url = esc_url($product->get_permalink()); $button_text = esc_html__('View Options', 'wc-tier-package-prices'); + // Use correct product type class + $product_type_class = $product->is_type('variable') ? 'product_type_variable' : 'product_type_simple'; + $new_html = sprintf( - '%s', + '%s', $product_url, + esc_attr($product_type_class), esc_attr(sprintf(__('View options for %s', 'wc-tier-package-prices'), $product->get_name())), $button_text ); return $new_html; } + + /** + * AJAX handler to get variation pricing data + */ + public function ajax_get_variation_pricing() { + // Verify nonce + check_ajax_referer('wc_tpp_variation_pricing', 'nonce'); + + $variation_id = isset($_POST['variation_id']) ? absint($_POST['variation_id']) : 0; + + if (!$variation_id) { + wp_send_json_error(array('message' => __('Invalid variation ID', 'wc-tier-package-prices'))); + } + + // Get variation data + $variation = wc_get_product($variation_id); + + if (!$variation || !$variation->is_type('variation')) { + wp_send_json_error(array('message' => __('Variation not found', 'wc-tier-package-prices'))); + } + + // Get tier and package pricing + $tiers = get_post_meta($variation_id, '_wc_tpp_tiers', true); + $packages = get_post_meta($variation_id, '_wc_tpp_packages', true); + $global_restrict = get_option('wc_tpp_restrict_package_quantities', 'no') === 'yes'; + $product_restrict = get_post_meta($variation_id, '_wc_tpp_restrict_to_packages', true) === 'yes'; + + if (empty($tiers) && empty($packages)) { + // No pricing data for this variation + wp_send_json_success(array( + 'has_pricing' => false, + 'html' => '' + )); + } + + // Render the pricing table HTML + ob_start(); + WC_TPP_Template_Loader::get_instance()->display('frontend/pricing-table.twig', array( + 'product' => $variation, + 'tiers' => $tiers, + 'packages' => $packages, + 'restrict_to_packages' => $global_restrict || $product_restrict + )); + $html = ob_get_clean(); + + wp_send_json_success(array( + 'has_pricing' => true, + 'html' => $html, + 'tiers' => $tiers ? $tiers : array(), + 'packages' => $packages ? $packages : array(), + 'restrict_to_packages' => $global_restrict || $product_restrict + )); + } } new WC_TPP_Frontend(); diff --git a/includes/class-wc-tpp-product-meta.php b/includes/class-wc-tpp-product-meta.php index eede0a8..11ea6a4 100644 --- a/includes/class-wc-tpp-product-meta.php +++ b/includes/class-wc-tpp-product-meta.php @@ -11,9 +11,14 @@ if (!class_exists('WC_TPP_Product_Meta')) { class WC_TPP_Product_Meta { public function __construct() { + // Simple product hooks add_action('woocommerce_product_options_pricing', array($this, 'add_tier_pricing_fields')); add_action('woocommerce_product_options_pricing', array($this, 'add_package_pricing_fields')); add_action('woocommerce_process_product_meta', array($this, 'save_tier_package_fields')); + + // Variable product variation hooks + add_action('woocommerce_variation_options_pricing', array($this, 'add_variation_pricing_fields'), 10, 3); + add_action('woocommerce_save_product_variation', array($this, 'save_variation_pricing_fields'), 10, 2); } public function add_tier_pricing_fields() { @@ -25,18 +30,28 @@ if (!class_exists('WC_TPP_Product_Meta')) {

-
- ID, '_wc_tpp_tiers', true); - if (!is_array($tiers)) { - $tiers = array(); - } + + + + + + + + + + + ID, '_wc_tpp_tiers', true); + if (!is_array($tiers)) { + $tiers = array(); + } - foreach ($tiers as $index => $tier) { - $this->render_tier_row($index, $tier); - } - ?> - + foreach ($tiers as $index => $tier) { + $this->render_tier_row($index, $tier); + } + ?> + +

@@ -54,18 +69,28 @@ if (!class_exists('WC_TPP_Product_Meta')) {

-
- ID, '_wc_tpp_packages', true); - if (!is_array($packages)) { - $packages = array(); - } + + + + + + + + + + + ID, '_wc_tpp_packages', true); + if (!is_array($packages)) { + $packages = array(); + } - foreach ($packages as $index => $package) { - $this->render_package_row($index, $package); - } - ?> - + foreach ($packages as $index => $package) { + $this->render_package_row($index, $package); + } + ?> + +

@@ -105,6 +130,135 @@ if (!class_exists('WC_TPP_Product_Meta')) { )); } + /** + * Add tier and package pricing fields to product variations + * + * @param int $loop Position in the loop + * @param array $variation_data Variation data + * @param WP_Post $variation Variation post object + */ + public function add_variation_pricing_fields($loop, $variation_data, $variation) { + $variation_id = $variation->ID; + + // Retrieve variation-specific data + $tiers = get_post_meta($variation_id, '_wc_tpp_tiers', true); + $packages = get_post_meta($variation_id, '_wc_tpp_packages', true); + $restrict = get_post_meta($variation_id, '_wc_tpp_restrict_to_packages', true); + + if (!is_array($tiers)) { + $tiers = array(); + } + if (!is_array($packages)) { + $packages = array(); + } + + ?> +

+

+ + +
+

+ + + + + + + + + + + $tier) : ?> + render_variation_tier_row($loop, $index, $tier); ?> + + +
+ +
+ + +
+

+ + + + + + + + + + + $package) : ?> + render_variation_package_row($loop, $index, $package); ?> + + +
+ +
+ + +
+ 'wc_tpp_restrict_to_packages_' . $loop, + 'name' => 'wc_tpp_restrict_to_packages[' . $loop . ']', + 'label' => __('Restrict to Package Quantities', 'wc-tier-package-prices'), + 'description' => __('Only allow quantities defined in packages above', 'wc-tier-package-prices'), + 'value' => $restrict === 'yes' ? 'yes' : 'no', + 'cbvalue' => 'yes', + 'wrapper_class' => 'form-row form-row-full' + )); + ?> +
+ + + + + +
+ display('admin/tier-row.twig', array( + 'index' => $index, + 'tier' => $tier, + 'field_prefix' => 'wc_tpp_tiers[' . $loop . ']' + )); + } + + /** + * Render a package row for variations + * + * @param int $loop Variation loop index + * @param int $index Package index + * @param array $package Package data + */ + private function render_variation_package_row($loop, $index, $package) { + WC_TPP_Template_Loader::get_instance()->display('admin/package-row.twig', array( + 'index' => $index, + 'package' => $package, + 'field_prefix' => 'wc_tpp_packages[' . $loop . ']' + )); + } + public function save_tier_package_fields($post_id) { // Verify nonce for security if (!isset($_POST['woocommerce_meta_nonce']) || !wp_verify_nonce($_POST['woocommerce_meta_nonce'], 'woocommerce_save_data')) { @@ -167,6 +321,68 @@ if (!class_exists('WC_TPP_Product_Meta')) { $restrict_to_packages = isset($_POST['_wc_tpp_restrict_to_packages']) ? 'yes' : 'no'; update_post_meta($post_id, '_wc_tpp_restrict_to_packages', $restrict_to_packages); } + + /** + * Save tier and package pricing for variations + * + * @param int $variation_id Variation ID + * @param int $loop Position in loop + */ + public function save_variation_pricing_fields($variation_id, $loop) { + // Security check + if (!current_user_can('edit_products')) { + return; + } + + // Save tier pricing for this variation + if (isset($_POST['wc_tpp_tiers'][$loop])) { + $tiers = array(); + foreach ($_POST['wc_tpp_tiers'][$loop] as $tier) { + if (!empty($tier['min_qty']) && !empty($tier['price'])) { + $tiers[] = array( + 'min_qty' => absint($tier['min_qty']), + 'price' => wc_format_decimal($tier['price']), + 'label' => sanitize_text_field($tier['label'] ?? '') + ); + } + } + // Sort by minimum quantity + usort($tiers, function($a, $b) { + return $a['min_qty'] - $b['min_qty']; + }); + update_post_meta($variation_id, '_wc_tpp_tiers', $tiers); + } else { + delete_post_meta($variation_id, '_wc_tpp_tiers'); + } + + // Save package pricing for this variation + if (isset($_POST['wc_tpp_packages'][$loop])) { + $packages = array(); + foreach ($_POST['wc_tpp_packages'][$loop] as $package) { + if (!empty($package['qty']) && !empty($package['price'])) { + $packages[] = array( + 'qty' => absint($package['qty']), + 'price' => wc_format_decimal($package['price']), + 'label' => sanitize_text_field($package['label'] ?? '') + ); + } + } + // Sort by quantity + usort($packages, function($a, $b) { + return $a['qty'] - $b['qty']; + }); + update_post_meta($variation_id, '_wc_tpp_packages', $packages); + } else { + delete_post_meta($variation_id, '_wc_tpp_packages'); + } + + // Save restriction setting for this variation + if (isset($_POST['wc_tpp_restrict_to_packages'][$loop]) && $_POST['wc_tpp_restrict_to_packages'][$loop] === 'yes') { + update_post_meta($variation_id, '_wc_tpp_restrict_to_packages', 'yes'); + } else { + delete_post_meta($variation_id, '_wc_tpp_restrict_to_packages'); + } + } } new WC_TPP_Product_Meta(); diff --git a/releases/wc-tier-and-package-prices-1.2.0.zip b/releases/wc-tier-and-package-prices-1.2.0.zip new file mode 100644 index 0000000..1198747 Binary files /dev/null and b/releases/wc-tier-and-package-prices-1.2.0.zip differ diff --git a/releases/wc-tier-and-package-prices-1.2.0.zip.md5 b/releases/wc-tier-and-package-prices-1.2.0.zip.md5 new file mode 100644 index 0000000..0d519e8 --- /dev/null +++ b/releases/wc-tier-and-package-prices-1.2.0.zip.md5 @@ -0,0 +1 @@ +cee7ab535938b4096f225f0e0640c9b7 wc-tier-and-package-prices-1.2.0.zip diff --git a/releases/wc-tier-and-package-prices-1.2.0.zip.sha256 b/releases/wc-tier-and-package-prices-1.2.0.zip.sha256 new file mode 100644 index 0000000..66f0acf --- /dev/null +++ b/releases/wc-tier-and-package-prices-1.2.0.zip.sha256 @@ -0,0 +1 @@ +b9cda03ef4ae8994e34fc1a6d8768e9c0a088461d795c5e79cb51f670c93d0b0 wc-tier-and-package-prices-1.2.0.zip diff --git a/templates/admin/package-row.twig b/templates/admin/package-row.twig index f1a3fa9..2461c38 100644 --- a/templates/admin/package-row.twig +++ b/templates/admin/package-row.twig @@ -4,33 +4,34 @@ # @package WC_Tier_Package_Prices # @var int index # @var array package + # @var string field_prefix (optional) - Prefix for field names (for variations) #} -
-

- +{% set name_prefix = field_prefix is defined ? field_prefix : '_wc_tpp_packages' %} + + -

-

- + + -

-

- + + -

- -
+ + + + + diff --git a/templates/admin/tier-row.twig b/templates/admin/tier-row.twig index 4a8f381..cfccb51 100644 --- a/templates/admin/tier-row.twig +++ b/templates/admin/tier-row.twig @@ -4,33 +4,34 @@ # @package WC_Tier_Package_Prices # @var int index # @var array tier + # @var string field_prefix (optional) - Prefix for field names (for variations) #} -
-

- +{% set name_prefix = field_prefix is defined ? field_prefix : '_wc_tpp_tiers' %} + + -

-

- + + -

-

- + + -

- -
+ + + + + diff --git a/wc-tier-and-package-prices.php b/wc-tier-and-package-prices.php index 3318375..d22b4d4 100644 --- a/wc-tier-and-package-prices.php +++ b/wc-tier-and-package-prices.php @@ -4,7 +4,7 @@ * Plugin Name: WooCommerce Tier and Package Prices * Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-tier-package-prices * Description: Add tier pricing and package prices to WooCommerce products with configurable quantities at fixed prices - * Version: 1.1.22 + * Version: 1.2.0 * Author: Marco Graetsch * Author URI: https://src.bundespruefstelle.ch/magdev * Text Domain: wc-tier-package-prices @@ -23,7 +23,7 @@ if (!defined('ABSPATH')) { // Define plugin constants if (!defined('WC_TPP_VERSION')) { - define('WC_TPP_VERSION', '1.1.22'); + define('WC_TPP_VERSION', '1.2.0'); } if (!defined('WC_TPP_PLUGIN_DIR')) { define('WC_TPP_PLUGIN_DIR', plugin_dir_path(__FILE__));