Skip to content

Commit 62a2f5f

Browse files
mjoslynclaude
andcommitted
Add multi-select roles/customers to bulk & customer-specific pricing
Bulk quantity breaks now target multiple roles per row, and customer-specific prices target multiple customers per row. On save each row fans out to one stored entry per role/customer; on render they regroup by (range+price) / price into a single multi-value row. Storage shape and PriceEngine reads are unchanged, so this is backward-compatible with no migration. Bulk render now also surfaces WP-role / "MSRP Customer" targeted breaks (previously hidden). Bump version to 0.5.2 to bust the cached admin JS/CSS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3b461ab commit 62a2f5f

3 files changed

Lines changed: 129 additions & 65 deletions

File tree

src/Admin/ProductMeta.php

Lines changed: 106 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -428,23 +428,31 @@ private function render_bulk_pricing_group( $product_id ) {
428428
$stored = get_post_meta( $product_id, $meta_key, true );
429429
$stored = is_array( $stored ) ? $stored : array();
430430

431-
// Flatten the { tier => rows } map to { role, min_qty, price } rows.
432-
$rows = array();
433-
foreach ( $stored as $tier_key => $tier_rows ) {
434-
if ( ! isset( $tiers[ $tier_key ] ) || ! is_array( $tier_rows ) ) {
431+
// Flatten the { role => rows } map, then group breaks that share the same
432+
// quantity range and price so one row can carry every role they apply to
433+
// (a break is stored once per role; multi-role rows fan out on save).
434+
$groups = array();
435+
foreach ( $stored as $role_key => $role_rows ) {
436+
if ( ! is_array( $role_rows ) ) {
435437
continue;
436438
}
437-
foreach ( $tier_rows as $row ) {
439+
foreach ( $role_rows as $row ) {
438440
if ( ! is_array( $row ) || ! isset( $row['min_qty'], $row['price'] ) ) {
439441
continue;
440442
}
441-
$max = isset( $row['max_qty'] ) ? (int) $row['max_qty'] : 0;
442-
$rows[] = array(
443-
'role' => $tier_key,
444-
'min_qty' => (string) (int) $row['min_qty'],
445-
'max_qty' => $max > 0 ? (string) $max : '',
446-
'price' => (string) $row['price'],
447-
);
443+
$max = isset( $row['max_qty'] ) && (int) $row['max_qty'] > 0 ? (string) (int) $row['max_qty'] : '';
444+
$min = (string) (int) $row['min_qty'];
445+
$price = (string) $row['price'];
446+
$sig = $min . '|' . $max . '|' . $price;
447+
if ( ! isset( $groups[ $sig ] ) ) {
448+
$groups[ $sig ] = array(
449+
'roles' => array(),
450+
'min_qty' => $min,
451+
'max_qty' => $max,
452+
'price' => $price,
453+
);
454+
}
455+
$groups[ $sig ]['roles'][] = (string) $role_key;
448456
}
449457
}
450458
?>
@@ -455,14 +463,14 @@ private function render_bulk_pricing_group( $product_id ) {
455463
<div data-repeater-list>
456464
<?php
457465
$index = 0;
458-
foreach ( $rows as $row ) {
459-
$this->render_bulk_row( (string) $index, $row['role'], $row['min_qty'], $row['max_qty'], $row['price'] );
466+
foreach ( $groups as $group ) {
467+
$this->render_bulk_row( (string) $index, $group['roles'], $group['min_qty'], $group['max_qty'], $group['price'] );
460468
$index++;
461469
}
462470
?>
463471
</div>
464472
<template data-repeater-template>
465-
<?php $this->render_bulk_row( '__INDEX__', '', '', '', '' ); ?>
473+
<?php $this->render_bulk_row( '__INDEX__', array(), '', '', '' ); ?>
466474
</template>
467475
<p class="form-field">
468476
<button type="button" class="button" data-repeater-add><?php esc_html_e( 'Add quantity break', 'wc-pricebook' ); ?></button>
@@ -488,7 +496,7 @@ private function render_bulk_pricing_group( $product_id ) {
488496
* Any stored value not otherwise present is appended so an existing row never
489497
* silently changes on save.
490498
*
491-
* @param string $selected Currently stored value for the row.
499+
* @param string|array<int,string> $selected Currently stored value(s) for the row.
492500
* @return array<string,string> value => label.
493501
*/
494502
private function bulk_target_options( $selected = '' ) {
@@ -498,8 +506,11 @@ private function bulk_target_options( $selected = '' ) {
498506
$options[ $key ] = $label;
499507
}
500508
}
501-
if ( '' !== (string) $selected && ! isset( $options[ (string) $selected ] ) ) {
502-
$options[ (string) $selected ] = (string) $selected;
509+
foreach ( (array) $selected as $sel ) {
510+
$sel = (string) $sel;
511+
if ( '' !== $sel && ! isset( $options[ $sel ] ) ) {
512+
$options[ $sel ] = $sel;
513+
}
503514
}
504515
return $options;
505516
}
@@ -519,19 +530,19 @@ private function is_valid_bulk_target( $key, array $tiers ) {
519530
return function_exists( 'wp_roles' ) && wp_roles()->is_role( $key );
520531
}
521532

522-
private function render_bulk_row( $index, $role, $min_qty, $max_qty, $price ) {
523-
$base = 'pricebook_bulk_price[' . $index . ']';
533+
private function render_bulk_row( $index, array $roles, $min_qty, $max_qty, $price ) {
534+
$base = 'pricebook_bulk_price[' . $index . ']';
535+
$roles = array_map( 'strval', $roles );
524536
?>
525537
<div class="wc-pricebook-repeater__item" data-repeater-item>
526538
<?php $this->card_header( __( 'Remove quantity break', 'wc-pricebook' ) ); ?>
527539
<div class="wc-pricebook-repeater__body">
528540
<div class="wc-pricebook-repeater__grid">
529541
<div class="wc-pricebook-field wc-pricebook-field--full">
530-
<label><?php esc_html_e( 'Role', 'wc-pricebook' ); ?></label>
531-
<select name="<?php echo esc_attr( $base . '[role]' ); ?>">
532-
<option value=""><?php esc_html_e( '— Select —', 'wc-pricebook' ); ?></option>
533-
<?php foreach ( $this->bulk_target_options( $role ) as $key => $label ) : ?>
534-
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $key, $role ); ?>><?php echo esc_html( $label ); ?></option>
542+
<label><?php esc_html_e( 'Roles', 'wc-pricebook' ); ?></label>
543+
<select multiple class="wc-enhanced-select" name="<?php echo esc_attr( $base . '[role][]' ); ?>" style="width:100%;" data-placeholder="<?php esc_attr_e( 'Select roles&hellip;', 'wc-pricebook' ); ?>">
544+
<?php foreach ( $this->bulk_target_options( $roles ) as $key => $label ) : ?>
545+
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( in_array( (string) $key, $roles, true ) ); ?>><?php echo esc_html( $label ); ?></option>
535546
<?php endforeach; ?>
536547
</select>
537548
</div>
@@ -570,25 +581,44 @@ private function render_user_pricing_group( $product_id ) {
570581

571582
$rows = get_post_meta( $product_id, $meta_key, true );
572583
$rows = is_array( $rows ) ? $rows : array();
584+
585+
// Group stored { user-id, price } entries by price so a single row can carry
586+
// every customer that shares it (a price is stored once per customer; multi-
587+
// customer rows fan out on save).
588+
$groups = array();
589+
foreach ( $rows as $row ) {
590+
if ( ! is_array( $row ) || ! isset( $row['user-id'] ) ) {
591+
continue;
592+
}
593+
$uid = (int) $row['user-id'];
594+
if ( $uid <= 0 ) {
595+
continue;
596+
}
597+
$price = isset( $row['price'] ) ? (string) $row['price'] : '';
598+
if ( ! isset( $groups[ $price ] ) ) {
599+
$groups[ $price ] = array(
600+
'users' => array(),
601+
'price' => $price,
602+
);
603+
}
604+
$groups[ $price ]['users'][] = $uid;
605+
}
573606
?>
574607
<div class="options_group">
575608
<p class="form-field wc-pricebook-group-title"><strong><?php esc_html_e( 'Customer-specific prices', 'wc-pricebook' ); ?></strong></p>
576-
<p class="form-field"><?php esc_html_e( 'A price set here for a customer overrides every tier and role price for that customer.', 'wc-pricebook' ); ?></p>
609+
<p class="form-field"><?php esc_html_e( 'A price set here overrides every tier and role price for the selected customers.', 'wc-pricebook' ); ?></p>
577610
<div class="wc-pricebook-user-prices" data-repeater data-repeater-kind="user-price" data-currency="<?php echo esc_attr( $this->currency_symbol() ); ?>">
578611
<div data-repeater-list>
579612
<?php
580613
$index = 0;
581-
foreach ( $rows as $row ) {
582-
if ( ! is_array( $row ) || ! isset( $row['user-id'] ) ) {
583-
continue;
584-
}
585-
$this->render_user_row( (string) $index, (int) $row['user-id'], isset( $row['price'] ) ? (string) $row['price'] : '' );
614+
foreach ( $groups as $group ) {
615+
$this->render_user_row( (string) $index, $group['users'], $group['price'] );
586616
$index++;
587617
}
588618
?>
589619
</div>
590620
<template data-repeater-template>
591-
<?php $this->render_user_row( '__INDEX__', 0, '' ); ?>
621+
<?php $this->render_user_row( '__INDEX__', array(), '' ); ?>
592622
</template>
593623
<p class="form-field">
594624
<button type="button" class="button" data-repeater-add><?php esc_html_e( 'Add customer price', 'wc-pricebook' ); ?></button>
@@ -606,27 +636,33 @@ private function render_user_pricing_group( $product_id ) {
606636
* @param string $price Price value.
607637
* @return void
608638
*/
609-
private function render_user_row( $index, $user_id, $price ) {
639+
private function render_user_row( $index, array $user_ids, $price ) {
610640
$base = 'pricebook_user_price[' . $index . ']';
611-
$user = $user_id > 0 ? get_userdata( $user_id ) : false;
612641
?>
613642
<div class="wc-pricebook-repeater__item" data-repeater-item>
614643
<?php $this->card_header( __( 'Remove customer price', 'wc-pricebook' ) ); ?>
615644
<div class="wc-pricebook-repeater__body">
616645
<div class="wc-pricebook-repeater__grid">
617646
<div class="wc-pricebook-field wc-pricebook-field--full">
618-
<label><?php esc_html_e( 'Customer', 'wc-pricebook' ); ?></label>
647+
<label><?php esc_html_e( 'Customers', 'wc-pricebook' ); ?></label>
619648
<select
649+
multiple
620650
class="wc-customer-search"
621-
name="<?php echo esc_attr( $base . '[user-id]' ); ?>"
622-
data-placeholder="<?php esc_attr_e( 'Search for a customer&hellip;', 'wc-pricebook' ); ?>"
651+
name="<?php echo esc_attr( $base . '[user-id][]' ); ?>"
652+
data-placeholder="<?php esc_attr_e( 'Search for customers&hellip;', 'wc-pricebook' ); ?>"
623653
data-allow_clear="true"
624654
style="width:100%;">
625-
<?php if ( $user ) : ?>
626-
<option value="<?php echo esc_attr( (string) $user_id ); ?>" selected="selected">
627-
<?php echo esc_html( sprintf( '%1$s (#%2$d &ndash; %3$s)', $user->display_name, $user_id, $user->user_email ) ); ?>
655+
<?php
656+
foreach ( $user_ids as $user_id ) :
657+
$user = (int) $user_id > 0 ? get_userdata( (int) $user_id ) : false;
658+
if ( ! $user ) :
659+
continue;
660+
endif;
661+
?>
662+
<option value="<?php echo esc_attr( (string) (int) $user_id ); ?>" selected="selected">
663+
<?php echo esc_html( sprintf( '%1$s (#%2$d &ndash; %3$s)', $user->display_name, (int) $user_id, $user->user_email ) ); ?>
628664
</option>
629-
<?php endif; ?>
665+
<?php endforeach; ?>
630666
</select>
631667
</div>
632668
<div class="wc-pricebook-field">
@@ -793,28 +829,36 @@ private function save_bulk_pricing( $post_id ) {
793829
$tiers = $this->config->tiers();
794830

795831
// Group by role, keyed within a role by quantity so a repeated quantity for
796-
// the same role collapses to the last row entered.
832+
// the same role collapses to the last row entered. A row may target several
833+
// roles at once (role[]); it fans out to one stored break per role.
797834
$by_role = array();
798835
foreach ( $rows as $row ) {
799836
if ( ! is_array( $row ) ) {
800837
continue;
801838
}
802-
$role = isset( $row['role'] ) ? sanitize_key( $row['role'] ) : '';
839+
$roles = isset( $row['role'] ) ? (array) $row['role'] : array();
840+
$roles = array_values( array_unique( array_filter( array_map( 'sanitize_key', $roles ) ) ) );
803841
$qty = isset( $row['min_qty'] ) ? absint( $row['min_qty'] ) : 0;
804842
$max = isset( $row['max_qty'] ) ? absint( $row['max_qty'] ) : 0;
805843
$price = isset( $row['price'] ) ? trim( (string) $row['price'] ) : '';
806844
// Drop incomplete rows and inverted ranges (a set max below the min).
807-
if ( '' === $role || ! $this->is_valid_bulk_target( $role, $tiers ) || $qty < 1 || '' === $price ) {
845+
if ( empty( $roles ) || $qty < 1 || '' === $price ) {
808846
continue;
809847
}
810848
if ( $max > 0 && $max < $qty ) {
811849
continue;
812850
}
813-
$by_role[ $role ][ $qty ] = array(
814-
'min_qty' => $qty,
815-
'max_qty' => $max,
816-
'price' => $this->format( $price ),
817-
);
851+
$formatted = $this->format( $price );
852+
foreach ( $roles as $role ) {
853+
if ( ! $this->is_valid_bulk_target( $role, $tiers ) ) {
854+
continue;
855+
}
856+
$by_role[ $role ][ $qty ] = array(
857+
'min_qty' => $qty,
858+
'max_qty' => $max,
859+
'price' => $formatted,
860+
);
861+
}
818862
}
819863

820864
if ( empty( $by_role ) ) {
@@ -851,20 +895,26 @@ private function save_user_pricing( $post_id ) {
851895
: array();
852896

853897
// Keyed by user id so a duplicate customer collapses to the last row entered.
898+
// A row may target several customers at once (user-id[]); it fans out to one
899+
// stored entry per customer, all sharing the row's price.
854900
$by_user = array();
855901
foreach ( $rows as $row ) {
856902
if ( ! is_array( $row ) ) {
857903
continue;
858904
}
859-
$user_id = isset( $row['user-id'] ) ? absint( $row['user-id'] ) : 0;
860-
$price = isset( $row['price'] ) ? trim( (string) $row['price'] ) : '';
861-
if ( $user_id <= 0 || '' === $price ) {
905+
$user_ids = isset( $row['user-id'] ) ? (array) $row['user-id'] : array();
906+
$user_ids = array_values( array_unique( array_filter( array_map( 'absint', $user_ids ) ) ) );
907+
$price = isset( $row['price'] ) ? trim( (string) $row['price'] ) : '';
908+
if ( empty( $user_ids ) || '' === $price ) {
862909
continue;
863910
}
864-
$by_user[ $user_id ] = array(
865-
'user-id' => $user_id,
866-
'price' => $this->format( $price ),
867-
);
911+
$formatted = $this->format( $price );
912+
foreach ( $user_ids as $user_id ) {
913+
$by_user[ $user_id ] = array(
914+
'user-id' => $user_id,
915+
'price' => $formatted,
916+
);
917+
}
868918
}
869919

870920
if ( empty( $by_user ) ) {

src/Admin/assets/settings.js

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@
107107
var els = item.querySelectorAll( '[name]' );
108108
for ( var i = 0; i < els.length; i++ ) {
109109
var name = els[ i ].getAttribute( 'name' ) || '';
110-
if ( name.slice( -suffix.length ) === suffix ) {
110+
// Match a plain field ("[role]") or its multi-value array form ("[role][]").
111+
if ( name.slice( -suffix.length ) === suffix || name.slice( -( suffix.length + 2 ) ) === suffix + '[]' ) {
111112
return els[ i ];
112113
}
113114
}
@@ -128,16 +129,29 @@
128129
return Object.prototype.hasOwnProperty.call( spec.map, val ) ? spec.map[ val ] : '';
129130
}
130131
if ( spec.select && el.tagName === 'SELECT' ) {
132+
var cut = function ( text ) {
133+
if ( spec.cut && text.indexOf( spec.cut ) !== -1 ) {
134+
text = text.split( spec.cut )[ 0 ];
135+
}
136+
return text.trim();
137+
};
138+
// Multi-select: join the selected option labels (empty when nothing chosen).
139+
if ( el.multiple ) {
140+
var texts = [];
141+
Array.prototype.forEach.call( el.selectedOptions || [], function ( opt ) {
142+
var t = cut( opt.text || '' );
143+
if ( t ) {
144+
texts.push( t );
145+
}
146+
} );
147+
return texts.join( ', ' );
148+
}
131149
// An empty selection (e.g. a "— Select —" placeholder) reads as no value.
132150
if ( val === '' || val == null ) {
133151
return '';
134152
}
135-
var opt = el.options[ el.selectedIndex ];
136-
var text = opt ? opt.text : '';
137-
if ( spec.cut && text.indexOf( spec.cut ) !== -1 ) {
138-
text = text.split( spec.cut )[ 0 ];
139-
}
140-
return text.trim();
153+
var opt = el.options[ el.selectedIndex ];
154+
return cut( opt ? opt.text : '' );
141155
}
142156
if ( spec.upper ) {
143157
return val ? String( val ).toUpperCase() : '';

wc-pricebook.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Plugin Name: WC Pricebook
44
* Plugin URI: https://github.com/mjoslyn/wc-pricebook
55
* Description: Role/tier-based pricing engine for WooCommerce. Configurable price tiers, rule-based product behaviors, and a manager pricing-view switcher.
6-
* Version: 0.5.1
6+
* Version: 0.5.2
77
* Author: Robot of the Future
88
* Author URI: https://github.com/mjoslyn
99
* License: GPL-2.0-or-later
@@ -23,7 +23,7 @@
2323
exit;
2424
}
2525

26-
define( 'WC_PRICEBOOK_VERSION', '0.5.1' );
26+
define( 'WC_PRICEBOOK_VERSION', '0.5.2' );
2727
define( 'WC_PRICEBOOK_FILE', __FILE__ );
2828
define( 'WC_PRICEBOOK_DIR', plugin_dir_path( __FILE__ ) );
2929
define( 'WC_PRICEBOOK_URL', plugin_dir_url( __FILE__ ) );

0 commit comments

Comments
 (0)