├── .gitignore ├── Gruntfile.js ├── README.md ├── assets ├── css │ ├── wc-pb-min-max-items-single-rtl.css │ └── wc-pb-min-max-items-single.css └── js │ ├── wc-pb-min-max-items-add-to-cart.js │ └── wc-pb-min-max-items-add-to-cart.min.js ├── languages └── woocommerce-product-bundles-min-max-items.pot ├── package.json ├── product-bundles-min-max-items-for-woocommerce.php └── readme.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Editors 2 | project.xml 3 | project.properties 4 | /nbproject/private/ 5 | .buildpath 6 | .project 7 | .settings* 8 | sftp-config.json 9 | .idea 10 | 11 | # Grunt 12 | /node_modules/ 13 | /vendor/ 14 | /deploy/ 15 | npm-debug.log 16 | package-lock.json 17 | 18 | # Sass 19 | .sass-cache/ 20 | 21 | # OS X metadata 22 | .DS_Store 23 | 24 | # Windows junk 25 | Thumbs.db 26 | 27 | # ApiGen 28 | /wc-apidocs/ 29 | 30 | # Unit tests 31 | /tmp 32 | /tests/coverage/ 33 | 34 | # Logs 35 | /logs -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | module.exports = function( grunt ) { 3 | 'use strict'; 4 | 5 | grunt.initConfig({ 6 | 7 | // setting folder templates 8 | dirs: { 9 | css: 'assets/css', 10 | js: 'assets/js' 11 | }, 12 | 13 | // JavaScript linting with JSHint. 14 | jshint: { 15 | options: { 16 | 'force': true, 17 | 'boss': true, 18 | 'curly': true, 19 | 'eqeqeq': false, 20 | 'eqnull': true, 21 | 'es3': false, 22 | 'expr': false, 23 | 'immed': true, 24 | 'noarg': true, 25 | 'onevar': true, 26 | 'quotmark': 'single', 27 | 'trailing': true, 28 | 'undef': true, 29 | 'unused': true, 30 | 'sub': false, 31 | 'browser': true, 32 | 'maxerr': 1000, 33 | globals: { 34 | 'jQuery': false, 35 | '$': false, 36 | 'Backbone': false, 37 | '_': false, 38 | 'wc_bundle_params': false 39 | }, 40 | }, 41 | all: [ 42 | 'Gruntfile.js', 43 | '<%= dirs.js %>/*.js', 44 | '!<%= dirs.js %>/*.min.js' 45 | ] 46 | }, 47 | 48 | // Minify .js files. 49 | uglify: { 50 | options: { 51 | preserveComments: 'some' 52 | }, 53 | jsfiles: { 54 | files: [{ 55 | expand: true, 56 | cwd: '<%= dirs.js %>', 57 | src: [ 58 | '*.js', 59 | '!*.min.js' 60 | ], 61 | dest: '<%= dirs.js %>', 62 | ext: '.min.js' 63 | }] 64 | } 65 | }, 66 | 67 | // Watch changes for assets. 68 | watch: { 69 | js: { 70 | files: [ 71 | '<%= dirs.js %>/*js' 72 | ], 73 | tasks: ['uglify'] 74 | } 75 | }, 76 | 77 | // Generate POT files. 78 | makepot: { 79 | options: { 80 | type: 'wp-plugin', 81 | domainPath: 'languages', 82 | potHeaders: { 83 | 'report-msgid-bugs-to': 'https://support.woothemes.com/hc/en-us', 84 | 'language-team': 'LANGUAGE ' 85 | } 86 | }, 87 | go: { 88 | options: { 89 | potFilename: 'woocommerce-product-bundles-min-max-items.pot', 90 | exclude: [ 91 | 'languages/.*', 92 | 'assets/.*', 93 | 'node-modules/.*', 94 | 'woo-includes/.*' 95 | ] 96 | } 97 | } 98 | }, 99 | 100 | // Check textdomain errors. 101 | checktextdomain: { 102 | options:{ 103 | text_domain: 'woocommerce-product-bundles-min-max-items', 104 | keywords: [ 105 | '__:1,2d', 106 | '_e:1,2d', 107 | '_x:1,2c,3d', 108 | 'esc_html__:1,2d', 109 | 'esc_html_e:1,2d', 110 | 'esc_html_x:1,2c,3d', 111 | 'esc_attr__:1,2d', 112 | 'esc_attr_e:1,2d', 113 | 'esc_attr_x:1,2c,3d', 114 | '_ex:1,2c,3d', 115 | '_n:1,2,4d', 116 | '_nx:1,2,4c,5d', 117 | '_n_noop:1,2,3d', 118 | '_nx_noop:1,2,3c,4d' 119 | ] 120 | }, 121 | files: { 122 | src: [ 123 | '**/*.php', // Include all files 124 | '!apigen/**', // Exclude apigen/ 125 | '!deploy/**', // Exclude deploy/ 126 | '!node_modules/**' // Exclude node_modules/ 127 | ], 128 | expand: true 129 | } 130 | }, 131 | 132 | rtlcss: { 133 | options: { 134 | config: { 135 | swapLeftRightInUrl: false, 136 | swapLtrRtlInUrl: false, 137 | autoRename: false, 138 | preserveDirectives: true 139 | }, 140 | properties : [ 141 | { 142 | name: 'swap-fontawesome-left-right-angles', 143 | expr: /content/im, 144 | action: function( prop, value ) { 145 | if ( value === '"\\f105"' ) { // fontawesome-angle-left 146 | value = '"\\f104"'; 147 | } 148 | if ( value === '"\\f178"' ) { // fontawesome-long-arrow-right 149 | value = '"\\f177"'; 150 | } 151 | return { prop: prop, value: value }; 152 | } 153 | } 154 | ] 155 | }, 156 | main: { 157 | expand: true, 158 | ext: '-rtl.css', 159 | src: [ 160 | 'assets/css/wc-pb-min-max-items-single.css' 161 | ] 162 | } 163 | } 164 | } ); 165 | 166 | // Load NPM tasks to be used here. 167 | grunt.loadNpmTasks( 'grunt-contrib-jshint' ); 168 | grunt.loadNpmTasks( 'grunt-contrib-uglify' ); 169 | grunt.loadNpmTasks( 'grunt-contrib-watch' ); 170 | grunt.loadNpmTasks( 'grunt-wp-i18n' ); 171 | grunt.loadNpmTasks( 'grunt-rtlcss' ); 172 | grunt.loadNpmTasks( 'grunt-checktextdomain' ); 173 | 174 | // Register tasks. 175 | grunt.registerTask( 'dev', [ 176 | 'checktextdomain', 177 | 'uglify', 178 | 'rtlcss' 179 | ] ); 180 | 181 | grunt.registerTask( 'default', [ 182 | 'dev', 183 | 'makepot' 184 | ] ); 185 | 186 | grunt.registerTask( 'domain', [ 187 | 'checktextdomain' 188 | ] ); 189 | }; 190 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Product Bundles - Min/Max Items 2 | 3 | ### Important 4 | 5 | **Product Bundles - Min/Max Items** has been rolled into Product Bundles. If you are running Product Bundles 6.4.0 or newer, you don't need this plugin! 6 | 7 | ### What's This? 8 | 9 | Free mini-extension for [WooCommerce Product Bundles](https://woocommerce.com/products/product-bundles/) that allows you to control the minimum or maximum quantity of bundled products that customers must choose in order to purchase a Product Bundle. 10 | 11 | ### How It Works 12 | 13 | Up until version 6.4.0, [WooCommerce Product Bundles](https://woocommerce.com/products/product-bundles/) did not include any options for selling fixed- or variable-size bundles, such as cases of wine, six-packs of soap, or candy boxes. 14 | 15 | The plugin adds two new options under **Product Data > Bundled Products**: 16 | 17 | * **Items required (≥)**; and 18 | * **Items allowed (≤)**. 19 | 20 | You can use these options to define a lower or upper quantity of items that customers must choose to purchase their own custom bundle. 21 | 22 | For details, check out [this example](https://docs.woocommerce.com/document/bundles/bundles-use-case-pick-and-mix/) from the official WooCommerce Product Bundles documentation. 23 | 24 | pb_use_case_pick_n_mix_57 25 | -------------------------------------------------------------------------------- /assets/css/wc-pb-min-max-items-single-rtl.css: -------------------------------------------------------------------------------- 1 | /* Base */ 2 | 3 | .bundled_items_selection_status { 4 | float: left; 5 | font-style: italic; 6 | padding-right: 1em; 7 | border-width: 0; 8 | border-right-width: 1px; 9 | border-right-style: solid; 10 | border-right-color: rgba(255, 255, 255, .25)!important; 11 | } 12 | -------------------------------------------------------------------------------- /assets/css/wc-pb-min-max-items-single.css: -------------------------------------------------------------------------------- 1 | /* Base */ 2 | 3 | .bundled_items_selection_status { 4 | float: right; 5 | font-style: italic; 6 | padding-left: 1em; 7 | border-width: 0; 8 | border-left-width: 1px; 9 | border-left-style: solid; 10 | border-left-color: rgba(255, 255, 255, .25)!important; 11 | } 12 | -------------------------------------------------------------------------------- /assets/js/wc-pb-min-max-items-add-to-cart.js: -------------------------------------------------------------------------------- 1 | /* global wc_pb_min_max_items_params */ 2 | 3 | ;( function ( $, window, document ) { 4 | 5 | function init_script( bundle ) { 6 | 7 | var min = bundle.$bundle_form.find( '.min_max_items' ).data( 'min' ), 8 | max = bundle.$bundle_form.find( '.min_max_items' ).data( 'max' ); 9 | 10 | if ( typeof( min ) !== 'undefined' && typeof( max ) !== 'undefined' ) { 11 | 12 | bundle.min_max_validation = { 13 | 14 | min: min, 15 | max: max, 16 | 17 | bind_validation_handler: function() { 18 | 19 | var min_max_validation = this; 20 | 21 | bundle.$bundle_data.on( 'woocommerce-product-bundle-validate', function( event, bundle ) { 22 | 23 | var total_qty = 0, 24 | qty_error_status = '', 25 | qty_error_prompt = '', 26 | passed_validation = true; 27 | 28 | // Count items. 29 | $.each( bundle.bundled_items, function( index, bundled_item ) { 30 | if ( bundled_item.is_selected() ) { 31 | total_qty += bundled_item.get_quantity(); 32 | } 33 | } ); 34 | 35 | // Validate. 36 | if ( min_max_validation.min !== '' && total_qty < parseInt( min_max_validation.min ) ) { 37 | 38 | passed_validation = false; 39 | 40 | if ( min_max_validation.min === 1 ) { 41 | 42 | if ( min_max_validation.min === min_max_validation.max ) { 43 | qty_error_prompt = wc_pb_min_max_items_params.i18n_min_zero_max_qty_error_singular; 44 | } else { 45 | qty_error_prompt = wc_pb_min_max_items_params.i18n_min_qty_error_singular; 46 | } 47 | 48 | } else { 49 | 50 | if ( min_max_validation.min === min_max_validation.max ) { 51 | qty_error_prompt = wc_pb_min_max_items_params.i18n_min_max_qty_error_plural; 52 | } else { 53 | qty_error_prompt = wc_pb_min_max_items_params.i18n_min_qty_error_plural; 54 | } 55 | 56 | qty_error_prompt = qty_error_prompt.replace( '%q', parseInt( min_max_validation.min ) ); 57 | } 58 | 59 | } else if ( min_max_validation.max !== '' && total_qty > parseInt( min_max_validation.max ) ) { 60 | 61 | passed_validation = false; 62 | 63 | if ( min_max_validation.max === 1 ) { 64 | 65 | if ( min_max_validation.min === min_max_validation.max ) { 66 | qty_error_prompt = wc_pb_min_max_items_params.i18n_min_max_qty_error_singular; 67 | } else { 68 | qty_error_prompt = wc_pb_min_max_items_params.i18n_max_qty_error_singular; 69 | } 70 | 71 | } else { 72 | 73 | if ( min_max_validation.min === min_max_validation.max ) { 74 | qty_error_prompt = wc_pb_min_max_items_params.i18n_min_max_qty_error_plural; 75 | } else { 76 | qty_error_prompt = wc_pb_min_max_items_params.i18n_max_qty_error_plural; 77 | } 78 | 79 | qty_error_prompt = qty_error_prompt.replace( '%q', parseInt( min_max_validation.max ) ); 80 | } 81 | } 82 | 83 | // Add notice. 84 | if ( ! passed_validation ) { 85 | 86 | if ( total_qty === 0 ) { 87 | 88 | qty_error_status = ''; 89 | 90 | if ( 'no' === bundle.price_data.zero_items_allowed ) { 91 | 92 | var validation_messages = bundle.get_validation_messages(), 93 | cleaned_validation_messages = []; 94 | 95 | for ( var i = 0; i <= validation_messages.length - 1; i++ ) { 96 | if ( validation_messages[ i ] !== wc_bundle_params.i18n_zero_qty_error ) { 97 | cleaned_validation_messages.push( validation_messages[ i ] ); 98 | } 99 | } 100 | 101 | bundle.validation_messages = cleaned_validation_messages; 102 | } 103 | 104 | } else if ( total_qty === 1 ) { 105 | qty_error_status = wc_pb_min_max_items_params.i18n_qty_error_singular; 106 | } else { 107 | qty_error_status = wc_pb_min_max_items_params.i18n_qty_error_plural; 108 | } 109 | 110 | qty_error_status = qty_error_status.replace( '%s', total_qty ); 111 | 112 | if ( bundle.validation_messages.length > 0 ) { 113 | bundle.add_validation_message( qty_error_prompt.replace( '%s', '' ) ); 114 | } else { 115 | bundle.add_validation_message( qty_error_prompt.replace( '%s', wc_pb_min_max_items_params.i18n_qty_error_status_format.replace( '%s', qty_error_status ) ) ); 116 | } 117 | } 118 | 119 | } ); 120 | } 121 | 122 | }; 123 | 124 | bundle.min_max_validation.bind_validation_handler(); 125 | } 126 | } 127 | 128 | $( 'body .component' ).on( 'wc-composite-component-loaded', function( event, component ) { 129 | if ( component.get_selected_product_type() === 'bundle' ) { 130 | var bundle = component.get_bundle_script(); 131 | if ( bundle ) { 132 | init_script( bundle ); 133 | bundle.update_bundle_task(); 134 | } 135 | } 136 | } ); 137 | 138 | $( '.bundle_form .bundle_data' ).each( function() { 139 | 140 | $( this ).on( 'woocommerce-product-bundle-initializing', function( event, bundle ) { 141 | if ( ! bundle.is_composited() ) { 142 | init_script( bundle ); 143 | } 144 | } ); 145 | } ); 146 | 147 | } ) ( jQuery, window, document ); 148 | -------------------------------------------------------------------------------- /assets/js/wc-pb-min-max-items-add-to-cart.min.js: -------------------------------------------------------------------------------- 1 | !function(_,a,i){function n(a){var i=a.$bundle_form.find(".min_max_items").data("min"),n=a.$bundle_form.find(".min_max_items").data("max");void 0!==i&&void 0!==n&&(a.min_max_validation={min:i,max:n,bind_validation_handler:function(){var i=this;a.$bundle_data.on("woocommerce-product-bundle-validate",function(a,n){var m=0,e="",r="",t=!0;if(_.each(n.bundled_items,function(_,a){a.is_selected()&&(m+=a.get_quantity())}),""!==i.min&&mparseInt(i.max)&&(t=!1,r=1===i.max?i.min===i.max?wc_pb_min_max_items_params.i18n_min_max_qty_error_singular:wc_pb_min_max_items_params.i18n_max_qty_error_singular:(r=i.min===i.max?wc_pb_min_max_items_params.i18n_min_max_qty_error_plural:wc_pb_min_max_items_params.i18n_max_qty_error_plural).replace("%q",parseInt(i.max))),!t){if(0===m){if(e="","no"===n.price_data.zero_items_allowed){for(var s=n.get_validation_messages(),o=[],l=0;l<=s.length-1;l++)s[l]!==wc_bundle_params.i18n_zero_qty_error&&o.push(s[l]);n.validation_messages=o}}else e=1===m?wc_pb_min_max_items_params.i18n_qty_error_singular:wc_pb_min_max_items_params.i18n_qty_error_plural;e=e.replace("%s",m),n.validation_messages.length>0?n.add_validation_message(r.replace("%s","")):n.add_validation_message(r.replace("%s",wc_pb_min_max_items_params.i18n_qty_error_status_format.replace("%s",e)))}})}},a.min_max_validation.bind_validation_handler())}_("body .component").on("wc-composite-component-loaded",function(_,a){if("bundle"===a.get_selected_product_type()){var i=a.get_bundle_script();i&&(n(i),i.update_bundle_task())}}),_(".bundle_form .bundle_data").each(function(){_(this).on("woocommerce-product-bundle-initializing",function(_,a){a.is_composited()||n(a)})})}(jQuery,window,document); -------------------------------------------------------------------------------- /languages/woocommerce-product-bundles-min-max-items.pot: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 SomewhereWarm 2 | # This file is distributed under the GNU General Public License v3.0. 3 | msgid "" 4 | msgstr "" 5 | "Project-Id-Version: Product Bundles - Min/Max Items 1.4.3\n" 6 | "Report-Msgid-Bugs-To: https://support.woothemes.com/hc/en-us\n" 7 | "POT-Creation-Date: 2020-09-08 07:20:59+00:00\n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=utf-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "PO-Revision-Date: 2020-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "X-Generator: grunt-wp-i18n 1.0.3\n" 15 | 16 | #: product-bundles-min-max-items-for-woocommerce.php:132 17 | msgid "" 18 | "Product Bundles – Min/Max Items requires WooCommerce Product Bundles version " 20 | "%2$s or higher." 21 | msgstr "" 22 | 23 | #: product-bundles-min-max-items-for-woocommerce.php:144 24 | msgid "Items Required (≥)" 25 | msgstr "" 26 | 27 | #: product-bundles-min-max-items-for-woocommerce.php:146 28 | msgid "Minimum required quantity of bundled items." 29 | msgstr "" 30 | 31 | #: product-bundles-min-max-items-for-woocommerce.php:153 32 | msgid "Items Allowed (≤)" 33 | msgstr "" 34 | 35 | #: product-bundles-min-max-items-for-woocommerce.php:155 36 | msgid "Maximum allowed quantity of bundled items." 37 | msgstr "" 38 | 39 | #: product-bundles-min-max-items-for-woocommerce.php:195 40 | msgid "Please choose an item." 41 | msgstr "" 42 | 43 | #: product-bundles-min-max-items-for-woocommerce.php:196 44 | msgid "Please choose 1 item.%s" 45 | msgstr "" 46 | 47 | #: product-bundles-min-max-items-for-woocommerce.php:197 48 | msgid "Please choose at least 1 item.%s" 49 | msgstr "" 50 | 51 | #: product-bundles-min-max-items-for-woocommerce.php:198 52 | msgid "Please choose up to 1 item.%s" 53 | msgstr "" 54 | 55 | #: product-bundles-min-max-items-for-woocommerce.php:199 56 | msgid "Please choose at least %1$s items.%2$s" 57 | msgstr "" 58 | 59 | #: product-bundles-min-max-items-for-woocommerce.php:200 60 | msgid "Please choose up to %1$s items.%2$s" 61 | msgstr "" 62 | 63 | #: product-bundles-min-max-items-for-woocommerce.php:201 64 | msgid "Please choose %1$s items.%2$s" 65 | msgstr "" 66 | 67 | #: product-bundles-min-max-items-for-woocommerce.php:202 68 | msgid "%s items selected" 69 | msgstr "" 70 | 71 | #: product-bundles-min-max-items-for-woocommerce.php:203 72 | msgid "1 item selected" 73 | msgstr "" 74 | 75 | #: product-bundles-min-max-items-for-woocommerce.php:271 76 | msgid ""%s" cannot be purchased" 77 | msgstr "" 78 | 79 | #: product-bundles-min-max-items-for-woocommerce.php:274 80 | #: product-bundles-min-max-items-for-woocommerce.php:335 81 | msgid "please choose 1 item" 82 | msgid_plural "please choose %s items" 83 | msgstr[0] "" 84 | msgstr[1] "" 85 | 86 | #: product-bundles-min-max-items-for-woocommerce.php:276 87 | #: product-bundles-min-max-items-for-woocommerce.php:337 88 | msgid "please choose at least 1 item" 89 | msgid_plural "please choose at least %s items" 90 | msgstr[0] "" 91 | msgstr[1] "" 92 | 93 | #: product-bundles-min-max-items-for-woocommerce.php:278 94 | #: product-bundles-min-max-items-for-woocommerce.php:339 95 | msgid "please limit your selection to 1 item" 96 | msgid_plural "please choose up to %s items" 97 | msgstr[0] "" 98 | msgstr[1] "" 99 | 100 | #: product-bundles-min-max-items-for-woocommerce.php:332 101 | msgid ""%s" cannot be added to the cart" 102 | msgstr "" 103 | 104 | #: product-bundles-min-max-items-for-woocommerce.php:343 105 | msgid " (you have chosen 1)" 106 | msgstr "" 107 | 108 | #: product-bundles-min-max-items-for-woocommerce.php:345 109 | msgid " (you have chosen %s)" 110 | msgstr "" 111 | 112 | #. Plugin Name of the plugin/theme 113 | msgid "Product Bundles - Min/Max Items" 114 | msgstr "" 115 | 116 | #. Plugin URI of the plugin/theme 117 | msgid "" 118 | "https://docs.woocommerce.com/document/bundles/bundles-extensions/#min-max-" 119 | "items" 120 | msgstr "" 121 | 122 | #. Description of the plugin/theme 123 | msgid "" 124 | "Free mini-extension for WooCommerce Product Bundles that allows you to " 125 | "control the minimum or maximum quantity of bundled products that customers " 126 | "must choose in order to purchase a Product Bundle." 127 | msgstr "" 128 | 129 | #. Author of the plugin/theme 130 | msgid "SomewhereWarm" 131 | msgstr "" 132 | 133 | #. Author URI of the plugin/theme 134 | msgid "https://somewherewarm.com/" 135 | msgstr "" 136 | 137 | #: product-bundles-min-max-items-for-woocommerce.php:204 138 | msgctxt "validation error status format" 139 | msgid "%s" 140 | msgstr "" 141 | 142 | #: product-bundles-min-max-items-for-woocommerce.php:281 143 | msgctxt "cart validation error: action, resolution" 144 | msgid "%1$s – %2$s." 145 | msgstr "" 146 | 147 | #: product-bundles-min-max-items-for-woocommerce.php:348 148 | msgctxt "add-to-cart validation error: action, resolution, status" 149 | msgid "%1$s – %2$s%3$s." 150 | msgstr "" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "woocommerce-product-bundles-min-max-items", 3 | "title": "Product Bundles - Min/Max Items for WooCommerce", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/somewherewarm/woocommerce-product-bundles-min-max-items.git" 7 | }, 8 | "license": "GPL-3.0+", 9 | "version": "1.4.3", 10 | "homepage": "https://woocommerce.com/products/product-bundles/", 11 | "main": "Gruntfile.js", 12 | "devDependencies": { 13 | "grunt": "~1.0.1", 14 | "grunt-checktextdomain": "~1.0.0", 15 | "grunt-contrib-jshint": "~1.1.0", 16 | "grunt-contrib-uglify": "~3.0.0", 17 | "grunt-contrib-watch": "^1.1.0", 18 | "grunt-rtlcss": "~2.0.1", 19 | "grunt-wp-i18n": "~1.0.0" 20 | }, 21 | "engines": { 22 | "node": ">=6.9.4", 23 | "npm": ">=1.1.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /product-bundles-min-max-items-for-woocommerce.php: -------------------------------------------------------------------------------- 1 | version, self::$req_pb_version ) < 0 ) { 84 | add_action( 'admin_notices', array( __CLASS__, 'pb_admin_notice' ) ); 85 | return false; 86 | } 87 | 88 | // Display min/max qty settings in "Bundled Products" tab. 89 | add_action( 'woocommerce_bundled_products_admin_config', array( __CLASS__, 'display_options' ), 15 ); 90 | 91 | // Save min/max qty settings. 92 | add_action( 'woocommerce_admin_process_product_object', array( __CLASS__, 'save_meta' ) ); 93 | 94 | // Validation script. 95 | add_action( 'woocommerce_bundle_add_to_cart', array( __CLASS__, 'script' ) ); 96 | add_action( 'woocommerce_composite_add_to_cart', array( __CLASS__, 'script' ) ); 97 | 98 | // Add min/max data to template for use by validation script. 99 | add_action( 'woocommerce_before_bundled_items', array( __CLASS__, 'script_data' ) ); 100 | add_action( 'woocommerce_before_composited_bundled_items', array( __CLASS__, 'script_data' ) ); 101 | 102 | // Add-to-Cart validation. 103 | add_action( 'woocommerce_add_to_cart_bundle_validation', array( __CLASS__, 'add_to_cart_validation' ), 10, 4 ); 104 | 105 | // Cart validation. 106 | add_action( 'woocommerce_check_cart_items', array( __CLASS__, 'cart_validation' ), 15 ); 107 | 108 | // Change bundled item quantities. 109 | add_filter( 'woocommerce_bundled_item_quantity', array( __CLASS__, 'bundled_item_quantity' ), 10, 3 ); 110 | add_filter( 'woocommerce_bundled_item_quantity_max', array( __CLASS__, 'bundled_item_quantity_max' ), 10, 3 ); 111 | 112 | // When min/max qty constraints are present, require input. 113 | add_filter( 'woocommerce_bundle_requires_input', array( __CLASS__, 'min_max_bundle_requires_input' ), 10, 2 ); 114 | 115 | // Localization. 116 | add_action( 'init', array( __CLASS__, 'localize_plugin' ) ); 117 | } 118 | 119 | /** 120 | * Load textdomain. 121 | * 122 | * @return void 123 | */ 124 | public static function localize_plugin() { 125 | load_plugin_textdomain( 'woocommerce-product-bundles-min-max-items', false, dirname( plugin_basename( __FILE__ ) ) . '/languages/' ); 126 | } 127 | 128 | /** 129 | * PB version check notice. 130 | */ 131 | public static function pb_admin_notice() { 132 | echo '

' . sprintf( __( 'Product Bundles – Min/Max Items requires WooCommerce Product Bundles version %2$s or higher.', 'woocommerce-product-bundles-min-max-items' ), self::$pb_url, self::$req_pb_version ) . '

'; 133 | } 134 | 135 | /** 136 | * Admin min/max settings. 137 | */ 138 | public static function display_options() { 139 | 140 | woocommerce_wp_text_input( array( 141 | 'id' => '_wcpb_min_qty_limit', 142 | 'wrapper_class' => 'bundled_product_data_field', 143 | 'type' => 'number', 144 | 'label' => __( 'Items Required (≥)', 'woocommerce-product-bundles-min-max-items' ), 145 | 'desc_tip' => true, 146 | 'description' => __( 'Minimum required quantity of bundled items.', 'woocommerce-product-bundles-min-max-items' ) 147 | ) ); 148 | 149 | woocommerce_wp_text_input( array( 150 | 'id' => '_wcpb_max_qty_limit', 151 | 'wrapper_class' => 'bundled_product_data_field', 152 | 'type' => 'number', 153 | 'label' => __( 'Items Allowed (≤)', 'woocommerce-product-bundles-min-max-items' ), 154 | 'desc_tip' => true, 155 | 'description' => __( 'Maximum allowed quantity of bundled items.', 'woocommerce-product-bundles-min-max-items' ) 156 | ) ); 157 | } 158 | 159 | /** 160 | * Save meta. 161 | * 162 | * @param WC_Product $product 163 | * @return void 164 | */ 165 | public static function save_meta( $product ) { 166 | 167 | if ( ! empty( $_POST[ '_wcpb_min_qty_limit' ] ) && is_numeric( $_POST[ '_wcpb_min_qty_limit' ] ) ) { 168 | $product->add_meta_data( '_wcpb_min_qty_limit', stripslashes( $_POST[ '_wcpb_min_qty_limit' ] ), true ); 169 | } else { 170 | $product->delete_meta_data( '_wcpb_min_qty_limit' ); 171 | } 172 | 173 | if ( ! empty( $_POST[ '_wcpb_max_qty_limit' ] ) && is_numeric( $_POST[ '_wcpb_max_qty_limit' ] ) ) { 174 | $product->add_meta_data( '_wcpb_max_qty_limit', stripslashes( $_POST[ '_wcpb_max_qty_limit' ] ), true ); 175 | } else { 176 | $product->delete_meta_data( '_wcpb_max_qty_limit' ); 177 | } 178 | } 179 | 180 | /** 181 | * Validation script. 182 | */ 183 | public static function script() { 184 | 185 | $suffix = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? '' : '.min'; 186 | 187 | wp_register_script( 'wc-pb-min-max-items-add-to-cart', self::plugin_url() . '/assets/js/wc-pb-min-max-items-add-to-cart' . $suffix . '.js', array( 'wc-add-to-cart-bundle' ), self::$version ); 188 | wp_enqueue_script( 'wc-pb-min-max-items-add-to-cart' ); 189 | 190 | wp_register_style( 'wc-pb-min-max-items-single-css', self::plugin_url() . '/assets/css/wc-pb-min-max-items-single.css', false, self::$version ); 191 | wp_style_add_data( 'wc-pb-min-max-items-single-css', 'rtl', 'replace' ); 192 | wp_enqueue_style( 'wc-pb-min-max-items-single-css' ); 193 | 194 | $params = array( 195 | 'i18n_min_zero_max_qty_error_singular' => __( 'Please choose an item.', 'woocommerce-product-bundles-min-max-items' ), 196 | 'i18n_min_max_qty_error_singular' => __( 'Please choose 1 item.%s', 'woocommerce-product-bundles-min-max-items' ), 197 | 'i18n_min_qty_error_singular' => __( 'Please choose at least 1 item.%s', 'woocommerce-product-bundles-min-max-items' ), 198 | 'i18n_max_qty_error_singular' => __( 'Please choose up to 1 item.%s', 'woocommerce-product-bundles-min-max-items' ), 199 | 'i18n_min_qty_error_plural' => sprintf( __( 'Please choose at least %1$s items.%2$s', 'woocommerce-product-bundles-min-max-items' ), '%q', '%s' ), 200 | 'i18n_max_qty_error_plural' => sprintf( __( 'Please choose up to %1$s items.%2$s', 'woocommerce-product-bundles-min-max-items' ), '%q', '%s' ), 201 | 'i18n_min_max_qty_error_plural' => sprintf( __( 'Please choose %1$s items.%2$s', 'woocommerce-product-bundles-min-max-items' ), '%q', '%s' ), 202 | 'i18n_qty_error_plural' => __( '%s items selected', 'woocommerce-product-bundles-min-max-items' ), 203 | 'i18n_qty_error_singular' => __( '1 item selected', 'woocommerce-product-bundles-min-max-items' ), 204 | 'i18n_qty_error_status_format' => _x( '%s', 'validation error status format', 'woocommerce-product-bundles-min-max-items' ) 205 | ); 206 | 207 | wp_localize_script( 'wc-pb-min-max-items-add-to-cart', 'wc_pb_min_max_items_params', $params ); 208 | } 209 | 210 | /** 211 | * Pass min/max container values to the single-product script. 212 | * 213 | * @param WC_Product $product 214 | * @return void 215 | */ 216 | public static function script_data( $the_product = false ) { 217 | 218 | global $product; 219 | 220 | if ( ! $the_product ) { 221 | $the_product = $product; 222 | } 223 | 224 | if ( is_object( $the_product ) && $the_product->is_type( 'bundle' ) ) { 225 | 226 | $min = $the_product->get_meta( '_wcpb_min_qty_limit', true ); 227 | $max = $the_product->get_meta( '_wcpb_max_qty_limit', true ); 228 | 229 | ?>
cart->cart_contents as $cart_item_key => $cart_item ) { 239 | 240 | if ( wc_pb_is_bundle_container_cart_item( $cart_item ) ) { 241 | 242 | $configuration = isset( $cart_item[ 'stamp' ] ) ? $cart_item[ 'stamp' ] : false; 243 | $items_selected = 0; 244 | 245 | $bundle = $cart_item[ 'data' ]; 246 | 247 | $min_meta = $bundle->get_meta( '_wcpb_min_qty_limit', true ); 248 | $max_meta = $bundle->get_meta( '_wcpb_max_qty_limit', true ); 249 | 250 | $items_min = $min_meta > 0 ? absint( $min_meta ) : ''; 251 | $items_max = $max_meta > 0 ? absint( $max_meta ) : ''; 252 | 253 | if ( $configuration ) { 254 | foreach ( $configuration as $item_id => $item_configuration ) { 255 | $item_qty = isset( $item_configuration[ 'quantity' ] ) ? $item_configuration[ 'quantity' ] : 0; 256 | $items_selected += $item_qty; 257 | } 258 | } 259 | 260 | $items_invalid = false; 261 | 262 | if ( $items_min !== '' && $items_selected < $items_min ) { 263 | $items_invalid = true; 264 | } else if ( $items_max !== '' && $items_selected > $items_max ) { 265 | $items_invalid = true; 266 | } 267 | 268 | if ( $items_invalid ) { 269 | 270 | $bundle_title = $bundle->get_title(); 271 | $action = sprintf( __( '"%s" cannot be purchased', 'woocommerce-product-bundles-min-max-items' ), $bundle_title ); 272 | 273 | if ( $items_min === $items_max ) { 274 | $resolution = sprintf( _n( 'please choose 1 item', 'please choose %s items', $items_min, 'woocommerce-product-bundles-min-max-items' ), $items_min ); 275 | } elseif ( $items_selected < $items_min ) { 276 | $resolution = sprintf( _n( 'please choose at least 1 item', 'please choose at least %s items', $items_min, 'woocommerce-product-bundles-min-max-items' ), $items_min ); 277 | } else { 278 | $resolution = sprintf( _n( 'please limit your selection to 1 item', 'please choose up to %s items', $items_max, 'woocommerce-product-bundles-min-max-items' ), $items_max ); 279 | } 280 | 281 | $message = sprintf( _x( '%1$s – %2$s.', 'cart validation error: action, resolution', 'woocommerce-product-bundles-min-max-items' ), $action, $resolution ); 282 | 283 | wc_add_notice( $message, 'error' ); 284 | 285 | $is_valid = false; 286 | } 287 | } 288 | } 289 | } 290 | 291 | /** 292 | * Add-to-Cart validation. 293 | * 294 | * @param bool $result 295 | * @param int $bundle_id 296 | * @param WC_PB_Stock_Manager $stock_data 297 | * @param array $configuration 298 | * @return boolean 299 | */ 300 | public static function add_to_cart_validation( $is_valid, $bundle_id, $stock_data, $configuration = array() ) { 301 | 302 | if ( $is_valid ) { 303 | 304 | $bundle = $stock_data->product; 305 | 306 | $min_meta = $bundle->get_meta( '_wcpb_min_qty_limit', true ); 307 | $max_meta = $bundle->get_meta( '_wcpb_max_qty_limit', true ); 308 | 309 | $items_min = $min_meta > 0 ? absint( $min_meta ) : ''; 310 | $items_max = $max_meta > 0 ? absint( $max_meta ) : ''; 311 | 312 | $items = $stock_data->get_items(); 313 | $items_selected = 0; 314 | 315 | foreach ( $items as $item ) { 316 | $item_id = isset( $item->bundled_item ) && $item->bundled_item ? $item->bundled_item->item_id : false; 317 | $item_qty = $item_id && isset( $configuration[ $item_id ] ) && isset( $configuration[ $item_id ][ 'quantity' ] ) ? $configuration[ $item_id ][ 'quantity' ] : $item->quantity; 318 | $items_selected += $item_qty; 319 | } 320 | 321 | $items_invalid = false; 322 | 323 | if ( $items_min !== '' && $items_selected < $items_min ) { 324 | $items_invalid = true; 325 | } else if ( $items_max !== '' && $items_selected > $items_max ) { 326 | $items_invalid = true; 327 | } 328 | 329 | if ( $items_invalid ) { 330 | 331 | $bundle_title = $bundle->get_title(); 332 | $action = sprintf( __( '"%s" cannot be added to the cart', 'woocommerce-product-bundles-min-max-items' ), $bundle_title ); 333 | 334 | if ( $items_min === $items_max ) { 335 | $resolution = sprintf( _n( 'please choose 1 item', 'please choose %s items', $items_min, 'woocommerce-product-bundles-min-max-items' ), $items_min ); 336 | } elseif ( $items_selected < $items_min ) { 337 | $resolution = sprintf( _n( 'please choose at least 1 item', 'please choose at least %s items', $items_min, 'woocommerce-product-bundles-min-max-items' ), $items_min ); 338 | } else { 339 | $resolution = sprintf( _n( 'please limit your selection to 1 item', 'please choose up to %s items', $items_max, 'woocommerce-product-bundles-min-max-items' ), $items_max ); 340 | } 341 | 342 | if ( $items_selected === 1 ) { 343 | $status = __( ' (you have chosen 1)', 'woocommerce-product-bundles-min-max-items' ); 344 | } elseif ( $items_selected > 1 ) { 345 | $status = sprintf( __( ' (you have chosen %s)', 'woocommerce-product-bundles-min-max-items' ), $items_selected ); 346 | } 347 | 348 | $message = sprintf( _x( '%1$s – %2$s%3$s.', 'add-to-cart validation error: action, resolution, status', 'woocommerce-product-bundles-min-max-items' ), $action, $resolution, $status ); 349 | 350 | wc_add_notice( $message, 'error' ); 351 | 352 | $is_valid = false; 353 | } 354 | } 355 | 356 | return $is_valid; 357 | } 358 | 359 | /** 360 | * Filter bundled item min quantities used in sync/price context. 361 | * 362 | * @param int $qty 363 | * @param WC_Bundled_Item $bundled_item 364 | * @param array $args 365 | * @return int 366 | */ 367 | public static function bundled_item_quantity( $qty, $bundled_item, $args = array() ) { 368 | 369 | if ( isset( $args[ 'context' ] ) && in_array( $args[ 'context' ], array( 'sync', 'price' ) ) ) { 370 | 371 | $bundle = $bundled_item->get_bundle(); 372 | $min_qty = $bundle ? $bundle->get_meta( '_wcpb_min_qty_limit', true ) : ''; 373 | 374 | if ( $min_qty ) { 375 | 376 | if ( 'sync' === $args[ 'context' ] ) { 377 | $quantities = self::get_min_required_quantities( $bundle ); 378 | } elseif ( 'price' === $args[ 'context' ] ) { 379 | $quantities = self::get_min_price_quantities( $bundle ); 380 | } 381 | 382 | if ( isset( $quantities[ $bundled_item->get_id() ] ) ) { 383 | $qty = $quantities[ $bundled_item->get_id() ]; 384 | } 385 | } 386 | } 387 | 388 | return $qty; 389 | } 390 | 391 | /** 392 | * Filter bundled item max quantities used in sync/price context. 393 | * 394 | * @param int $qty 395 | * @param WC_Bundled_Item $bundled_item 396 | * @param array $args 397 | * @return int 398 | */ 399 | public static function bundled_item_quantity_max( $qty, $bundled_item, $args = array() ) { 400 | 401 | if ( isset( $args[ 'context' ] ) && in_array( $args[ 'context' ], array( 'sync', 'price' ) ) ) { 402 | 403 | $bundle = $bundled_item->get_bundle(); 404 | $min_qty = $bundle ? $bundle->get_meta( '_wcpb_min_qty_limit', true ) : ''; 405 | 406 | if ( $min_qty ) { 407 | 408 | if ( 'price' === $args[ 'context' ] ) { 409 | $quantities = self::get_max_price_quantities( $bundle ); 410 | } 411 | 412 | if ( isset( $quantities[ $bundled_item->get_id() ] ) ) { 413 | $qty = $quantities[ $bundled_item->get_id() ]; 414 | } 415 | } 416 | } 417 | 418 | return $qty; 419 | } 420 | 421 | /** 422 | * Find the price-optimized AND availability-constrained set of bundled item quantities that meet the min item count constraint while honoring the initial min/max item quantity constraints. 423 | * 424 | * @param WC_Product $product 425 | * @return array 426 | */ 427 | public static function get_min_required_quantities( $bundle ) { 428 | 429 | $result = WC_PB_Helpers::cache_get( 'min_required_quantities_' . $bundle->get_id() ); 430 | 431 | if ( is_null( $result ) ) { 432 | 433 | $quantities = array( 434 | 'min' => array(), 435 | 'max' => array() 436 | ); 437 | 438 | $pricing_data = array(); 439 | $bundled_items = $bundle->get_bundled_items(); 440 | 441 | if ( ! empty( $bundled_items ) ) { 442 | 443 | $min_qty = $bundle->get_meta( '_wcpb_min_qty_limit', true ); 444 | 445 | foreach ( $bundled_items as $bundled_item ) { 446 | $pricing_data[ $bundled_item->get_id() ][ 'price' ] = $bundled_item->get_price(); 447 | $pricing_data[ $bundled_item->get_id() ][ 'regular_price' ] = $bundled_item->get_regular_price(); 448 | $quantities[ 'min' ][ $bundled_item->get_id() ] = $bundled_item->get_quantity( 'min', array( 'check_optional' => true ) ); 449 | $quantities[ 'max' ][ $bundled_item->get_id() ] = $bundled_item->get_quantity( 'max' ); 450 | } 451 | 452 | // Slots filled so far. 453 | $filled_slots = 0; 454 | 455 | foreach ( $quantities[ 'min' ] as $item_min_qty ) { 456 | $filled_slots += $item_min_qty; 457 | } 458 | 459 | // Fill in the box with items that are in stock, giving preference to cheapest available. 460 | if ( $filled_slots < $min_qty ) { 461 | 462 | // Sort by cheapest. 463 | uasort( $pricing_data, array( __CLASS__, 'sort_by_price' ) ); 464 | 465 | // Fill additional slots. 466 | foreach ( $pricing_data as $bundled_item_id => $data ) { 467 | 468 | $slots_to_fill = $min_qty - $filled_slots; 469 | 470 | if ( $filled_slots >= $min_qty ) { 471 | break; 472 | } 473 | 474 | $bundled_item = $bundled_items[ $bundled_item_id ]; 475 | 476 | if ( false === $bundled_item->is_purchasable() ) { 477 | continue; 478 | } 479 | 480 | if ( false === $bundled_item->is_in_stock() ) { 481 | continue; 482 | } 483 | 484 | $max_stock = $bundled_item->get_max_stock(); 485 | $max_item_qty = $quantities[ 'max' ][ $bundled_item_id ]; 486 | 487 | if ( '' === $max_item_qty ) { 488 | $max_items_to_use = $max_stock; 489 | } elseif ( '' === $max_stock ) { 490 | $max_items_to_use = $max_item_qty; 491 | } else { 492 | $max_items_to_use = min( $max_item_qty, $max_stock ); 493 | } 494 | 495 | $min_items_to_use = $quantities[ 'min' ][ $bundled_item_id ]; 496 | 497 | $items_to_use = '' !== $max_items_to_use ? min( $max_items_to_use - $min_items_to_use, $slots_to_fill ) : $slots_to_fill; 498 | 499 | $filled_slots += $items_to_use; 500 | 501 | $quantities[ 'min' ][ $bundled_item_id ] += $items_to_use; 502 | } 503 | } 504 | 505 | // If there are empty slots, then bundled items do not have sufficient stock to fill the minimum box size. 506 | // In this case, ignore stock constraints and return the optimal price quantities, forcing the bundle to show up as out of stock. 507 | 508 | if ( $min_qty > $filled_slots ) { 509 | $quantities[ 'min' ] = self::get_min_price_quantities( $bundle ); 510 | } 511 | } 512 | 513 | $result = $quantities[ 'min' ]; 514 | WC_PB_Helpers::cache_set( 'min_required_quantities_' . $bundle->get_id(), $result ); 515 | } 516 | 517 | return $result; 518 | } 519 | 520 | /** 521 | * Find the price-optimized set of bundled item quantities that meet the min item count constraint while honoring the initial min/max item quantity constraints. 522 | * 523 | * @param WC_Product $product 524 | * @return array 525 | */ 526 | public static function get_min_price_quantities( $bundle ) { 527 | 528 | $result = WC_PB_Helpers::cache_get( 'min_price_quantities_' . $bundle->get_id() ); 529 | 530 | if ( is_null( $result ) ) { 531 | 532 | $quantities = array( 533 | 'min' => array(), 534 | 'max' => array() 535 | ); 536 | 537 | $pricing_data = array(); 538 | $bundled_items = $bundle->get_bundled_items(); 539 | 540 | if ( ! empty( $bundled_items ) ) { 541 | foreach ( $bundled_items as $bundled_item ) { 542 | $pricing_data[ $bundled_item->get_id() ][ 'price' ] = $bundled_item->get_price(); 543 | $quantities[ 'min' ][ $bundled_item->get_id() ] = $bundled_item->get_quantity( 'min', array( 'check_optional' => true ) ); 544 | $quantities[ 'max' ][ $bundled_item->get_id() ] = $bundled_item->get_quantity( 'max' ); 545 | } 546 | } 547 | 548 | if ( ! empty( $pricing_data ) ) { 549 | 550 | $min_qty = $bundle->get_meta( '_wcpb_min_qty_limit', true ); 551 | 552 | // Slots filled due to item min quantities. 553 | $filled_slots = 0; 554 | 555 | foreach ( $quantities[ 'min' ] as $item_min_qty ) { 556 | $filled_slots += $item_min_qty; 557 | } 558 | 559 | // Fill in the remaining box slots with cheapest combination of items. 560 | if ( $filled_slots < $min_qty ) { 561 | 562 | // Sort by cheapest. 563 | uasort( $pricing_data, array( __CLASS__, 'sort_by_price' ) ); 564 | 565 | // Fill additional slots. 566 | foreach ( $pricing_data as $bundled_item_id => $data ) { 567 | 568 | $slots_to_fill = $min_qty - $filled_slots; 569 | 570 | if ( $filled_slots >= $min_qty ) { 571 | break; 572 | } 573 | 574 | $bundled_item = $bundled_items[ $bundled_item_id ]; 575 | 576 | if ( false === $bundled_item->is_purchasable() ) { 577 | continue; 578 | } 579 | 580 | $max_items_to_use = $quantities[ 'max' ][ $bundled_item_id ]; 581 | $min_items_to_use = $quantities[ 'min' ][ $bundled_item_id ]; 582 | 583 | $items_to_use = '' !== $max_items_to_use ? min( $max_items_to_use - $min_items_to_use, $slots_to_fill ) : $slots_to_fill; 584 | 585 | $filled_slots += $items_to_use; 586 | 587 | $quantities[ 'min' ][ $bundled_item_id ] += $items_to_use; 588 | } 589 | } 590 | } 591 | 592 | $result = $quantities[ 'min' ]; 593 | WC_PB_Helpers::cache_set( 'min_price_quantities_' . $bundle->get_id(), $result ); 594 | } 595 | 596 | return $result; 597 | } 598 | 599 | /** 600 | * Find the worst-price set of bundled item quantities that meet the max item count constraint while honoring the initial min/max item quantity constraints. 601 | * 602 | * @param WC_Product $product 603 | * @return array 604 | */ 605 | public static function get_max_price_quantities( $bundle ) { 606 | 607 | $result = WC_PB_Helpers::cache_get( 'max_price_quantities_' . $bundle->get_id() ); 608 | 609 | /* 610 | * Max items count defined: Put the min quantities in the box, then keep adding items giving preference to the most expensive ones, while honoring their max quantity constraints. 611 | */ 612 | if ( is_null( $result ) ) { 613 | 614 | $quantities = array( 615 | 'min' => array(), 616 | 'max' => array() 617 | ); 618 | 619 | $pricing_data = array(); 620 | $bundled_items = $bundle->get_bundled_items(); 621 | 622 | if ( ! empty( $bundled_items ) ) { 623 | foreach ( $bundled_items as $bundled_item ) { 624 | $pricing_data[ $bundled_item->get_id() ][ 'price' ] = $bundled_item->get_price(); 625 | $quantities[ 'min' ][ $bundled_item->get_id() ] = $bundled_item->get_quantity( 'min', array( 'check_optional' => true ) ); 626 | $quantities[ 'max' ][ $bundled_item->get_id() ] = $bundled_item->get_quantity( 'max' ); 627 | } 628 | } 629 | 630 | $max_qty = $bundle->get_meta( '_wcpb_max_qty_limit', true ); 631 | 632 | if ( ! empty( $pricing_data ) ) { 633 | 634 | // Sort by most expensive. 635 | uasort( $pricing_data, array( __CLASS__, 'sort_by_price' ) ); 636 | $reverse_pricing_data = array_reverse( $pricing_data, true ); 637 | 638 | // Slots filled due to item min quantities. 639 | $filled_slots = 0; 640 | 641 | foreach ( $quantities[ 'min' ] as $item_min_qty ) { 642 | $filled_slots += $item_min_qty; 643 | } 644 | } 645 | 646 | // Fill in the remaining box slots with most expensive combination of items. 647 | if ( $filled_slots < $max_qty ) { 648 | 649 | // Fill additional slots. 650 | foreach ( $reverse_pricing_data as $bundled_item_id => $data ) { 651 | 652 | $slots_to_fill = $max_qty - $filled_slots; 653 | 654 | 655 | if ( $filled_slots >= $max_qty ) { 656 | $quantities[ 'max' ][ $bundled_item_id ] = $quantities[ 'min' ][ $bundled_item_id ]; 657 | continue; 658 | } 659 | 660 | $bundled_item = $bundled_items[ $bundled_item_id ]; 661 | 662 | if ( false === $bundled_item->is_purchasable() ) { 663 | continue; 664 | } 665 | 666 | $max_items_to_use = $quantities[ 'max' ][ $bundled_item_id ]; 667 | $min_items_to_use = $quantities[ 'min' ][ $bundled_item_id ]; 668 | 669 | $items_to_use = '' !== $max_items_to_use ? min( $max_items_to_use - $min_items_to_use, $slots_to_fill ) : $slots_to_fill; 670 | 671 | $filled_slots += $items_to_use; 672 | 673 | $quantities[ 'max' ][ $bundled_item_id ] = $quantities[ 'min' ][ $bundled_item_id ] + $items_to_use; 674 | } 675 | } 676 | 677 | $result = $quantities[ 'max' ]; 678 | WC_PB_Helpers::cache_set( 'max_price_quantities_' . $bundle->get_id(), $result ); 679 | } 680 | 681 | return $result; 682 | } 683 | 684 | /** 685 | * When min/max qty constraints are present, require input. 686 | * 687 | * @param bool $requires_input 688 | * @param WC_Product_Bundle $bundle 689 | */ 690 | public static function min_max_bundle_requires_input( $requires_input, $bundle ) { 691 | 692 | $min_qty = $bundle->get_meta( '_wcpb_min_qty_limit', true ); 693 | $max_qty = $bundle->get_meta( '_wcpb_max_qty_limit', true ); 694 | 695 | if ( $min_qty || $max_qty ) { 696 | $requires_input = true; 697 | } 698 | 699 | return $requires_input; 700 | } 701 | 702 | /** 703 | * Sort array data by price. 704 | * 705 | * @param array $a 706 | * @param array $b 707 | * @return -1|0|1 708 | */ 709 | private static function sort_by_price( $a, $b ) { 710 | 711 | if ( $a[ 'price' ] == $b[ 'price' ] ) { 712 | return 0; 713 | } 714 | 715 | return ( $a[ 'price' ] < $b[ 'price' ] ) ? -1 : 1; 716 | } 717 | } 718 | 719 | WC_PB_Min_Max_Items::init(); 720 | -------------------------------------------------------------------------------- /readme.txt: -------------------------------------------------------------------------------- 1 | === Product Bundles - Min/Max Items === 2 | 3 | Contributors: franticpsyx, SomewhereWarm 4 | Tags: woocommerce, product, bundles, bundled, quantity, min, max, item, items, count, restrict, limit 5 | Requires at least: 4.4 6 | Tested up to: 5.5 7 | Requires PHP: 5.6 8 | Stable tag: 1.4.3 9 | WC requires at least: 3.1 10 | WC tested up to: 4.5 11 | License: GNU General Public License v3.0 12 | License URI: http://www.gnu.org/licenses/gpl-3.0.html 13 | 14 | Free mini-extension for WooCommerce Product Bundles that allows you to control the minimum or maximum quantity of bundled products that customers must choose in order to purchase a Product Bundle. 15 | 16 | 17 | == Description == 18 | 19 | **Product Bundles - Min/Max Items** has been rolled into [WooCommerce Product Bundles](https://woocommerce.com/products/product-bundles/). If you have updated to Product Bundles version 6.4.0 or newer, you don't need this plugin! 20 | 21 | **Important**: This plugin is a free add-on for the official [WooCommerce Product Bundles](https://woocommerce.com/products/product-bundles/) extension. It was created to validate and refine a feature that is now included with Product Bundles. 22 | 23 | 24 | == Documentation == 25 | 26 | Up until version 6.4.0, [WooCommerce Product Bundles](https://woocommerce.com/products/product-bundles/) did not include any options for selling fixed- or variable-size bundles, such as cases of wine, six-packs of soap, or candy boxes. 27 | 28 | This feature plugin adds two new options under **Product Data > Bundled Products**: 29 | 30 | * **Items required (≥)**; and 31 | * **Items allowed (≤)**. 32 | 33 | Use these to set a minimum or maximum quantity of bundled items that customers must choose to make a purchase. 34 | 35 | 36 | == Installation == 37 | 38 | This plugin works requires the official [WooCommerce Product Bundles](https://woocommerce.com/products/product-bundles/) extension to work. Please do not install this if you are using Product Bundles version 6.4.0+. 39 | 40 | 41 | == Screenshots == 42 | 43 | 1. A pick-and-mix Product Bundle. 44 | 2. Setting the minimum or maximum quantity of products that customers must choose in a Product Bundle. 45 | 46 | 47 | == Changelog == 48 | 49 | = 1.4.3 = 50 | * Tweak - Declared support for WooCommerce 4.5 and WordPress 5.5. 51 | 52 | = 1.4.2 = 53 | * Tweak - Declared support for WooCommerce 4.2. 54 | 55 | = 1.4.0 = 56 | * Important - Renamed plugin to comply with WordPress.org guidelines. 57 | 58 | = 1.3.6 = 59 | * Tweak - Declared support for WP 5.3 and WooCommerce 3.9. 60 | 61 | = 1.3.5 = 62 | * Tweak - Updated supported WP/WC versions. 63 | 64 | = 1.3.4 = 65 | * Tweak - Removed admin options wrapper div. 66 | 67 | = 1.3.3 = 68 | * Tweak - Declare WC 3.5 support. 69 | 70 | = 1.3.2 = 71 | * Tweak - Fixed an incorrect gettext string in validation messages. 72 | * Tweak - Added WC 3.3 support. 73 | 74 | = 1.3.1 = 75 | * Tweak - Updated plugin headers. 76 | * Tweak - Renamed 'Bundled Products' tab option labels. 77 | 78 | = 1.3.0 = 79 | * Fix - Cart validation. 80 | * Tweak - Re-designed validation messages. 81 | * Tweak - Updated validation message strings. 82 | 83 | = 1.2.0 = 84 | * Important - Product Bundles v5.5+ required. 85 | * Fix - Product Bundles v5.5 compatibility. 86 | 87 | = 1.1.1 = 88 | * Fix - Add-to-cart validation failure when bundle quantity > 1. 89 | 90 | = 1.1.0 = 91 | * Fix - WooCommerce v3.0 support. 92 | * Fix - Product Bundles v5.2 support. 93 | * Important - Product Bundles v5.1 support dropped. 94 | 95 | = 1.0.6 = 96 | * Fix - Product Bundles v5.0 support. 97 | 98 | = 1.0.5 = 99 | * Fix - Load plugin textdomain on init. 100 | 101 | = 1.0.4 = 102 | * Fix - Composite Products v3.6 support. 103 | * Fix - Product Bundles v4.14 support. Fix validation notices not displaying on first page load. Requires Product Bundles v4.14.3+. 104 | 105 | = 1.0.3 = 106 | * Fix - Composite Products support. 107 | 108 | = 1.0.2 = 109 | * Tweak - Bundles with min/max constraints require input: 'Add to cart' button text and behaviour changed. 110 | 111 | = 1.0.1 = 112 | * Fix - Accurate 'from:' price calculation based on the defined qty constraints. 113 | 114 | = 1.0.0 = 115 | * Initial Release. 116 | 117 | 118 | 119 | == Upgrade Notice == 120 | 121 | = 1.4.3 = 122 | Declared support for WooCommerce 4.5 and WordPress 5.5. 123 | --------------------------------------------------------------------------------