*/ function addon_boss_personalisation_field_types(): array { $own = array( 'text' => array( 'label' => __( 'Text', 'addon-boss' ), 'per_char' => true, 'menu' => __( 'Name or text to print', 'addon-boss' ) ), 'textarea' => array( 'label' => __( 'Text area', 'addon-boss' ), 'per_char' => true, 'menu' => __( 'Message', 'addon-boss' ) ), 'number' => array( 'label' => __( 'Number', 'addon-boss' ), 'menu' => __( 'Number', 'addon-boss' ) ), 'date' => array( 'label' => __( 'Date', 'addon-boss' ) ), 'time' => array( 'label' => __( 'Time', 'addon-boss' ) ), 'select' => array( 'label' => __( 'Dropdown', 'addon-boss' ), 'has_options' => true ), 'checkbox' => array( 'label' => __( 'Tick box', 'addon-boss' ), 'menu' => __( 'Tick box add-on', 'addon-boss' ) ), 'heading' => array( 'label' => __( 'Heading', 'addon-boss' ), 'display_only' => true, 'menu' => __( 'Heading', 'addon-boss' ) ), 'html' => array( 'label' => __( 'Info / HTML block', 'addon-boss' ), 'display_only' => true, 'menu' => __( 'Info / HTML block', 'addon-boss' ) ), ); return (array) apply_filters( 'addon_boss_field_types', $own ); } /** * The fee modes a field can charge by, as mode => menu wording. The engine's * own two are a flat fee for each item and a fee charged once per order line; * richer modes register from the module that implements their maths, which * answers the addon_boss_fee_modes_fee filter with the amount they resolve to. * * A stored mode nothing registers charges nothing and is never rewritten, so * the field keeps its input and its price comes back with the module. * * @return array */ function addon_boss_fee_modes(): array { $own = array( 'unit' => __( 'for each', 'addon-boss' ), 'once' => __( 'once', 'addon-boss' ), ); return (array) apply_filters( 'addon_boss_fee_modes', $own ); } /** * Sanitise raw personalisation field-def rows (the admin repeater's POST shape) * into stored field definitions. The ONE sanitiser shared by the per-item save * and the whole-order save, so both apply identical rules (types, labels, * unique keys, requirement, fees, options, limits). * * Idempotent on its own output: re-sanitising a STORED def (which carries * required/require_when instead of the raw `require`) preserves the requirement, * so an import or a wizard-edit round trip never drops it. Raw rows carry * neither key, so per-item save behaviour is byte-identical. * * @param array $raw_addons Raw field rows. * @return array[] Clean stored field definitions. */ function addon_boss_personalisation_sanitize_field_defs( array $raw_addons ): array { $types = addon_boss_personalisation_field_types(); $clean = array(); $used = array(); foreach ( $raw_addons as $f ) { if ( ! is_array( $f ) ) { continue; } $type = sanitize_key( $f['type'] ?? '' ); if ( '' === $type ) { continue; } // A type this build has no module for is stored, not judged: the shared // core below is sanitised as usual and, at the end, every other key the // row carries rides through as submitted. What arrives here has already // been leaf-sanitised at the request boundary, and nothing on this build // ever renders, prices or requires such a field, so the config simply // waits, whole, for the module that owns it. $known = isset( $types[ $type ] ); $label = sanitize_text_field( $f['label'] ?? '' ); // An info/HTML block carries its body in `content` and can stand with no // label (the body speaks for itself), so it survives the empty-label drop. $content = ( 'html' === $type ) ? wp_kses_post( (string) ( $f['content'] ?? '' ) ) : ''; if ( '' === $label && '' === $content ) { continue; // a field with nothing to show is meaningless } // Stable, unique key per item. Prefer a submitted key, else derive. $key = sanitize_title( $f['key'] ?? '' ) ?: sanitize_title( $label ); if ( '' === $key ) { $key = 'field'; } $base = $key; $n = 2; while ( isset( $used[ $key ] ) ) { $key = $base . '-' . $n++; } $used[ $key ] = true; $def = array( 'key' => $key, 'label' => $label, 'type' => $type, ); if ( ! empty( $types[ $type ]['display_only'] ) ) { if ( 'html' === $type && '' !== $content ) { $def['content'] = $content; // the sanitised HTML body } // An info block's label does two jobs: it names the row in the editor // and, until a merchant says otherwise, it heads the body on the page. // Those come apart here, because plenty of blocks are a sentence of // guidance with nothing to head. // // HIDING is the key that has to be present, never showing. The editor // posts show_title through a hidden zero paired with the tick box, so // unticking arrives as a fact; a STORED def carries hide_title and no // show_title at all, and reading that absence as "hide" would strip the // heading off every block an import or a wizard edit passes through. if ( 'html' === $type ) { $bb_hide_title = ! empty( $f['hide_title'] ) || ( isset( $f['show_title'] ) && ! (int) $f['show_title'] ); if ( $bb_hide_title ) { $def['hide_title'] = true; } } $clean[] = $def; // heading / info block: nothing else to store continue; } $def['scope'] = ( ( $f['scope'] ?? 'unit' ) === 'item' ) ? 'item' : 'unit'; // Requirement: optional (default) / always / only once personalising. A // re-sanitised STORED def carries required/require_when instead of the raw // `require`; derive from them so an import or wizard-edit round trip keeps // the rule. Raw rows have neither key, so per-item save is byte-identical. $require = $f['require'] ?? ''; if ( '' === $require && ! empty( $f['required'] ) ) { $require = 'always'; } elseif ( '' === $require && ( $f['require_when'] ?? '' ) === 'engaged' ) { $require = 'engaged'; } $require = in_array( $require, array( 'always', 'engaged' ), true ) ? $require : ''; $def['required'] = ( 'always' === $require ); $def['require_when'] = ( 'engaged' === $require ) ? 'engaged' : ''; $def['fee'] = max( 0.0, (float) wc_format_decimal( (string) ( $f['fee'] ?? 0 ) ) ); // The mode is kept as stored even when this build has no module for it: // an unregistered mode charges nothing (the fee resolver has no maths to // answer with), and a save must never reprice a field to "for each" // just because the module that priced it is not installed today. $bb_fee_mode = sanitize_key( (string) ( $f['fee_mode'] ?? '' ) ); $def['fee_mode'] = '' === $bb_fee_mode ? 'unit' : $bb_fee_mode; // Repeatable: the shopper fills this field more than once on one line. // 'add' lets them add as many as they want (up to a maximum); 'per_item' // asks once for every unit bought, so a run of six shirts asks for six // names. Anything else, including the absent key every field built before // this carries, means the field asks exactly once, as it always has. // // Stored WITHOUT asking whether the plan is active, deliberately: gating // the save would rewrite a merchant's configuration the moment a licence // lapsed and a field was re-saved. The capability is asked at the two // places it can be honoured, the editor's panel and the storefront, so a // free build asks once and leaves the setting alone for the plan's return. $repeat = (string) ( $f['repeat'] ?? '' ); if ( in_array( $repeat, array( 'add', 'per_item' ), true ) ) { $def['repeat'] = $repeat; // A ceiling on 'add' so a shopper cannot open a thousand cards. Absent // or nonsense means the built-in ceiling, never unlimited. $rmax = (int) ( $f['repeat_max'] ?? 0 ); if ( $rmax > 0 ) { $def['repeat_max'] = min( ADDON_BOSS_REPEAT_CEILING, $rmax ); } // What each one is called on the page, in the cart and on the order: // "Shirt %d", "Name %d". The %d is filled with the instance number; a // label without one still reads sensibly because the number is // appended when it is missing. $rlabel = sanitize_text_field( (string) ( $f['repeat_label'] ?? '' ) ); if ( '' !== $rlabel ) { $def['repeat_label'] = $rlabel; } } // Which variations this field applies to. Empty means every one, which is // what every field built before this existed resolves to, so nothing has // to be migrated and a simple product is unaffected. $only = array(); foreach ( (array) ( $f['only_variations'] ?? array() ) as $vid ) { $vid = absint( $vid ); if ( $vid && ! in_array( $vid, $only, true ) ) { $only[] = $vid; } } if ( $only ) { $def['only_variations'] = $only; } if ( in_array( $type, array( 'text', 'textarea' ), true ) ) { $def['maxlength'] = max( 0, (int) ( $f['maxlength'] ?? 0 ) ); } // A field's on-photo placement is the drawing module's data: it registers // the sanitiser for its own config on this seam. With nothing registered a // stored placement rides through a save untouched (the row reposts it // whole), so it returns with its module. $bb_preview = apply_filters( 'addon_boss_sanitize_field_preview', is_array( $f['preview'] ?? null ) ? $f['preview'] : array(), $f, $type ); if ( is_array( $bb_preview ) && $bb_preview ) { $def['preview'] = $bb_preview; } if ( ! empty( $types[ $type ]['has_options'] ) ) { $def['options'] = addon_boss_personalisation_parse_options( $f['options'] ?? '' ); } // A type's own extra config (the unit a size is measured in, an upload // field's accept rules) is sanitised by the callback its module // registered alongside the type. if ( $known && is_callable( $types[ $type ]['sanitize_def'] ?? null ) ) { $def = (array) call_user_func( $types[ $type ]['sanitize_def'], $def, $f ); } if ( ! $known ) { // The rest of an unregistered type's row is that module's data, not // ours to rewrite; it rides through as submitted, minus the raw-only // control keys the shared core above already consumed. $def = array_merge( array_diff_key( $f, array( 'require' => 1, 'label' => 1, 'key' => 1, 'type' => 1, 'fee' => 1, 'fee_mode' => 1, 'scope' => 1, 'required' => 1, 'require_when' => 1, 'only_variations' => 1, 'maxlength' => 1, 'preview' => 1, 'options' => 1, 'content' => 1, ) ), $def ); } $clean[] = $def; } return $clean; } /** * Sanitise the structured option rows from the admin repeater. Each row is * [ label, fee ]; the stored value (slug) is derived from the label so the * merchant never types one. Keys are kept unique within the field. * * @return array */ function addon_boss_personalisation_parse_options( $raw ): array { $out = array(); if ( ! is_array( $raw ) ) { return $out; } $used = array(); foreach ( $raw as $row ) { if ( ! is_array( $row ) ) { continue; } $label = sanitize_text_field( $row['label'] ?? '' ); if ( '' === $label ) { continue; } $value = sanitize_title( $row['value'] ?? '' ) ?: sanitize_title( $label ); if ( '' === $value ) { $value = 'option'; } $base = $value; $n = 2; while ( isset( $used[ $value ] ) ) { $value = $base . '-' . $n++; } $used[ $value ] = true; $out[] = array( 'value' => $value, 'label' => $label, 'fee' => max( 0.0, (float) wc_format_decimal( (string) ( $row['fee'] ?? 0 ) ) ), ); } return $out; } // ================================================================= // Value + fee resolution (the money math). Lifted UNCHANGED from // the earlier bundle-side layout; shared by both surfaces. // ================================================================= /** * Sanitise a raw submitted value against a field definition. * * @return array{value:string,display:string,opt_fee?:float}|null null = empty/invalid. */ function addon_boss_personalisation_resolve_value( array $def, $raw ) { $bb_type = (string) ( $def['type'] ?? 'text' ); $bb_types = addon_boss_personalisation_field_types(); // A type that registered its own resolver (a measured size, an upload) // answers through it. A type nothing registered resolves to nothing, the // same as any other value the engine has no code to read. if ( is_callable( $bb_types[ $bb_type ]['resolve'] ?? null ) ) { return call_user_func( $bb_types[ $bb_type ]['resolve'], $def, $raw ); } switch ( $bb_type ) { case 'textarea': $val = trim( sanitize_textarea_field( (string) $raw ) ); return '' === $val ? null : array( 'value' => addon_boss_personalisation_clip( $val, $def ), 'display' => addon_boss_personalisation_clip( $val, $def ) ); case 'text': $val = trim( sanitize_text_field( (string) $raw ) ); if ( '' === $val ) { return null; } $val = addon_boss_personalisation_clip( $val, $def ); return array( 'value' => $val, 'display' => $val ); case 'number': if ( null === $raw || '' === $raw || ! is_numeric( $raw ) ) { return null; } $val = (string) ( $raw + 0 ); return array( 'value' => $val, 'display' => $val ); case 'date': // A real calendar date, not merely something date-shaped: 2026-02-31 // passes a regex and fails checkdate, and letting it through would put // an impossible delivery date on a real order. $raw = trim( sanitize_text_field( (string) $raw ) ); if ( ! preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $raw, $dm ) ) { return null; } if ( ! checkdate( (int) $dm[2], (int) $dm[3], (int) $dm[1] ) ) { return null; } // Shown in the store's own date format, stored ISO so it sorts and // travels safely. $fmt = function_exists( 'wc_date_format' ) ? wc_date_format() : get_option( 'date_format', 'Y-m-d' ); return array( 'value' => $raw, 'display' => date_i18n( $fmt, strtotime( $raw . ' 12:00:00' ) ) ); case 'time': $raw = trim( sanitize_text_field( (string) $raw ) ); // Browsers send H:i, and H:i:s when seconds are enabled. if ( ! preg_match( '/^([01]\d|2[0-3]):([0-5]\d)(?::[0-5]\d)?$/', $raw, $tm ) ) { return null; } $hhmm = $tm[1] . ':' . $tm[2]; $tfmt = function_exists( 'wc_time_format' ) ? wc_time_format() : get_option( 'time_format', 'H:i' ); return array( 'value' => $hhmm, 'display' => date_i18n( $tfmt, strtotime( '1970-01-01 ' . $hhmm . ':00' ) ) ); case 'select': $slug = sanitize_title( (string) $raw ); if ( '' === $slug ) { return null; } foreach ( (array) ( $def['options'] ?? array() ) as $opt ) { if ( ( $opt['value'] ?? '' ) === $slug ) { return array( 'value' => $slug, 'display' => $opt['label'] ?? $slug, 'opt_fee' => (float) ( $opt['fee'] ?? 0 ) ); } } return null; // not a defined option case 'checkbox': return empty( $raw ) ? null : array( 'value' => '1', 'display' => __( 'Yes', 'addon-boss' ) ); } return null; } /** * Storefront field polish: keep every capped field's character count in step, and * mark a field the shopper has completed with a quiet edge. * * Pure sugar over the fields that already carry the values, and it degrades to * nothing when absent. File fields are deliberately left out of the done state: * theirs arrives with the upload chip, so an empty file field can never read as * finished. */ function addon_boss_standalone_fields_polish(): void { if ( ! function_exists( 'is_product' ) || ! is_product() ) { return; } ?> ( function () { var root = document.querySelector( '.bb-standalone-options' ); if ( ! root ) { return; } // Delegated from the document, one copy for the page, so an instance // an add-another clones AFTER boot behaves exactly like one the // server rendered. Per-element binding at boot was how a cloned // engraving's count sat frozen at 0/20 while its twin counted. function counterUpd( input ) { var field = input.closest( '.bb-addon-field' ); var c = field ? field.querySelector( '.bb-addon-counter' ) : null; if ( ! c ) { return; } var max = parseInt( c.getAttribute( 'data-bb-max' ), 10 ) || 0; var n = ( input.value || '' ).length; c.textContent = n + '/' + max; // Warn only as the cap arrives, so the count is quiet the rest of the time. c.classList.toggle( 'is-near', !! max && n >= max - 2 ); } function filledOf( g ) { // A file field's done state belongs to its chip, not to this. if ( g.querySelector( '.bb-addon-file-ref' ) ) { return false; } var cb = g.querySelector( 'input[type="checkbox"].bb-addon-input' ); if ( cb ) { return cb.checked; } var input = g.querySelector( '.bb-addon-input' ); return !! ( input && input.value && input.value.trim() ); } function groupSync( g ) { if ( ! g || g.querySelector( '.bb-addon-file-ref' ) ) { return; } g.classList.toggle( 'is-filled', filledOf( g ) ); } function onEdit( e ) { var input = e.target && e.target.classList && e.target.classList.contains( 'bb-addon-input' ) ? e.target : null; if ( ! input || ! root.contains( input ) ) { return; } counterUpd( input ); groupSync( input.closest( '.bb-field-group' ) ); } document.addEventListener( 'input', onEdit ); document.addEventListener( 'change', onEdit ); // First paint: the server may have prefilled any of them. root.querySelectorAll( '.bb-addon-input' ).forEach( function ( input ) { counterUpd( input ); groupSync( input.closest( '.bb-field-group' ) ); } ); } )(); $attrs Attribute map. */ function addon_boss_print_attrs( array $attrs ): void { foreach ( $attrs as $key => $value ) { if ( null === $value || false === $value ) { continue; } if ( true === $value ) { echo ' ' . esc_attr( $key ); continue; } echo ' ' . esc_attr( $key ) . '="' . esc_attr( (string) $value ) . '"'; } } /** * Render one add-on field. data-bb-* attributes let the configurator JS mirror * the fee into the live total. */ function addon_boss_personalisation_render_field( array $def, string $name, $value = null, string $context = 'block' ) { $type = $def['type'] ?? 'text'; $label = $def['label'] ?? ''; $cell = ( 'cell' === $context ); // table-cell mode: no label/wrapper (the column header carries the label). if ( 'heading' === $type ) { if ( ! $cell ) { echo '
' . esc_html( $label ) . '
'; } return; } // An info / HTML block: display only, no input, no fee, no chrome. The body // carries its own markup, so it is emitted as-is (kses-cleaned); an optional // label reads as a plain note above it, unless the merchant has kept the // label for the editor's list and asked for no heading on the page. if ( 'html' === $type ) { if ( ! $cell ) { if ( '' !== (string) $label && empty( $def['hide_title'] ) ) { echo '
' . esc_html( $label ) . '
'; } echo wp_kses_post( (string) ( $def['content'] ?? '' ) ); } return; } $fee = (float) ( $def['fee'] ?? 0 ); $types = addon_boss_personalisation_field_types(); // A select prices by its option, and a type whose charge is its own maths // (a measured size prices by area) wears the mode its registration names, // rather than the fee mode the other types share. $bb_own_mode = (string) ( $types[ $type ]['own_fee_mode'] ?? '' ); $mode = ( 'select' === $type ) ? 'select' : ( '' !== $bb_own_mode ? $bb_own_mode : (string) ( $def['fee_mode'] ?? 'unit' ) ); // A shared mode this build has no maths for charges nothing at add-to-cart, // so the page must not promise a fee either: the shown price and the taken // price are the same fact. if ( 'select' !== $mode && '' === $bb_own_mode && ! isset( addon_boss_fee_modes()[ '' === $mode ? 'unit' : $mode ] ) ) { $fee = 0.0; } $req = ! empty( $def['required'] ); $id = 'bb-add-' . substr( md5( $name ), 0, 8 ); // The input's own data attributes, carried as a map and printed through // addon_boss_print_attrs() so each value is escaped where it lands. // // NOTHING is concatenated in raw here. This used to accept an $extra_attrs // parameter appended straight into the tag; no caller ever passed one, but // an unescaped path is a hole even while it is unreachable. The parameter is // gone rather than sanitised, because a caller-supplied attribute blob has // no legitimate use here. $attr_map = array( 'data-bb-addon' => '1', 'data-bb-fee' => (string) $fee, 'data-bb-fee-mode' => $mode, ); if ( 'select' === $type && 'once' === (string) ( $def['fee_mode'] ?? '' ) ) { // A dropdown's mode attribute stays "select": it is what tells every // reader to price by the CHOSEN option. WHEN it charges follows the // field's own stored mode, and the server spreads a 'once' select's // option fee across the units (one setup charge however many are // bought), so the whole-line fact rides its own marker for the live // total to read. Without it the page multiplied the fee by the // quantity and promised 195.50 where 171.50 was charged. $attr_map['data-bb-fee-once'] = '1'; } if ( $fee > 0 && 'select' !== $mode && '' === $bb_own_mode && ! in_array( $mode, array( 'unit', 'once' ), true ) ) { // A mode whose live maths lives in a module starts at nothing and is // recomputed by that module's script; the flat fallback must not count // the bare rate as the charge. $attr_map['data-bb-live-fee'] = '0'; } if ( 'engaged' === ( $def['require_when'] ?? '' ) ) { $attr_map['data-bb-follower'] = '1'; // a modifier; only counts when an anchor field is filled } // The programmatic half of the required marking: aria-required rides the // CONTROL the shopper uses, so assistive tech announces the requirement at // the field rather than nowhere at all. Only the perceivable controls carry // it (a hidden reference or area carrier has no reader to tell), which is // why it is its own map rather than a line in $attr_map above. $attr_req = $attr_map; if ( $req ) { $attr_req['aria-required'] = 'true'; } if ( ! $cell ) { echo '
'; } if ( 'checkbox' === $type ) { if ( $cell ) { // Bare checkbox; the column header carries the label. printf( ''; return; } printf( ''; echo '
'; return; } if ( ! $cell ) { // A heading right above already wears this field's exact words (the // per-item accordion header carrying "Shirt 1"): the label keeps the // ACCESSIBLE name and leaves the screen, the heading-stutter rule the // group section applies. Never stored; stamped by the caller that // drew the heading. $bb_label_sr = ! empty( $def['_bb_label_sr'] ); // A capped field carries its own count, so a shopper naming a shirt can see // how much room is left instead of typing into a silent stop. printf( ''; if ( $bb_label_sr && '' !== $bb_counter ) { // The room-left count must not leave the screen with the label it // used to ride in; it stands beside the control instead. echo wp_kses( $bb_counter, addon_boss_kses_allowed() ); } } switch ( $type ) { case 'textarea': printf( ''; break; case 'number': printf( ''; break; case 'date': case 'time': printf( ''; break; case 'select': printf( ''; break; case 'text': default: // A type that registered its own input markup renders through it. if ( is_callable( $types[ $type ]['render'] ?? null ) ) { call_user_func( $types[ $type ]['render'], $def, $name, $value, $id, $attr_map ); break; } printf( ''; break; } if ( ! $cell && $fee > 0 ) { // A rate has to say what it is a rate OF, or the shopper reads it as the // whole charge: "+£12.00" on a size field that will actually bill £72 for // six square metres is the exact mismatch this suffix prevents. Each // rate-shaped mode's wording rides with the module that does its maths; // a flat fee needs no qualifier and says nothing. $suffix = (string) apply_filters( 'addon_boss_fee_rate_suffix', '', $mode, $def ); echo ' +' . wp_kses_post( wc_price( $fee ) ) . esc_html( $suffix ) . ''; } if ( ! $cell ) { echo ''; } } // ================================================================= // Option-set store, schema + measured maths. Lifted UNCHANGED from // the earlier bundle-side layout; the option-set library both surfaces draw // from, plus the measured-set pricing maths. // ================================================================= const ADDON_BOSS_OPTION_SETS_STORE = 'bundle_boss_option_sets'; const ADDON_BOSS_PRODUCT_OPTIONS_META = '_bundle_boss_product_options'; /** * The VAT-toggle attributes for a surface that paints its own money. * * 🔴 Whether the Tax Switch plugin runs is decided HERE, server-side, rather * than by sniffing its JS globals. Its wtsCompatibilityObject only exists * where one of ITS OWN compat shims happens to load, and its switch block * loads nothing unless the merchant placed it, while its server side wraps * every static price in the incl/excl pair unconditionally. So on David's * shop (23 Aug) the page was covered in dual prices while the one figure * carrying the option and embroidery money, our live total, painted a single * basis: the pair "only carried the base". The base rate rides along for the * same reason: the compat object that used to supply it is not there to ask. * * Empty when the plugin is absent or the product carries no tax, so every * consumer can echo it unconditionally and nothing changes on shops without * the toggle. Escaped within. */ function addon_boss_wts_attrs( WC_Product $product ): string { if ( ! defined( 'WDEVS_TAX_SWITCH_VERSION' ) || ! function_exists( 'wc_tax_enabled' ) || ! wc_tax_enabled() || 'taxable' !== $product->get_tax_status() ) { return ''; } $rate = array_sum( array_map( 'floatval', array_column( WC_Tax::get_base_tax_rates( $product->get_tax_class() ), 'rate' ) ) ); return ' data-wts="1" data-wts-rate="' . esc_attr( (string) $rate ) . '"'; } // ----------------------------------------------------------------- // Keys // ----------------------------------------------------------------- /** * A stable, immutable key for an option or a set. Generated once at row * creation and preserved by the sanitiser forever after; renaming a label * never changes the key. */ function addon_boss_option_key( string $prefix = 'op' ): string { return $prefix . '_' . substr( md5( uniqid( (string) wp_rand(), true ) ), 0, 10 ); } /** True when a key looks like one of ours (or a sane hand-written slug). */ function addon_boss_option_key_valid( $key ): bool { return is_string( $key ) && '' !== $key && (bool) preg_match( '/^[a-z0-9_\-]{1,40}$/', $key ); } // ----------------------------------------------------------------- // Sets store (CRUD) // ----------------------------------------------------------------- /** * All saved option sets, keyed by set id. * * @return array */ function addon_boss_option_sets(): array { $sets = get_option( ADDON_BOSS_OPTION_SETS_STORE, array() ); if ( ! is_array( $sets ) ) { return array(); } // Stored data is historical fact: an older build wrote table sets with no // options key, and one such set took the whole library screen down with a // TypeError. The reader guarantees the shape every consumer assumes, so a // set written by any past version renders, opens and can be put right. foreach ( $sets as $id => $set ) { if ( ! is_array( $set ) ) { unset( $sets[ $id ] ); continue; } if ( ! isset( $set['options'] ) || ! is_array( $set['options'] ) ) { $sets[ $id ]['options'] = array(); } } return $sets; } /** One saved set, or null. */ function addon_boss_option_set( string $id ): ?array { $sets = addon_boss_option_sets(); return $sets[ $id ] ?? null; } /** * The kinds an option set can be. * * The engine's own kind is 'choices': a named list, each entry with an * optional charge. A kind priced by other means registers here from the module * that implements it, carrying the callbacks the engine calls at each seam: * label the kind picker's wording * desc the sentence under the picker saying what the kind is for * uses_options whether the set's option rows apply (a kind priced off its * own panel says no and the editor hides them) * collect read the kind's own fields from the save form into raw shape * sanitize raw shape to the stored keys to merge over the base set * editor print the kind's panel inside the set editor * snapshot resolve one request against a set of this kind into a cart * snapshot (null = nothing picked, WP_Error = refuse the add) * seed the reverse of snapshot, for the cart edit loop: one frozen * snapshot back into the request fields (name => value) that * would have produced it, so an edited line arrives prefilled. * The engine's own 'choices' kind needs none (its one field * carries the option's key); a kind without one seeds nothing. * render print the set's body on the product page * save_notice something worth saying about the set just saved, or null * * A stored set whose kind nothing registers is INERT: it stays stored and * listed, offers nothing, charges nothing, and is never sanitised away, so it * returns whole the moment the module that owns it is installed. * * @return array */ function addon_boss_option_set_kinds(): array { $own = array( 'choices' => array( 'label' => __( 'a list of choices', 'addon-boss' ), 'desc' => __( 'Named options, each with its own price. Good for colours, finishes, or set sizes like Small, Medium, Large.', 'addon-boss' ), 'uses_options' => true, ), ); return (array) apply_filters( 'addon_boss_option_set_kinds', $own ); } /** * Sanitise a set definition. Options with an empty label are dropped; * existing valid keys are PRESERVED (stability is the contract), missing or * invalid keys are generated; swatches must be real hex colours; deltas are * floats and may be negative. Order is the submitted order. */ function addon_boss_sanitize_option_set( array $raw ): array { $options = array(); foreach ( (array) ( $raw['options'] ?? array() ) as $opt ) { if ( ! is_array( $opt ) ) { continue; } $label = sanitize_text_field( (string) ( $opt['label'] ?? '' ) ); if ( '' === $label ) { continue; } $swatch = sanitize_hex_color( (string) ( $opt['swatch'] ?? '' ) ); // An optional second colour for two-tone options (a "Navy/Red" jacket): // when set, the swatch renders as a split of the two. $swatch2 = sanitize_hex_color( (string) ( $opt['swatch2'] ?? '' ) ); $row = array( 'key' => addon_boss_option_key_valid( $opt['key'] ?? '' ) ? (string) $opt['key'] : addon_boss_option_key(), 'label' => $label, 'swatch' => $swatch ? $swatch : '', 'swatch2' => $swatch2 ? $swatch2 : '', // An optional short note shown when the shopper picks this option // (e.g. "Print protection" -> "adds a clear laminate..."). 'desc' => sanitize_text_field( (string) ( $opt['desc'] ?? '' ) ), 'image_id' => absint( $opt['image_id'] ?? 0 ), 'delta' => (float) wc_format_decimal( (string) ( $opt['delta'] ?? '0' ) ), 'dims' => array_map( static function ( $v ) { return (float) wc_format_decimal( (string) $v ); }, array_slice( array_values( (array) ( $opt['dims'] ?? array() ) ), 0, 3 ) ), ); // Per-option stock: an integer count this option is limited to. Present, // even 0 (sold out), means tracked; absent means unlimited (the default, // so every existing set is untouched). The actual number lives on a hidden // WooCommerce backing product provisioned on save, so Woo owns the count. if ( isset( $opt['stock'] ) && '' !== $opt['stock'] && is_numeric( $opt['stock'] ) ) { $row['stock'] = max( 0, (int) $opt['stock'] ); } $options[] = $row; } // Type "Navy" and the swatch fills itself from the shared name map, but // only when the WHOLE row resolves: every option carrying its own colour // or a recognised name. A lone hit in a row of strangers (Lavender among // scents) painted one tile in a row of pills, so a row that cannot be // uniform in dots stores no guesses at all and stays uniform in pills. // Colours the merchant typed themselves are already in place above. if ( function_exists( 'addon_boss_swatch_row_uniform' ) && addon_boss_swatch_row_uniform( $options ) ) { foreach ( $options as &$bb_row_opt ) { if ( '' === $bb_row_opt['swatch'] ) { $bb_row_opt['swatch'] = (string) sanitize_hex_color( addon_boss_swatch_from_name( $bb_row_opt['label'] ) ); } } unset( $bb_row_opt ); } // The set's kind. The engine's own is 'choices'; a richer kind sanitises // through the callback its module registered. A stored kind nothing // registers is carried through INERT, payload and all: the merchant's data // is not this build's to rewrite, and nothing here ever reads it. The // payload arrives leaf-sanitised at the request boundary, exactly like // every other admin-shaped array this function receives. $kinds = addon_boss_option_set_kinds(); $kind = sanitize_key( (string) ( $raw['kind'] ?? 'choices' ) ); if ( '' === $kind ) { $kind = 'choices'; } $out = array( 'name' => sanitize_text_field( (string) ( $raw['name'] ?? '' ) ), 'kind' => $kind, 'measure' => array(), 'table' => array(), 'options' => $options, // The group (folder) a set is filed under on the list page. Empty means // Ungrouped. Managed by drag on the list, never in the set editor. 'group' => sanitize_text_field( (string) ( $raw['group'] ?? '' ) ), // The swatch shape (display only): square, circle or the photo card. // Empty means the merchant never chose, which is a REAL state: it // renders square, except that a set whose options carry photos // prefers the card by itself. Nothing is migrated to a word here, // or that preference would die on the first unrelated save. 'shape' => in_array( (string) ( $raw['shape'] ?? '' ), array( 'square', 'circle', 'card' ), true ) ? (string) $raw['shape'] : '', // D6, display only: past the threshold a long set renders as the // list control; this pin keeps the chips regardless, per set. 'pin_chips' => ! empty( $raw['pin_chips'] ), // Whether the storefront shows this set's stock (Sold out / Only N left). // Absent means yes - the useful default and downgrade-safe. The guard that // prevents overselling is independent of this and always on; turning this // off just hides the numbers (and quietly drops a sold-out option). 'show_stock' => ! isset( $raw['show_stock'] ) || ! empty( $raw['show_stock'] ), // Several-of: the shopper may pick MORE THAN ONE choice from this set // ("choose up to 3 toppings"), each pick charged its own fee. The // bounds are optional; zero means unbounded on that side. A build that // never heard of the keys simply reads the set as pick-one, which is // the downgrade-safe shape on purpose. 'multi' => ! empty( $raw['multi'] ), 'min' => 0, 'max' => 0, ); if ( $out['multi'] ) { $bb_min = min( 100, absint( $raw['min'] ?? 0 ) ); $bb_max = min( 100, absint( $raw['max'] ?? 0 ) ); // Bounds that cannot both be met are made lawful, never guessed past: // an at-least above the up-to clamps down to it. if ( $bb_max > 0 && $bb_min > $bb_max ) { $bb_min = $bb_max; } $out['min'] = $bb_min; $out['max'] = $bb_max; } if ( 'choices' !== $kind ) { if ( is_callable( $kinds[ $kind ]['sanitize'] ?? null ) ) { $out = array_merge( $out, (array) call_user_func( $kinds[ $kind ]['sanitize'], $raw ) ); } else { $out['measure'] = is_array( $raw['measure'] ?? null ) ? $raw['measure'] : array(); $out['table'] = is_array( $raw['table'] ?? null ) ? $raw['table'] : array(); } } return $out; } // ================================================================= // Option-set resolution + snapshot + config sanitisers. Lifted UNCHANGED from // the earlier bundle-side layout; the option-set library, the cart-snapshot // contract, the product-options config sanitisers and the active-set resolver. // ================================================================= /** * Save a set (new when $id is empty). Returns the set id. The store is * written with autoload OFF: option sets are looked up on demand, never * dragged into every request. */ function addon_boss_save_option_set( array $set, string $id = '' ): string { $id = addon_boss_option_key_valid( $id ) ? $id : addon_boss_option_key( 'set' ); $sets = addon_boss_option_sets(); // The set editor has no group control (grouping lives on the list page), so // carry the existing group through an edit rather than clearing it. if ( ! isset( $set['group'] ) && isset( $sets[ $id ]['group'] ) ) { $set['group'] = $sets[ $id ]['group']; } // The same for a caller that never mentions the swatch shape (imports, // bulk paths): an explicit shape survives; it is never wiped by omission. if ( ! isset( $set['shape'] ) && isset( $sets[ $id ]['shape'] ) ) { $set['shape'] = $sets[ $id ]['shape']; } // And the chip pin: a save that never mentions the toggle must not undo // a merchant's explicit "keep the buttons". if ( ! isset( $set['pin_chips'] ) && ! empty( $sets[ $id ]['pin_chips'] ) ) { $set['pin_chips'] = true; } // Per-option stock rides through untouched whenever this save cannot speak // for it: a build without the plan prints no stock boxes and no display // toggle, so its form posts neither. Without this, one Save on a free build // would wipe every number the merchant set while on the plan. Stored values // are restored by option KEY, so reordering or renaming options is safe. if ( ! addon_boss_option_stock_enabled() && isset( $sets[ $id ] ) ) { if ( ! isset( $set['show_stock'] ) && isset( $sets[ $id ]['show_stock'] ) ) { $set['show_stock'] = $sets[ $id ]['show_stock']; } $bb_stored_stock = array(); foreach ( (array) ( $sets[ $id ]['options'] ?? array() ) as $bb_old ) { if ( isset( $bb_old['key'], $bb_old['stock'] ) ) { $bb_stored_stock[ (string) $bb_old['key'] ] = (int) $bb_old['stock']; } } if ( $bb_stored_stock && ! empty( $set['options'] ) ) { foreach ( $set['options'] as &$bb_new ) { $bb_k = (string) ( $bb_new['key'] ?? '' ); if ( '' !== $bb_k && ! isset( $bb_new['stock'] ) && isset( $bb_stored_stock[ $bb_k ] ) ) { $bb_new['stock'] = $bb_stored_stock[ $bb_k ]; } } unset( $bb_new ); } } // The several-of setting and its bounds ride through a save that never // mentions them (imports, bulk paths), exactly like the shape and the pin. if ( ! isset( $set['multi'] ) && ! empty( $sets[ $id ]['multi'] ) ) { $set['multi'] = true; foreach ( array( 'min', 'max' ) as $bb_bound ) { if ( ! isset( $set[ $bb_bound ] ) && isset( $sets[ $id ][ $bb_bound ] ) ) { $set[ $bb_bound ] = $sets[ $id ][ $bb_bound ]; } } } // A kind this build has no module for cannot repost its own payload (no // panel printed a form for it), so the stored payload rides into the save. // Losing a merchant's dimensions or price grid to a Save press would be // unforgivable; the data waits, whole, for the module that reads it. $bb_kind = sanitize_key( (string) ( $set['kind'] ?? 'choices' ) ); if ( 'choices' !== $bb_kind && '' !== $bb_kind && ! isset( addon_boss_option_set_kinds()[ $bb_kind ] ) && isset( $sets[ $id ] ) ) { foreach ( array( 'measure', 'table' ) as $bb_carry ) { if ( empty( $set[ $bb_carry ] ) && ! empty( $sets[ $id ][ $bb_carry ] ) ) { $set[ $bb_carry ] = $sets[ $id ][ $bb_carry ]; } } } $sets[ $id ] = addon_boss_sanitize_option_set( $set ); // update_option() keeps the existing autoload flag, so seed the row with // autoload off and update after. if ( false === get_option( ADDON_BOSS_OPTION_SETS_STORE, false ) ) { add_option( ADDON_BOSS_OPTION_SETS_STORE, array(), '', false ); } update_option( ADDON_BOSS_OPTION_SETS_STORE, $sets, false ); // Sync the hidden backing product behind each stock-limited option, so Woo // owns the count from the moment the merchant sets one. addon_boss_provision_option_stock( $id, $sets[ $id ] ); return $id; } /** Delete a set. Product attachments referencing it resolve to nothing. */ function addon_boss_delete_option_set( string $id ): bool { $sets = addon_boss_option_sets(); if ( ! isset( $sets[ $id ] ) ) { return false; } unset( $sets[ $id ] ); update_option( ADDON_BOSS_OPTION_SETS_STORE, $sets, false ); // A deleted set leaves no counters behind (the old build left its hidden // backing products orphaned here, which nothing ever cleaned). if ( function_exists( 'addon_boss_option_stock_prune' ) ) { addon_boss_option_stock_prune( $id, array() ); } return true; } // ----------------------------------------------------------------- // Per-option stock. A stock-limited option's count lives in ONE ROW of // our own small table, never in a product. The engine enforces the // merchant's "stock N on this choice" contract at add-to-cart and at // checkout (counting the cart), draws the count down on the sale, and // gives it back on a cancel or a restocking refund. // // The count deliberately does NOT live on a WooCommerce product. An // earlier build minted a hidden product per stocked option "so Woo owns // the counting", and the audit of what shipped showed Woo never did: // the backing product was never a line item on any order, so Woo's own // stock pass, its low-stock mails, its checkout holds and its reports // never touched it, while the catalogue gained mystery entries the // founding customer reported as a bug on day one. The one primitive it // provided was a relative SQL update, which is exactly what the table // does, minus the product wrapped around it. Every plugin in the // category that links options to products links products the merchant // ALREADY SELLS; none of them mint products, and neither do we. // ----------------------------------------------------------------- // Where the old build kept its counters: an option-name to post-id map, and a // stamp on each hidden product. Read ONLY by the one-time migration below. const ADDON_BOSS_OPTION_STOCK_MAP = 'addon_boss_option_stock_products'; const ADDON_BOSS_OPTION_STOCK_BACKING_META = '_addon_boss_option_stock_backing'; /** * Is per-option stock offered by this build? A Pro capability: without it the * engine neither tracks nor shows option stock, and every stored number is left * exactly as the merchant set it, ready for the plan returning. */ function addon_boss_option_stock_enabled(): bool { return addon_boss_engine_can( 'option-stock' ); } /** The counters' table name. */ function addon_boss_option_stock_table(): string { global $wpdb; return $wpdb->prefix . 'addon_boss_option_stock'; } /** * Create the counters' table. Self-healing on first write rather than an * activation hook, so a copied database, a multisite subsite or a build that * skipped activation all work the same; the flag keeps the DDL to once. */ function addon_boss_option_stock_install(): void { global $wpdb; if ( '1' === get_option( 'addon_boss_option_stock_db' ) ) { return; } $table = addon_boss_option_stock_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- our own table; identifiers cannot be placeholdered. $wpdb->query( "CREATE TABLE IF NOT EXISTS {$table} ( set_id VARCHAR(64) NOT NULL, option_key VARCHAR(64) NOT NULL, qty INT NOT NULL DEFAULT 0, PRIMARY KEY (set_id, option_key) ) {$wpdb->get_charset_collate()}" ); update_option( 'addon_boss_option_stock_db', '1', false ); } /** * How many of one option are left, or null when the option tracks no stock * (which includes every option on a build without the capability: stored * numbers wait untouched, nothing is tracked, nothing is blocked). * * Floored at zero: a racing pair of orders can take the stored count below * zero exactly as WooCommerce's own products allow, and nothing downstream * needs the negative, only "none left". */ function addon_boss_option_stock_left( string $set_id, string $key ): ?int { if ( ! addon_boss_option_stock_enabled() || '1' !== get_option( 'addon_boss_option_stock_db' ) ) { return null; } global $wpdb; $table = addon_boss_option_stock_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- one primary-key read of our own table; a live count must not be served from cache. $qty = $wpdb->get_var( $wpdb->prepare( "SELECT qty FROM {$table} WHERE set_id = %s AND option_key = %s", $set_id, $key ) ); return null === $qty ? null : max( 0, (int) $qty ); } /** Set one option's live count (the merchant's typed number, and the migration). */ function addon_boss_option_stock_write( string $set_id, string $key, int $qty ): void { addon_boss_option_stock_install(); global $wpdb; $table = addon_boss_option_stock_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- upsert into our own table. $wpdb->query( $wpdb->prepare( "INSERT INTO {$table} (set_id, option_key, qty) VALUES (%s, %s, %d) ON DUPLICATE KEY UPDATE qty = %d", $set_id, $key, $qty, $qty ) ); } /** * Move one option's live count by a signed amount, in a single relative * UPDATE so two orders landing together each take their own units (the same * arithmetic wc_update_product_stock does for a product). A row that does not * exist moves nothing: an option whose number was cleared takes no decrements * and no give-backs, which is what "not tracked" means. */ function addon_boss_option_stock_adjust( string $set_id, string $key, int $delta ): void { if ( 0 === $delta || '1' !== get_option( 'addon_boss_option_stock_db' ) ) { return; } global $wpdb; $table = addon_boss_option_stock_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- one relative update of our own table; relative on purpose, see above. $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET qty = qty + %d WHERE set_id = %s AND option_key = %s", $delta, $set_id, $key ) ); } /* * ================================================================= * COMBINATION counters: a colour AND a size together. * * Per-option counters answer "how many Navy" and "how many XXL", and for a * shop holding one pile per choice that is the whole truth. A garment * supplier holds a pile per PAIR: forty Black Small, none in Navy Small. * Per-option counters cannot say that, and the gap is not cosmetic - a * shopper can buy five Navy Small while Navy (fifty, mostly Large) and * Small (forty, mostly Black) both look healthy, and the order goes to a * supplier who has none. * * So a product may declare that its counts are kept per combination. The * rows live in the SAME table as the option counters, in a bucket of the * product's own, which is what makes every seam that already works keep * working: the cart's demand walk, the sale-time decrement, the give-back * on a refund, the checkout re-read. Only the key is new. * * Declaring is per product and explicit, because it says WHICH sets the * pairs are made of: a tee with Colour, Size and Printing is tracked on * colour x size, and the printing choice is not part of the pile. * * Nothing declares it by hand. A supplier feed does (StockBoss writes the * pairs it already holds per item code), and a product that declares * nothing behaves exactly as it did before: per-option counters, or * nothing at all. * ================================================================= */ /** The sets a product's counts are kept in pairs of, or none. */ const ADDON_BOSS_COMBO_SETS_META = '_addon_boss_combo_sets'; /** * Which sets this product tracks combinations across, in a fixed order. * * @return string[] */ function addon_boss_combo_sets( $product ): array { if ( ! $product instanceof WC_Product ) { $product = function_exists( 'wc_get_product' ) ? wc_get_product( (int) $product ) : null; } if ( ! $product instanceof WC_Product ) { return array(); } $sets = $product->get_meta( ADDON_BOSS_COMBO_SETS_META ); if ( ! is_array( $sets ) ) { return array(); } $out = array(); foreach ( $sets as $sid ) { $sid = (string) $sid; if ( '' !== $sid ) { $out[] = $sid; } } sort( $out ); // one order, whoever wrote it, so a key never depends on the writer. return $out; } /** One product's bucket in the counter table. */ function addon_boss_combo_bucket( int $product_id ): string { return 'bbpair:' . $product_id; } /** * The key one combination is counted under. * * Hashed rather than joined, because two option keys can be 38 characters * each and the column holds 64. The pairs a key stands for are always * recoverable from the picks themselves, so nothing is lost by hashing. * * @param array $picks set id => option key. * @param string[] $sets The sets to key on, in their fixed order. */ function addon_boss_combo_key( array $picks, array $sets ): string { $parts = array(); foreach ( $sets as $sid ) { if ( ! isset( $picks[ $sid ] ) || '' === (string) $picks[ $sid ] ) { return ''; // an incomplete pair is not a pair. } $parts[] = $sid . '::' . $picks[ $sid ]; } return $parts ? substr( sha1( implode( '|', $parts ) ), 0, 40 ) : ''; } /** * The combination a line's option snapshots land on, as a counter row. * * Returns null when this product tracks no pairs, or when the snapshots do * not name every set a pair is made of. The label is the shopper's own * words for it ("Deep Black / XXL"), because a refusal that says * "bbpair:263" would be useless to everybody. * * @param array $snaps * @return array{set:string,key:string,label:string}|null */ function addon_boss_combo_row( $product, array $snaps ): ?array { $sets = addon_boss_combo_sets( $product ); if ( ! $sets ) { return null; } $picks = array(); $labels = array(); foreach ( $snaps as $snap ) { if ( ! is_array( $snap ) ) { continue; } $sid = (string) ( $snap['set'] ?? '' ); if ( '' === $sid || ! in_array( $sid, $sets, true ) ) { continue; } $picks[ $sid ] = (string) ( $snap['key'] ?? '' ); $labels[ $sid ] = (string) ( $snap['label'] ?? $picks[ $sid ] ); } $key = addon_boss_combo_key( $picks, $sets ); if ( '' === $key ) { return null; } $words = array(); foreach ( $sets as $sid ) { $words[] = $labels[ $sid ]; } $pid = $product instanceof WC_Product ? $product->get_id() : (int) $product; return array( 'set' => addon_boss_combo_bucket( $pid ), 'key' => $key, 'label' => implode( ' / ', array_filter( $words ) ), ); } /** * Does this product's CHOOSING carry its own counts? * * True when the pairs are tracked, or when any set the product wears holds a * counter of its own. It is the question behind one piece of copy: whether * the product-wide "6,454 in stock" means anything to a shopper. * * On a product whose colours and sizes each carry a number, that figure is * every colour and size added together. It answers a question nobody asked, * it dwarfs anything anybody can actually buy, and it contradicts the very * counts beside it, where the size they want reads sold out. So a product * that counts per choice stops printing the total, and a product that * counts nothing per choice keeps it, because there the total IS the truth. */ function addon_boss_product_counts_per_choice( $product ): bool { if ( ! $product instanceof WC_Product ) { return false; } if ( addon_boss_combo_sets( $product ) ) { return true; } if ( ! function_exists( 'addon_boss_standalone_option_sets' ) || ! function_exists( 'addon_boss_option_stock_left' ) ) { return false; } foreach ( (array) addon_boss_standalone_option_sets( $product ) as $set ) { foreach ( (array) ( $set['options'] ?? array() ) as $opt ) { if ( null !== addon_boss_option_stock_left( (string) ( $set['id'] ?? '' ), (string) ( $opt['key'] ?? '' ) ) ) { return true; } } } return false; } /** * A product whose choices carry counts never shows the product-wide one. * * @param mixed $html What WooCommerce was going to print. * @param mixed $product The product it was printing it for. * @return mixed */ function addon_boss_no_whole_count( $html, $product = null ) { if ( $product instanceof WC_Product && ( addon_boss_product_counts_per_choice( $product ) || ( function_exists( 'addon_boss_product_uses_grid' ) && addon_boss_product_uses_grid( $product ) ) ) ) { return ''; } return $html; } add_filter( 'woocommerce_get_stock_html', 'addon_boss_no_whole_count', 10, 2 ); /** * Every tracked pair of a product, keyed by the option keys a shopper picks. * * The stored key is a hash, which the counters do not need to reverse but a * SCREEN does: the chips have to know that Bottle Green plus S is the pile * with none in it, before the shopper presses anything. So the cartesian is * walked once here and matched against a single read of the bucket, rather * than asking the database once per pair. * * @return array "c_bot|s_s" => qty, in the declared set order. */ function addon_boss_combo_stock_all( $product ): array { $sets = addon_boss_combo_sets( $product ); if ( ! $sets || ! function_exists( 'addon_boss_option_set' ) ) { return array(); } if ( ! $product instanceof WC_Product ) { $product = function_exists( 'wc_get_product' ) ? wc_get_product( (int) $product ) : null; } if ( ! $product instanceof WC_Product || '1' !== get_option( 'addon_boss_option_stock_db' ) ) { return array(); } global $wpdb; $table = addon_boss_option_stock_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- one read of our own table; a live count must not be served from cache. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT option_key, qty FROM {$table} WHERE set_id = %s", addon_boss_combo_bucket( $product->get_id() ) ), ARRAY_A ); if ( ! $rows ) { return array(); } $held = array(); foreach ( $rows as $row ) { $held[ (string) $row['option_key'] ] = max( 0, (int) $row['qty'] ); } // The cartesian of the declared sets, in their declared order. $combos = array( array() ); foreach ( $sets as $sid ) { $set = addon_boss_option_set( $sid ); $next = array(); foreach ( (array) ( $set['options'] ?? array() ) as $opt ) { $key = (string) ( $opt['key'] ?? '' ); if ( '' === $key ) { continue; } foreach ( $combos as $combo ) { $next[] = $combo + array( $sid => $key ); } } $combos = $next; } $out = array(); foreach ( $combos as $combo ) { $hash = addon_boss_combo_key( $combo, $sets ); if ( '' === $hash || ! array_key_exists( $hash, $held ) ) { continue; // this pair tracks nothing. } $parts = array(); foreach ( $sets as $sid ) { $parts[] = $combo[ $sid ]; } $out[ implode( '|', $parts ) ] = $held[ $hash ]; } return $out; } /** * How many of one combination are left, or null when this pair is not tracked. * * A pair the table does not hold reads as NONE rather than as unlimited, and * the difference is a real one: the table is written whole every run, so a * missing pair is not an unknown, it is a combination the supplier does not * make. Half the range is like this - a shop's White tee stops at XXL while * its Black one runs to 6XL - and reading those as untracked let a shopper * order five White 6XL, which nobody anywhere has. * * A product whose table is empty is a different thing entirely: nothing is * tracked, and everything is offered exactly as it was before. */ function addon_boss_combo_stock_left( $product, array $snaps ): ?int { $row = addon_boss_combo_row( $product, $snaps ); if ( null === $row ) { return null; } return addon_boss_counted_left( $row['set'], $row['key'] ); } /** * How many of one COUNTED thing are left, option or pair. * * The one reader every guard uses, because the two kinds differ in exactly * one way and it matters at the till: a missing option row means the option * counts nothing, while a missing PAIR row on a product that holds a table * means that combination is not made. Reading them the same way is what let * five White 6XL through. */ function addon_boss_counted_left( string $set_id, string $key ): ?int { $left = addon_boss_option_stock_left( $set_id, $key ); if ( null !== $left || 0 !== strpos( $set_id, 'bbpair:' ) ) { return $left; } return addon_boss_combo_stock_any( $set_id ) ? 0 : null; } /** * Does this product hold any pairs at all? * * Asked once per product per request, because it is the difference between * "this combination is not made" and "this shop counts nothing", and the * cartesian above would otherwise ask it per cell. */ function addon_boss_combo_stock_any( string $bucket ): bool { static $seen = array(); if ( isset( $seen[ $bucket ] ) ) { return $seen[ $bucket ]; } if ( '1' !== get_option( 'addon_boss_option_stock_db' ) ) { $seen[ $bucket ] = false; return false; } global $wpdb; $table = addon_boss_option_stock_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- one existence check of our own table. $seen[ $bucket ] = (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$table} WHERE set_id = %s LIMIT 1", $bucket ) ); return $seen[ $bucket ]; } /** * Write one product's whole pair table at once, and say which sets it is * keyed on. Whole-table on purpose: a feed knows every pair it holds, and * a pair that has left the range should stop being counted rather than * linger at its last figure. * * @param array,qty:int}> $pairs * @param string[] $sets */ function addon_boss_combo_stock_put( $product, array $sets, array $pairs ): void { if ( ! $product instanceof WC_Product ) { $product = function_exists( 'wc_get_product' ) ? wc_get_product( (int) $product ) : null; } if ( ! $product instanceof WC_Product || ! function_exists( 'addon_boss_option_stock_write' ) ) { return; } $clean = array(); foreach ( $sets as $sid ) { $sid = (string) $sid; if ( '' !== $sid ) { $clean[] = $sid; } } sort( $clean ); $bucket = addon_boss_combo_bucket( $product->get_id() ); if ( ! $clean || ! $pairs ) { // Tracking withdrawn: the declaration goes and so do the rows, so // nothing is left half-counted. $product->delete_meta_data( ADDON_BOSS_COMBO_SETS_META ); $product->save(); addon_boss_option_stock_prune( $bucket, array() ); return; } $product->update_meta_data( ADDON_BOSS_COMBO_SETS_META, $clean ); $product->save(); $kept = array(); foreach ( $pairs as $pair ) { $key = addon_boss_combo_key( (array) ( $pair['picks'] ?? array() ), $clean ); if ( '' === $key ) { continue; } $kept[] = $key; addon_boss_option_stock_write( $bucket, $key, max( 0, (int) ( $pair['qty'] ?? 0 ) ) ); } addon_boss_option_stock_prune( $bucket, $kept ); } /** * Delete a set's counters, except the keys named. An empty keep-list clears * the whole set (a deleted set leaves no counters behind). * * @param string[] $keep Option keys that still carry a number. */ function addon_boss_option_stock_prune( string $set_id, array $keep ): void { if ( '1' !== get_option( 'addon_boss_option_stock_db' ) ) { return; // no table, nothing to prune. } global $wpdb; $table = addon_boss_option_stock_table(); if ( ! $keep ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- deleting our own rows. $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE set_id = %s", $set_id ) ); return; } $holes = implode( ', ', array_fill( 0, count( $keep ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders -- deleting our own rows; the placeholder list is built from count() alone. $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE set_id = %s AND option_key NOT IN ( {$holes} )", array_merge( array( $set_id ), array_values( $keep ) ) ) ); } /** * Keep the counters in step with the merchant's numbers, on every set save. * * The box in the editor IS the count: typing 10 makes it 10, and emptying the * box deletes the row, so an empty box means unlimited everywhere at once and * there is no counter left anywhere to go stale. On a build without the plan * nothing here runs and the stored numbers ride through the save whole. */ function addon_boss_provision_option_stock( string $set_id, array $set ): void { if ( ! addon_boss_option_stock_enabled() ) { return; } $tracked = array(); foreach ( (array) ( $set['options'] ?? array() ) as $opt ) { $key = (string) ( $opt['key'] ?? '' ); if ( '' === $key || ! isset( $opt['stock'] ) || ! is_numeric( $opt['stock'] ) ) { continue; } $tracked[] = $key; addon_boss_option_stock_write( $set_id, $key, max( 0, (int) $opt['stock'] ) ); } addon_boss_option_stock_prune( $set_id, $tracked ); } /** * Move every counter out of the old hidden backing products, then remove the * products themselves. Runs once (the flag), and belt-and-braces: after the * map walk it sweeps for any product still carrying the backing stamp, so a * store whose map lost an entry is cleaned too. Counts are only carried over * for counters WooCommerce was still managing; a stood-down one was already * "cleared" and gets no row. */ function addon_boss_migrate_option_stock_backing(): void { if ( get_option( 'addon_boss_option_stock_migrated' ) || ! function_exists( 'wc_get_product' ) ) { return; } $map = get_option( ADDON_BOSS_OPTION_STOCK_MAP, null ); foreach ( is_array( $map ) ? $map : array() as $map_key => $bp_id ) { $parts = explode( '::', (string) $map_key, 2 ); $bp = wc_get_product( (int) $bp_id ); if ( $bp instanceof WC_Product ) { if ( 2 === count( $parts ) && $bp->managing_stock() ) { addon_boss_option_stock_write( $parts[0], $parts[1], max( 0, (int) $bp->get_stock_quantity() ) ); } $bp->delete( true ); } } // Strays the map forgot: anything still stamped as a backing product goes. global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- a one-time migration sweep. $stray_ids = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s", ADDON_BOSS_OPTION_STOCK_BACKING_META ) ); foreach ( array_map( 'intval', (array) $stray_ids ) as $stray ) { $bp = wc_get_product( $stray ); if ( $bp instanceof WC_Product ) { $bp->delete( true ); } } delete_option( ADDON_BOSS_OPTION_STOCK_MAP ); update_option( 'addon_boss_option_stock_migrated', '1', false ); } add_action( 'admin_init', 'addon_boss_migrate_option_stock_backing', 5 ); /** * The cart line this request is replacing, when the options form posted its * "edit this line" marker AND that marker really names a line of this product * in this visitor's own cart. Anything else excludes nothing, so a stale or * crossed-up marker can never discount stock that stays in the cart. The test * is deliberately the same one addon_boss_edit_apply_replace makes before it * removes the old line, so we only ever discount a line that is about to go. */ function addon_boss_editing_cart_line( int $product_id ): string { if ( ! isset( $_REQUEST['bb_edit_line'] ) || ! function_exists( 'WC' ) || null === WC()->cart ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- presence check; the key still has to resolve to this visitor's own line below. return ''; } $key = sanitize_text_field( wp_unslash( (string) $_REQUEST['bb_edit_line'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( '' === $key ) { return ''; } $line = WC()->cart->get_cart_item( $key ); if ( ! is_array( $line ) || (int) ( $line['product_id'] ?? 0 ) !== $product_id ) { return ''; } return $key; } /** * The set an interop snapshot came from, back out of its axis name. * * A bundle's component picks ride the neutral house shape, which names the set * by the axis addon_boss_virtual_attr_name() minted rather than by id, and the * asking plugin slugifies that name because it becomes a form field. Rather * than assume the round trip survives slugification, the known sets are asked * which of them mints this axis. */ function addon_boss_set_id_from_axis( string $axis ): string { if ( '' === $axis ) { return ''; } foreach ( array_keys( addon_boss_option_sets() ) as $id ) { $mine = addon_boss_virtual_attr_name( (string) $id ); if ( $axis === $mine || $axis === sanitize_title( $mine ) ) { return (string) $id; } } return ''; } /** * What a BundleBoss bundle's validated selections ask of stocked options. * * A bundle is one cart line holding several components, so demand is each * component's own quantity times the line's. Returns [ "set::key" => row ]. * * @param mixed $selections The bundle payload's selections (cart or order line). * @param int $line_qty How many of that bundle. */ function addon_boss_bundle_option_demand( $selections, int $line_qty = 1 ): array { $out = array(); foreach ( (array) $selections as $sel ) { $unit = max( 1, (int) ( $sel['qty'] ?? 1 ) ); foreach ( (array) ( ( is_array( $sel ) ? $sel['options'] : null ) ?? array() ) as $snap ) { $set = addon_boss_set_id_from_axis( (string) ( $snap['axis'] ?? '' ) ); $key = (string) ( $snap['key'] ?? '' ); if ( '' === $set || '' === $key ) { continue; } $mk = $set . '::' . $key; if ( ! isset( $out[ $mk ] ) ) { $out[ $mk ] = array( 'set' => $set, 'key' => $key, 'label' => (string) ( $snap['label'] ?? $key ), 'qty' => 0, ); } $out[ $mk ]['qty'] += $unit * max( 1, $line_qty ); } } return $out; } /** * Everything the whole cart currently asks of stocked options, keyed * "set::key". Counts BOTH a product bought on its own page and the same * product bought inside a bundle: one shelf, however the sale is dressed. */ function addon_boss_cart_option_demand( string $exclude_key = '' ): array { $out = array(); if ( ! function_exists( 'WC' ) || null === WC()->cart ) { return $out; } $add = static function ( array &$out, string $set, string $key, string $label, int $qty ): void { if ( '' === $set || '' === $key || $qty < 1 ) { return; } $mk = $set . '::' . $key; if ( ! isset( $out[ $mk ] ) ) { $out[ $mk ] = array( 'set' => $set, 'key' => $key, 'label' => $label, 'qty' => 0, ); } $out[ $mk ]['qty'] += $qty; }; foreach ( WC()->cart->get_cart() as $cart_key => $item ) { if ( '' !== $exclude_key && $cart_key === $exclude_key ) { continue; } $line_qty = max( 0, (int) ( $item['quantity'] ?? 0 ) ); if ( $line_qty < 1 ) { continue; } $bb_snaps = (array) ( $item[ ADDON_BOSS_STANDALONE_OPTIONS_KEY ] ?? array() ); foreach ( $bb_snaps as $snap ) { $add( $out, (string) ( $snap['set'] ?? '' ), (string) ( $snap['key'] ?? '' ), (string) ( $snap['label'] ?? '' ), $line_qty ); } // The pair this line lands on, when the product counts in pairs. It // rides the same demand list as any option, so the cart re-read, the // sale-time decrement and the give-back all reach it unchanged. $bb_pair = addon_boss_combo_row( (int) ( $item['product_id'] ?? 0 ), $bb_snaps ); if ( null !== $bb_pair ) { $add( $out, $bb_pair['set'], $bb_pair['key'], $bb_pair['label'], $line_qty ); } foreach ( addon_boss_bundle_option_demand( $item['bundle_boss']['selections'] ?? array(), $line_qty ) as $row ) { $add( $out, $row['set'], $row['key'], $row['label'], (int) $row['qty'] ); } } return $out; } /** * Is there room for this many of one counted thing, counting the cart? * * One voice for an option and for a pair, because the arithmetic and the * apology are identical and only the name differs. * * @param array{set:string,key:string,label:string} $row * @return WP_Error|null */ function addon_boss_stock_shortfall( array $row, int $add_qty, string $exclude_key = '' ) { $have = addon_boss_counted_left( $row['set'], $row['key'] ); if ( null === $have ) { return null; // not tracked: unlimited. } $label = (string) ( $row['label'] ?? '' ); if ( $have <= 0 ) { /* translators: %s: the choice, or the colour and size together */ return new WP_Error( 'addon_boss', sprintf( __( 'Sorry, "%s" is out of stock.', 'addon-boss' ), $label ) ); } $in = addon_boss_option_qty_in_cart( $row['set'], $row['key'], $exclude_key ); if ( $in + $add_qty > $have ) { $left = max( 0, $have - $in ); return new WP_Error( 'addon_boss', sprintf( /* translators: 1: units still available, 2: the choice, or the colour and size together */ _n( 'Sorry, only %1$d of "%2$s" is left.', 'Sorry, only %1$d of "%2$s" are left.', $left, 'addon-boss' ), $left, $label ) ); } return null; } /** How many of one option the cart already holds, on its own page or in a bundle. */ function addon_boss_option_qty_in_cart( string $set_id, string $key, string $exclude_key = '' ): int { $demand = addon_boss_cart_option_demand( $exclude_key ); return (int) ( $demand[ $set_id . '::' . $key ]['qty'] ?? 0 ); } /** * Refuse an add that would sell more of a stock-limited option than exist, * counting what the cart already holds. Returns true when every picked option * has room (or tracks no stock), or a WP_Error naming the short option. */ function addon_boss_check_option_stock( $product, $snaps, int $add_qty, string $exclude_key = '' ) { if ( ! is_array( $snaps ) || $add_qty < 1 ) { return true; } // The pair first, because it is the most specific thing anybody can be // short of: "Deep Black / XXL" is a truer refusal than "XXL", and a shop // that counts pairs has already said the per-option totals are not the // question. $bb_pair = addon_boss_combo_row( $product, $snaps ); if ( null !== $bb_pair ) { $bb_err = addon_boss_stock_shortfall( $bb_pair, $add_qty, $exclude_key ); if ( is_wp_error( $bb_err ) ) { return $bb_err; } } foreach ( $snaps as $snap ) { $set_id = (string) ( $snap['set'] ?? '' ); $key = (string) ( $snap['key'] ?? '' ); if ( '' === $set_id || '' === $key ) { continue; } $have = addon_boss_option_stock_left( $set_id, $key ); if ( null === $have ) { continue; // this option tracks no stock: unlimited. } $label = (string) ( $snap['label'] ?? $key ); if ( $have <= 0 ) { /* translators: %s: option name */ return new WP_Error( 'addon_boss', sprintf( __( 'Sorry, "%s" is out of stock.', 'addon-boss' ), $label ) ); } $in = addon_boss_option_qty_in_cart( $set_id, $key, $exclude_key ); if ( $in + $add_qty > $have ) { $left = max( 0, $have - $in ); return new WP_Error( 'addon_boss', sprintf( /* translators: 1: units still available, 2: option name */ _n( 'Sorry, only %1$d of "%2$s" is left.', 'Sorry, only %1$d of "%2$s" are left.', $left, 'addon-boss' ), $left, $label ) ); } } return true; } // The add-to-cart gate is not the last word: a shopper can raise the quantity // box on the cart page, and a cart restored from a session can outlive the // stock it was built against. WooCommerce re-reads its own products here, on // the way to checkout, so option stock is re-read at the same seam - counting // the WHOLE cart, since two lines can draw on one option. add_action( 'woocommerce_check_cart_items', 'addon_boss_check_cart_option_stock' ); function addon_boss_check_cart_option_stock(): void { if ( ! function_exists( 'WC' ) || null === WC()->cart ) { return; } foreach ( addon_boss_cart_option_demand() as $row ) { $have = addon_boss_counted_left( $row['set'], $row['key'] ); if ( null === $have ) { continue; // this option tracks no stock: unlimited. } if ( $have <= 0 ) { wc_add_notice( sprintf( /* translators: %s: option name */ __( 'Sorry, "%s" is out of stock. Please remove it from your cart.', 'addon-boss' ), $row['label'] ), 'error' ); continue; } if ( $row['qty'] > $have ) { wc_add_notice( sprintf( /* translators: 1: units still available, 2: option name */ _n( 'Sorry, only %1$d of "%2$s" is left. Please lower the quantity in your cart.', 'Sorry, only %1$d of "%2$s" are left. Please lower the quantity in your cart.', max( 0, $have ), 'addon-boss' ), max( 0, $have ), $row['label'] ), 'error' ); } } } // Decrement at the sale, restore on a cancel. An option is never a line item, // so WooCommerce's own stock pass never sees it: we ride the SAME order-stock // actions WC fires, once per order (guarded by an order flag, exactly like // WC's _order_stock_reduced), and move each counter with one relative UPDATE, // the same arithmetic WC's own stock update runs for a product. add_action( 'woocommerce_reduce_order_stock', 'addon_boss_reduce_order_option_stock', 10, 1 ); add_action( 'woocommerce_restore_order_stock', 'addon_boss_restore_order_option_stock', 10, 1 ); function addon_boss_reduce_order_option_stock( $order ): void { addon_boss_adjust_order_option_stock( $order, 'decrease' ); } function addon_boss_restore_order_option_stock( $order ): void { addon_boss_adjust_order_option_stock( $order, 'increase' ); } /** * What one order line asks of stocked options, keyed "set::key". * * A line is either a product bought on its own page, which froze its picks * under our own key, or a BundleBoss bundle, which froze its components' picks * under BundleBoss's. Both are sales of the same jars off the same shelf. * * @param WC_Order_Item $item The order line. * @param int $line_qty How many of that line. */ function addon_boss_order_item_option_demand( $item, int $line_qty ): array { $out = array(); $bb_snaps = (array) $item->get_meta( '_bundle_boss_standalone_options' ); $bb_pair = addon_boss_combo_row( (int) $item->get_product_id(), $bb_snaps ); if ( null !== $bb_pair ) { // Counted like any option, so a sale takes the pair down and a refund // puts it back, through the machinery that already does both. $out[ $bb_pair['set'] . '::' . $bb_pair['key'] ] = array( 'set' => $bb_pair['set'], 'key' => $bb_pair['key'], 'label' => $bb_pair['label'], 'qty' => $line_qty, ); } foreach ( $bb_snaps as $snap ) { $set = (string) ( is_array( $snap ) ? ( $snap['set'] ?? '' ) : '' ); $key = (string) ( is_array( $snap ) ? ( $snap['key'] ?? '' ) : '' ); if ( '' === $set || '' === $key ) { continue; } $out[ $set . '::' . $key ] = array( 'set' => $set, 'key' => $key, 'label' => (string) ( $snap['label'] ?? $key ), 'qty' => $line_qty, ); } foreach ( addon_boss_bundle_option_demand( $item->get_meta( '_bundle_boss_selections' ), $line_qty ) as $mk => $row ) { if ( isset( $out[ $mk ] ) ) { $out[ $mk ]['qty'] += (int) $row['qty']; continue; } $out[ $mk ] = $row; } return $out; } /** * Move every stocked option in an order by its ordered quantity, once. The flag * mirrors WooCommerce's reduce-once discipline, so a re-fired hook, a status * bounce or a manual "reduce stock" never double-counts. */ function addon_boss_adjust_order_option_stock( $order, string $direction ): void { if ( ! $order instanceof WC_Order ) { $order = wc_get_order( $order ); } if ( ! $order ) { return; } $flag = '_addon_boss_option_stock_reduced'; $reduced = 'yes' === $order->get_meta( $flag ); if ( 'decrease' === $direction && $reduced ) { return; // already off stock. } if ( 'increase' === $direction && ! $reduced ) { return; // nothing of ours to give back. } foreach ( $order->get_items() as $item ) { $qty = (int) $item->get_quantity(); if ( $qty < 1 ) { continue; } foreach ( addon_boss_order_item_option_demand( $item, $qty ) as $row ) { if ( null === addon_boss_option_stock_left( $row['set'], $row['key'] ) ) { continue; // not tracked: nothing to move. } addon_boss_option_stock_adjust( $row['set'], $row['key'], ( 'decrease' === $direction ? -1 : 1 ) * (int) $row['qty'] ); } } $order->update_meta_data( $flag, 'decrease' === $direction ? 'yes' : 'no' ); $order->save(); } // A refund with "restock refunded items" gives option stock back too. WC's own // wc_restock_refunded_items only touches real line items, so we ride the refund // event and lift each stocked option by the refunded quantity - honouring the // merchant's restock choice ($args['restock_items']) exactly as WooCommerce does. add_action( 'woocommerce_create_refund', 'addon_boss_restock_refunded_option_stock', 10, 2 ); function addon_boss_restock_refunded_option_stock( $refund, $args ): void { if ( empty( $args['restock_items'] ) || empty( $args['line_items'] ) ) { return; } $order_id = (int) ( $args['order_id'] ?? ( $refund instanceof WC_Order_Refund ? $refund->get_parent_id() : 0 ) ); $order = $order_id ? wc_get_order( $order_id ) : null; if ( ! $order ) { return; } // Only give back what a sale actually took off. if ( 'yes' !== $order->get_meta( '_addon_boss_option_stock_reduced' ) ) { return; } foreach ( (array) $args['line_items'] as $item_id => $data ) { $qty = abs( (int) round( (float) ( $data['qty'] ?? 0 ) ) ); if ( $qty < 1 ) { continue; } $item = $order->get_item( (int) $item_id ); if ( ! $item ) { continue; } // The refunded quantity is a count of LINES, so a bundle gives back // whatever each bundle held, exactly as the sale took it off. foreach ( addon_boss_order_item_option_demand( $item, $qty ) as $row ) { if ( null === addon_boss_option_stock_left( $row['set'], $row['key'] ) ) { continue; // not tracked (or cleared since the sale): nothing to give back. } addon_boss_option_stock_adjust( $row['set'], $row['key'], (int) $row['qty'] ); } } } // ----------------------------------------------------------------- // Starter library — ready-made sets a shop imports as its OWN copy // ----------------------------------------------------------------- /** * The shipped ready-made sets. Swatches resolve through the engine's * colour-name map where it exists, so garment colours arrive with correct * chips and zero typing. These are templates: importing COPIES one into the * shop's saved sets, where it is fully editable and deletable. * * @return array */ function addon_boss_option_starter_sets(): array { $swatch = static function ( string $name ): string { return function_exists( 'addon_boss_swatch_from_name' ) ? addon_boss_swatch_from_name( $name ) : ''; }; $colours = static function ( array $names ) use ( $swatch ): array { $out = array(); foreach ( $names as $name ) { $out[] = array( 'label' => $name, 'swatch' => $swatch( $name ) ); } return $out; }; $plain = static function ( array $labels ): array { $out = array(); foreach ( $labels as $label ) { $out[] = array( 'label' => $label ); } return $out; }; return array( 'starter_garment_colours' => array( 'name' => __( 'Garment colours', 'addon-boss' ), 'purpose' => __( 'For apparel and workwear', 'addon-boss' ), 'options' => $colours( array( 'Black', 'White', 'Navy', 'Royal Blue', 'Red', 'Burgundy', 'Charcoal', 'Heather Grey', 'Forest Green', 'Orange', 'Yellow', 'Pink' ) ), ), 'starter_sizes_s_4xl' => array( 'name' => __( 'Sizes S to 4XL', 'addon-boss' ), 'purpose' => __( 'Clothing sizes', 'addon-boss' ), 'options' => $plain( array( 'S', 'M', 'L', 'XL', '2XL', '3XL', '4XL' ) ), ), 'starter_shoe_sizes_uk' => array( 'name' => __( 'UK shoe sizes', 'addon-boss' ), 'purpose' => __( 'Footwear', 'addon-boss' ), 'options' => $plain( array( 'UK 6', 'UK 7', 'UK 8', 'UK 9', 'UK 10', 'UK 11', 'UK 12' ) ), ), 'starter_paper_sizes' => array( 'name' => __( 'Paper and board sizes', 'addon-boss' ), 'purpose' => __( 'Print and signage', 'addon-boss' ), 'options' => $plain( array( 'A6', 'A5', 'A4', 'A3', 'A2', 'A1', 'A0' ) ), ), 'starter_bag_weights' => array( 'name' => __( 'Bag weights', 'addon-boss' ), 'purpose' => __( 'Sweets and food', 'addon-boss' ), 'options' => $plain( array( '100g', '250g', '500g', '1kg' ) ), ), 'starter_finishes' => array( 'name' => __( 'Finishes', 'addon-boss' ), 'purpose' => __( 'Print, signs and framing', 'addon-boss' ), // Real starting numbers, like the price-table templates: the plain // finishes free, the laminates carrying a modest charge, every one // editable the moment the set is the shop's own. 'options' => array( array( 'label' => __( 'Matt', 'addon-boss' ) ), array( 'label' => __( 'Gloss', 'addon-boss' ) ), array( 'label' => __( 'Satin', 'addon-boss' ) ), array( 'label' => __( 'Matt laminated', 'addon-boss' ), 'delta' => 3.0, ), array( 'label' => __( 'Gloss laminated', 'addon-boss' ), 'delta' => 3.0, ), ), ), 'starter_addons' => array( 'name' => __( 'Add-ons', 'addon-boss' ), 'purpose' => __( 'Charged extras, any shop', 'addon-boss' ), // A true extras list is several-of: shoppers may take more than one, // each pick charged its own fee, so the set imports that way. 'multi' => true, 'options' => array( array( 'label' => __( 'Gift wrap', 'addon-boss' ), 'delta' => 3.5, ), array( 'label' => __( 'Gift box', 'addon-boss' ), 'delta' => 5.0, ), array( 'label' => __( 'Handwritten card', 'addon-boss' ), 'delta' => 1.5, ), array( 'label' => __( 'Priority dispatch', 'addon-boss' ), 'delta' => 9.95, ), ), ), ); } /** * The id of a saved set whose name matches, or '' when none does. The starter * shelf uses it so a ready-made set already imported reads as "Added", and * re-importing opens the existing copy instead of silently making a duplicate. */ function addon_boss_option_set_id_by_name( string $name ): string { foreach ( addon_boss_option_sets() as $id => $set ) { if ( isset( $set['name'] ) && (string) $set['name'] === $name ) { return (string) $id; } } return ''; } /** * Import a starter set as a new saved set the shop owns. Returns the new * set id, or '' for an unknown starter. */ function addon_boss_import_starter_set( string $starter_id ): string { $starters = addon_boss_option_starter_sets(); if ( ! isset( $starters[ $starter_id ] ) ) { return ''; } return addon_boss_save_option_set( $starters[ $starter_id ] ); } // ----------------------------------------------------------------- // Product attachment (Shape B: options live on the product) // ----------------------------------------------------------------- /** * The raw attachment config on a product: which sets it comes in, whether * each is required, and any per-product delta and photo overrides. * * @return array,images:array}> */ /** * Whether anything is offering choices and fields ON A PRODUCT'S OWN TERMS. * * Inside AddOnBoss the feature is always its own, so the default is simply * true; the filter stays so a site can switch the surface off. Same shape, * and the same reasoning, as addon_boss_positions_available(). */ function addon_boss_options_available(): bool { return (bool) apply_filters( 'addon_boss_options_available', true ); } function addon_boss_product_option_config( $product ): array { // The chokepoint. Both the option-set resolver and the product-field // resolver read through here, so one gate makes the whole per-product // feature inert: no sets, no fields, no grid, no standalone surface, and // nothing chargeable. Stored data simply lies dormant rather than being // migrated or deleted, exactly as it does for positions. if ( ! $product instanceof WC_Product || ! addon_boss_options_available() ) { return array(); } $cfg = $product->get_meta( ADDON_BOSS_PRODUCT_OPTIONS_META ); return is_array( $cfg ) ? $cfg : array(); } /** * Sanitise + save the attachment config. Override deltas keep an explicit * zero (it is a value that beats the set default) and drop empty strings * (absence, meaning "use the default"). */ function addon_boss_save_product_option_config( $product, array $raw ): void { if ( ! $product instanceof WC_Product ) { return; } $clean = addon_boss_sanitize_product_option_config( $raw ); if ( $clean ) { $product->update_meta_data( ADDON_BOSS_PRODUCT_OPTIONS_META, $clean ); } else { $product->delete_meta_data( ADDON_BOSS_PRODUCT_OPTIONS_META ); } $product->save(); } // How a product with several option groups lays them out. Stacked is plain // block flow, so it is theme-proof and stays the default for every product that // has never been told otherwise. const ADDON_BOSS_OPTIONS_LAYOUT_META = '_bundle_boss_options_layout'; /** * The layouts this build can actually draw. * * Every key here MUST be honoured by the renderer. "Step by step" was listed * before it was built: the menu offered it, the save accepted it, and the page * came out identical to stacked, so a merchant chose a layout and got nothing. * A control that does not do its job is worse than an absent one, because the * merchant spends their time wondering what they did wrong. It comes back when * there is a renderer behind it, and LayoutsRenderTest fails the moment this * list grows a key the renderer does not branch on. * * Deliberately no tabs. They are the worst of the options for a form: they hide * sibling groups behind a click, so a shopper cannot see what is left to decide, * and they are the most fragile thing to put inside a page builder's column. * * @return array Layout key => the merchant-facing name. */ function addon_boss_options_layouts(): array { $out = array( 'stacked' => __( 'One after another', 'addon-boss' ) ); // A layout beyond the engine's own flow registers from the module that // draws it, so the menu can never offer a page this build cannot render. return (array) apply_filters( 'addon_boss_options_layouts', $out ); } /** * The layout to draw a product's option groups in. * * Anything this build cannot draw falls back to stacked, which can render any * configuration. The STORED value is left alone, exactly as a repeatable field's * setting is, so a lapsed licence shows the plain layout for a while rather than * losing what the merchant chose. * * @param WC_Product|int|null $product * @return string */ function addon_boss_options_layout( $product ): string { $product = $product instanceof WC_Product ? $product : ( $product ? wc_get_product( $product ) : null ); if ( ! $product instanceof WC_Product ) { return 'stacked'; } $want = (string) $product->get_meta( ADDON_BOSS_OPTIONS_LAYOUT_META ); return isset( addon_boss_options_layouts()[ $want ] ) ? $want : 'stacked'; } /** * Store the chosen layout. An unknown value, or the default, stores nothing, so * a product only carries this meta once it actually differs from plain flow. * * @param WC_Product $product * @param string $layout */ function addon_boss_save_options_layout( WC_Product $product, string $layout ): void { // Validated against what this build OFFERS, plus whatever the product // already carries: a repost of the stored value on a build without the // module keeps the merchant's choice rather than resetting it, while a // layout nothing registered and nothing stored is refused, because // accepting it would store a choice that can never show up on the page. // (The caller only reaches here when the picker was actually on the // screen that posted, so an absent control can never blank a choice.) $known = array_keys( addon_boss_options_layouts() ); $stored = (string) $product->get_meta( ADDON_BOSS_OPTIONS_LAYOUT_META ); if ( '' !== $stored && ! in_array( $stored, $known, true ) ) { $known[] = $stored; } if ( in_array( $layout, $known, true ) && 'stacked' !== $layout ) { $product->update_meta_data( ADDON_BOSS_OPTIONS_LAYOUT_META, $layout ); } else { $product->delete_meta_data( ADDON_BOSS_OPTIONS_LAYOUT_META ); } } /** * The RESOLVED, render-ready option sets for a product: attachment plus set * data merged, cascade applied (set default delta, then product override). * Variable PARENTS resolve too: their variations keep owning colour, size, * stock and photos, and sets ride on top as extras priced per pick (88% of * the founding customer's variable catalogue carries add-on fields). * Variation objects resolve nothing (sets live on the parent), and * attachments whose set no longer exists degrade silently. * * @return array */ function addon_boss_product_option_sets( $product ): array { if ( ! $product instanceof WC_Product || $product->is_type( 'variation' ) ) { return array(); } $resolved = array(); // Flattened so a set inside a GROUP resolves (and prices) exactly like a // top-level one; grouping is presentation only. foreach ( addon_boss_flatten_option_config( addon_boss_product_option_config( $product ) ) as $row ) { if ( ( $row['row_type'] ?? '' ) === 'field' || ! isset( $row['set'] ) ) { continue; // field rows carry no set for this set-only resolver. } $set = addon_boss_option_set( $row['set'] ); if ( ! $set ) { continue; // deleted set: degrade silently, never fatally. } // An empty choices set offers nothing; a kind priced off its own panel // (its payload stored under measure or table) is whole WITHOUT option // rows (pure custom sizing is a real shop). This reads the stored // SHAPE only; whatever maths prices the payload lives with its module. $bb_kind_whole = 'choices' !== ( $set['kind'] ?? 'choices' ) && ( ! empty( $set['measure']['dims'] ) || ! empty( $set['table']['a_labels'] ) ); if ( empty( $set['options'] ) && ! $bb_kind_whole ) { continue; } // Choices this product does not offer. Applied HERE, in the resolver, // so the page, the validation, the price and the cart all read one // list: a hidden key is refused at add-to-cart for exactly the reason // an invented one is, because by this point they are the same thing. // (The discipline is BundleBoss's, ported with the feature.) $bb_hidden = array_flip( array_map( 'strval', (array) ( $row['hide'] ?? array() ) ) ); $options = array(); foreach ( $set['options'] as $opt ) { if ( isset( $bb_hidden[ (string) $opt['key'] ] ) ) { continue; } $options[] = array( 'key' => $opt['key'], 'label' => $opt['label'], 'swatch' => $opt['swatch'], 'swatch2' => $opt['swatch2'] ?? '', 'desc' => $opt['desc'] ?? '', // The photo cascades exactly like the charge: the set may carry a // default, the product override wins (sets are shared, but the // blue photo of THIS product is this product's business). 'image_id' => isset( $row['images'][ $opt['key'] ] ) ? (int) $row['images'][ $opt['key'] ] : (int) $opt['image_id'], 'delta' => array_key_exists( $opt['key'], $row['deltas'] ) ? (float) $row['deltas'][ $opt['key'] ] : (float) $opt['delta'], 'dims' => (array) ( $opt['dims'] ?? array() ), ); } // Hidden down to nothing: the set is not offered at all. A required set // with no choice left would block the product for good, and an empty one // is chrome around nothing either way. if ( ! $options && ! $bb_kind_whole ) { continue; } $resolved[] = array( 'id' => $row['set'], 'name' => $set['name'], 'required' => ! empty( $row['required'] ), 'when' => is_array( $row['when'] ?? null ) ? $row['when'] : array(), 'kind' => (string) ( $set['kind'] ?? 'choices' ), 'measure' => (array) ( $set['measure'] ?? array() ), 'table' => (array) ( $set['table'] ?? array() ), 'shape' => (string) ( $set['shape'] ?? '' ), 'pin_chips' => ! empty( $set['pin_chips'] ), // Absent means on (downgrade-safe): only an explicit false hides stock. 'show_stock' => ! isset( $set['show_stock'] ) || ! empty( $set['show_stock'] ), 'multi' => ! empty( $set['multi'] ), 'min' => max( 0, (int) ( $set['min'] ?? 0 ) ), 'max' => max( 0, (int) ( $set['max'] ?? 0 ) ), 'options' => $options, ); } return $resolved; } // ----------------------------------------------------------------- // The cart snapshot contract // ----------------------------------------------------------------- /** * Snapshot one pick against a RESOLVED set: the exact array the cart will * carry. Null when the key is not in the set — the server-side validation * building block (a DOM-invented key never reaches the cart). * * @return array{set:string,set_name:string,key:string,label:string,delta:float}|null */ function addon_boss_option_snapshot( array $resolved_set, string $key ): ?array { foreach ( (array) ( $resolved_set['options'] ?? array() ) as $opt ) { if ( ( $opt['key'] ?? '' ) === $key ) { return array( 'set' => (string) ( $resolved_set['id'] ?? '' ), 'set_name' => (string) ( $resolved_set['name'] ?? '' ), 'key' => $key, 'label' => (string) $opt['label'], 'delta' => (float) $opt['delta'], ); } } return null; } // ================================================================= // Form parsing — pure helpers so the save handlers stay testable // ================================================================= /** * Assemble option rows from the editor form's parallel arrays. Rows arrive * in DOM order (the drag-reorder result). The hidden per-row key input is * what keeps keys STABLE through every admin round trip; a row without one * (a freshly added row) gets a generated key from the sanitiser. * * @param string[] $labels * @param string[] $keys * @param string[] $swatches * @param string[] $deltas * @return array[] raw option rows ready for addon_boss_sanitize_option_set() */ function addon_boss_option_rows_from_form( array $labels, array $keys, array $swatches, array $deltas, array $dims = array(), array $swatches2 = array(), array $descs = array(), array $stocks = array(), array $images = array() ): array { $rows = array(); foreach ( array_values( $labels ) as $i => $label ) { $row_dims = array(); foreach ( $dims as $dim_column ) { $row_dims[] = (string) ( array_values( (array) $dim_column )[ $i ] ?? '' ); } $row = array( 'label' => (string) $label, 'key' => (string) ( array_values( $keys )[ $i ] ?? '' ), 'swatch' => (string) ( array_values( $swatches )[ $i ] ?? '' ), 'swatch2' => (string) ( array_values( $swatches2 )[ $i ] ?? '' ), 'desc' => (string) ( array_values( $descs )[ $i ] ?? '' ), 'delta' => (string) ( array_values( $deltas )[ $i ] ?? '0' ), 'dims' => $row_dims, // The choice's own picture, carried by the SET, so a library set // built once (print positions, finishes, papers) takes its // pictures onto every product it is ever put on. A photo set // against the same choice on one product still wins there, // exactly as a price override does; this is the one underneath. 'image_id' => absint( array_values( $images )[ $i ] ?? 0 ), ); // Per-option stock: a blank box means unlimited (no key stored); a number // (including 0 = sold out) turns tracking on for this option. $bb_stock = trim( (string) ( array_values( $stocks )[ $i ] ?? '' ) ); if ( '' !== $bb_stock && is_numeric( $bb_stock ) ) { $row['stock'] = max( 0, (int) $bb_stock ); } $rows[] = $row; } return $rows; } /** * Sanitise a product attachment config from raw form arrays (also used by * the model's save function, so the two paths can never drift). Groups are * real containers: a group row carries its own child rows, so membership is * explicit and moving a group never sweeps up its neighbours. */ function addon_boss_sanitize_product_option_config( array $raw ): array { $clean = array(); $field_keys = array(); // Set ids seen anywhere in this config, top level or inside a group, so one // set can only be on a product once however the rows are arranged. $set_ids = array(); foreach ( $raw as $row ) { if ( ! is_array( $row ) ) { continue; } // A GROUP row is a real container: a name and its own child rows (sets // and fields). Members belong to the group explicitly, never by // position, so moving a group never sweeps up its neighbours. A // 'collapsed' key from an older build is read nowhere and simply not // carried forward; how a group starts is the layout's call alone. if ( ( $row['row_type'] ?? '' ) === 'group' ) { $glabel = sanitize_text_field( (string) ( $row['label'] ?? '' ) ); $children = array(); foreach ( (array) ( $row['children'] ?? array() ) as $child ) { if ( ! is_array( $child ) || ( $child['row_type'] ?? '' ) === 'group' ) { continue; // groups never nest inside groups. } $c = addon_boss_sanitize_option_row( $child, $field_keys, $set_ids ); if ( $c ) { $children[] = $c; } } // A group keeps its children whether or not it is named; a nameless group // is a valid untitled section (no visible title on the front end). Only a // group that is BOTH nameless and empty is pointless, so that one is dropped. if ( '' === $glabel && ! $children ) { continue; } $bb_grow = array( 'row_type' => 'group', 'label' => $glabel, 'children' => $children, ); // Repeatable GROUP: all fields in this group repeat together, as one // block per instance. Same keys, same whitelist, same ceiling and the // same discipline as a field's own repeat: stored WITHOUT asking // whether the plan is active, because gating the save would rewrite a // merchant's configuration the moment a licence lapsed. The capability // is asked where it can be honoured (the editor's panel and the // storefront), so a free build renders a plain section and leaves the // setting alone for the plan's return. $bb_grpt = (string) ( $row['repeat'] ?? '' ); if ( in_array( $bb_grpt, array( 'add', 'per_item' ), true ) ) { $bb_grow['repeat'] = $bb_grpt; $bb_grmax = (int) ( $row['repeat_max'] ?? 0 ); if ( $bb_grmax > 0 ) { $bb_grow['repeat_max'] = min( ADDON_BOSS_REPEAT_CEILING, $bb_grmax ); } $bb_grlabel = sanitize_text_field( (string) ( $row['repeat_label'] ?? '' ) ); if ( '' !== $bb_grlabel ) { $bb_grow['repeat_label'] = $bb_grlabel; } } $clean[] = $bb_grow; continue; } $c = addon_boss_sanitize_option_row( $row, $field_keys, $set_ids ); if ( $c ) { $clean[] = $c; } } // Conditions second pass over EVERY row (top-level and group children). The // attached-set list is flattened so a rule may point at a set inside a group. // The reveal module validates the rules through this seam; with nothing // hooked, a stored rule rides through exactly as it stands. $attached = array(); foreach ( addon_boss_flatten_option_config( $clean ) as $r ) { if ( isset( $r['set'] ) ) { $attached[] = (string) $r['set']; } } return (array) apply_filters( 'addon_boss_sanitize_option_rows', $clean, $attached ); } /** * Flatten a (possibly grouped) config into a plain list of set/field rows, * unwrapping group containers so the MONEY + resolve paths see every row * regardless of nesting. A field or set inside a group is priced and captured * exactly as a top-level one; grouping is presentation only. */ function addon_boss_flatten_option_config( array $config ): array { $flat = array(); foreach ( $config as $row ) { if ( ( $row['row_type'] ?? '' ) === 'group' ) { foreach ( (array) ( $row['children'] ?? array() ) as $child ) { $flat[] = $child; } } else { $flat[] = $row; } } return $flat; } /** * Flatten a grouped config into a render sequence with explicit group_open / * group_close markers, so the storefront render walks one flat list yet still * knows each section's exact boundaries (never guessing by position). */ function addon_boss_config_render_sequence( array $config ): array { $seq = array(); foreach ( $config as $row ) { if ( ( $row['row_type'] ?? '' ) === 'group' ) { // The whole group row rides the marker, so a module that draws a // group AS a unit (a repeatable group) can see everything the group // carries without a second walk. $seq[] = array( 'row_type' => 'group_open', 'label' => (string) ( $row['label'] ?? '' ), 'group' => $row, ); foreach ( (array) ( $row['children'] ?? array() ) as $child ) { $seq[] = $child; } $seq[] = array( 'row_type' => 'group_close' ); } else { $seq[] = $row; } } return $seq; } /** * Sanitise ONE set-or-field row into its clean stored shape, or null to drop * it. Field keys stay unique across the whole product ($field_keys by ref, so * top-level and grouped fields never collide). This is the exact field/set * logic the money-tested path has always used, lifted out so a group can reuse * it for its children. * * @return array|null */ function addon_boss_sanitize_option_row( array $row, array &$field_keys, array &$set_ids = array() ) { // A FIELD row: reuse the personalisation field-def sanitiser wholesale. if ( ( $row['row_type'] ?? '' ) === 'field' || is_array( $row['field'] ?? null ) ) { if ( ! function_exists( 'addon_boss_personalisation_sanitize_field_defs' ) ) { return null; // personalisation engine off: field rows do not persist. } $defs = addon_boss_personalisation_sanitize_field_defs( array( (array) ( $row['field'] ?? array() ) ) ); if ( ! $defs ) { return null; // no label or unknown type: drop the row. } $field = $defs[0]; $base = $field['key']; $n = 2; while ( in_array( $field['key'], $field_keys, true ) ) { $field['key'] = $base . '-' . $n++; } $field_keys[] = $field['key']; return array( 'row_type' => 'field', 'field' => $field, 'when' => is_array( $row['when'] ?? null ) ? $row['when'] : array(), ); } // A SET row: unchanged from before (the money-tested path). if ( ! addon_boss_option_key_valid( $row['set'] ?? '' ) ) { return null; } // One row per set. Two rows for the same set share a field name, so the // second silently eats the first and the merchant ends up with one answer // where they configured two. The first placement wins, which is where they // put it; the copy simply does not exist. The picker refuses the click // too, but a stale form or a folder taken twice still arrives here. if ( in_array( (string) $row['set'], $set_ids, true ) ) { return null; } $set_ids[] = (string) $row['set']; $deltas = array(); foreach ( (array) ( $row['deltas'] ?? array() ) as $key => $val ) { if ( ! addon_boss_option_key_valid( $key ) || '' === $val || null === $val ) { continue; // absence = use the set default; explicit 0 stays. } $deltas[ $key ] = (float) wc_format_decimal( (string) $val ); } $images = array(); foreach ( (array) ( $row['images'] ?? array() ) as $key => $att ) { $att = absint( $att ); if ( ! addon_boss_option_key_valid( $key ) || ! $att ) { continue; // 0 = no photo (the cleared state), same absence rule as deltas. } $images[ $key ] = $att; } // Which of the shared set's choices this product does not offer. Stored as // what is HIDDEN, never as what is allowed: a set is shared on purpose, and // an allow-list would mean every product silently refusing the colour the // set gains next month. A key the set no longer carries is harmless (the // resolver matches by key), so nothing has to be pruned when a set changes. $hide = array(); foreach ( (array) ( $row['hide'] ?? array() ) as $key => $val ) { // Accepts both shapes: the panel posts key => 1, an import or a // programmatic save reads more naturally as a plain list of keys. $bb_k = is_string( $key ) ? $key : $val; if ( ! is_string( $bb_k ) || ! addon_boss_option_key_valid( $bb_k ) || ! $val ) { continue; } if ( ! in_array( $bb_k, $hide, true ) ) { $hide[] = $bb_k; } } return array( 'set' => (string) $row['set'], 'required' => ! empty( $row['required'] ), 'deltas' => $deltas, 'images' => $images, 'hide' => $hide, // Which library folder this set arrived from, when it came in with the // rest of its folder rather than on its own. Nothing reads it to decide // anything today; it is the record a later "this folder gained a set, // add it here too" nudge needs, and it costs one string to keep. 'from_group' => sanitize_text_field( (string) ( $row['from_group'] ?? '' ) ), 'when' => is_array( $row['when'] ?? null ) ? $row['when'] : array(), ); } /** * Which resolved sets are ACTIVE for one pick state. A set with no rule is * active. A set carrying a reveal rule starts inactive and resolves through * the module that evaluates such rules; with no evaluator registered a rule * was already wiped on read, so everything simply shows. Hidden means INERT * everywhere: never required of the shopper, never charged. That is the * server-side discipline the whole category keeps re-patching the lack of * (hidden fields still charging, hidden required fields blocking checkout, * in rival changelogs for years). * * @param array $sets Resolved sets carrying 'when'. * @param array $picks Set id => currently picked option key. * @return array Set id => active. */ function addon_boss_active_option_sets( array $sets, array $picks ): array { $active = array(); foreach ( $sets as $set ) { // A HIDE rule starts the other way round: the set is there until its // conditions fire, so it seeds ACTIVE and the module that evaluates // rules turns it off. Seeding it inactive like a show rule would leave // a hide-ruled set permanently gone, since the activation pass only // ever turns things on. $bb_when = (array) ( $set['when'] ?? array() ); $active[ $set['id'] ] = ! $bb_when || 'hide' === (string) ( $bb_when['act'] ?? '' ); } return (array) apply_filters( 'addon_boss_active_option_sets', $active, $sets, $picks ); } // ================================================================= // Synthetic variation axes + per-item resolver: the optioned-simple // engine both surfaces render from (virtual axes + render-seam // filters), plus the per-item set resolver that virtual_axes calls. // The cartesian entry list built on those axes is the grid module's. // ================================================================= /** * The product's sets AS THEY APPLY IN ONE BUNDLE ROW. Same shape as * addon_boss_product_option_sets(), then narrowed by the item's per-row * restrictions ($item['option_restrictions'] = { set_id => { off, options } }): * a set switched OFF for this row is dropped, and a set whose options are * restricted keeps only the chosen ones (an empty result drops the set too). * No restrictions ($item null or empty) returns the product-level sets * unchanged, so every existing call site behaves exactly as before. */ function addon_boss_product_option_sets_for_item( $product, $item = null ): array { $sets = addon_boss_product_option_sets( $product ); $rx = is_array( $item ) ? (array) ( $item['option_restrictions'] ?? array() ) : array(); if ( ! $rx ) { return $sets; } $out = array(); foreach ( $sets as $set ) { $rule = (array) ( $rx[ $set['id'] ] ?? array() ); if ( ! empty( $rule['off'] ) ) { continue; // this bundle does not offer the set. } $allow = array_filter( array_map( 'strval', (array) ( $rule['options'] ?? array() ) ) ); if ( $allow ) { $set['options'] = array_values( array_filter( $set['options'], static function ( $opt ) use ( $allow ) { return in_array( (string) $opt['key'], $allow, true ); } ) ); if ( ! $set['options'] ) { continue; // restricted to nothing = not offered. } } $out[] = $set; } return $out; } // ================================================================= // SYNTHETIC VARIATIONS: an optioned simple product enters the EXISTING // variation front end (chips, all-the-same, cards, table, grid) through // virtual attributes + virtual variation entries. Nothing touches the // database; picks resolve server-side by stable option KEYS, and the // virtual variation id is display plumbing only (negative, never loaded). // ================================================================= /** The virtual attribute name for a set (field: attribute_bbset-). */ function addon_boss_virtual_attr_name( string $set_id ): string { return 'bbset-' . $set_id; } /** True when a product renders per-unit choices (real variations or sets). */ function addon_boss_product_has_unit_choices( $product ): bool { return $product instanceof WC_Product && ( $product->is_type( 'variable' ) || (bool) addon_boss_product_option_sets( $product ) ); } /** * Virtual axes for the render: attribute name => option KEYS (stable), * with label + swatch lookups the render seams consume. * * @return array,swatches:array,set:array}> */ function addon_boss_virtual_axes( $product, $item = null, $only_sets = null ): array { // Synthetic variations exist to give an optioned SIMPLE the native // variation front end. A variable product HAS real variations; its sets // render on its own page (the standalone path), never as virtual axes, // so every virtual consumer (unit cards, grids, box cards, candidate // maps) stays untouched by sets on variables through this one gate. if ( $product instanceof WC_Product && $product->is_type( 'variable' ) ) { return array(); } // $only_sets (an allowlist of set ids) narrows the axes to a chosen few. The // quantity grid uses it to keep the matrix to colour x size, leaving any // further set to render as a whole-order choice instead of a cartesian cell. $only = is_array( $only_sets ) ? array_map( 'strval', $only_sets ) : null; $axes = array(); foreach ( addon_boss_product_option_sets_for_item( $product, $item ) as $set ) { if ( ! empty( $set['when'] ) || ! empty( $set['multi'] ) || 'choices' !== ( $set['kind'] ?? 'choices' ) ) { // Neither a conditional set (may be hidden), nor a several-of set // (a unit is one of each axis, never two), nor a kind priced by a // continuous dimension (no finite option list) can be a cartesian // axis. Only the engine's own pick-one choices kind qualifies; // everything else lives on the product's own page. continue; } if ( null !== $only && ! in_array( (string) $set['id'], $only, true ) ) { continue; // not one of the chosen matrix sets. } $labels = array(); $swatches = array(); $swatches2 = array(); $images = array(); $keys = array(); foreach ( $set['options'] as $opt ) { $keys[] = $opt['key']; $labels[ $opt['key'] ] = $opt['label']; $swatches[ $opt['key'] ] = $opt['swatch']; $swatches2[ $opt['key'] ] = $opt['swatch2'] ?? ''; $images[ $opt['key'] ] = (int) ( $opt['image_id'] ?? 0 ); } $axes[ addon_boss_virtual_attr_name( $set['id'] ) ] = array( 'options' => $keys, 'labels' => $labels, 'swatches' => $swatches, 'swatches2' => $swatches2, 'images' => $images, 'set' => $set, ); } return $axes; } // The render seams: pretty labels + real swatches for virtual attributes, // and a human name for the attribute heading. add_filter( 'addon_boss_attr_option_labels', 'addon_boss_virtual_option_labels', 10, 3 ); add_filter( 'addon_boss_attr_option_swatches', 'addon_boss_virtual_option_swatches', 10, 3 ); // The CROSS-PLUGIN seam keeps BundleBoss's names. BundleBoss draws a bundle // component's axes and asks bundle_boss_attr_option_labels / _swatches for // the pretty words and the real colours, whoever answers: the family // surface's shape, BB asks and whichever plugin owns the sets replies. The // namespace sweep renamed the providers along with everything else, which // unhooked them from their only caller and left an answered component's // chips printing raw op_* keys. The SAME callbacks answer both spellings; // the addon_boss_* pair above stays for surfaces of our own. add_filter( 'bundle_boss_attr_option_labels', 'addon_boss_virtual_option_labels', 10, 3 ); add_filter( 'bundle_boss_attr_option_swatches', 'addon_boss_virtual_option_swatches', 10, 3 ); add_filter( 'woocommerce_attribute_label', 'addon_boss_virtual_attribute_label', 10, 2 ); function addon_boss_virtual_option_labels( $labels, $attr_name, $item_product ) { if ( 0 === strpos( (string) $attr_name, 'bbset-' ) && $item_product instanceof WC_Product ) { $axes = addon_boss_virtual_axes( $item_product ); if ( isset( $axes[ $attr_name ] ) ) { return $axes[ $attr_name ]['labels']; } } return $labels; } function addon_boss_virtual_option_swatches( $swatches, $attr_name, $item_product ) { if ( 0 === strpos( (string) $attr_name, 'bbset-' ) && $item_product instanceof WC_Product ) { $axes = addon_boss_virtual_axes( $item_product ); if ( isset( $axes[ $attr_name ] ) ) { return array_filter( $axes[ $attr_name ]['swatches'] ); } } return $swatches; } function addon_boss_virtual_attribute_label( $label, $name ) { if ( 0 === strpos( (string) $name, 'bbset-' ) ) { $set = addon_boss_option_set( substr( (string) $name, 6 ) ); if ( $set && '' !== $set['name'] ) { return $set['name']; } } return $label; } // ================================================================= // Griddability: whether this product's own page shows the quantity // grid at all. The grid itself (the matrix, the set split, its save) // is the grid module's; the engine keeps only the gate it asks. // ================================================================= /** Whether this product is set to show the quantity grid on its own page. */ function addon_boss_product_uses_grid( $product ): bool { // The standalone quantity grid is a product's-own-page feature and belongs // to the plugin whose job that is. The gate sits here because everything the // grid does flows through this one question: the page takeover, its assets, // the AJAX add and the admin layout control. A variable product used to slip // past, because the can-grid check below answers "variable" before ever // touching the gated option config. if ( ! addon_boss_options_available() ) { return false; } // Without the grid module a product is ordered one at a time. Its stored grid // layout is left untouched for when the module arrives. if ( ! addon_boss_engine_can( 'grid' ) ) { return false; } if ( ! $product instanceof WC_Product || 'grid' !== (string) $product->get_meta( '_bundle_boss_order_layout' ) || ! function_exists( 'addon_boss_grid_render_card_body' ) ) { return false; } // A variable product grids across its variations; an optioned SIMPLE grids // across its choice sets (BundleBoss's "options, not variations" flagship). return addon_boss_product_can_grid( $product ); } /** * Whether a product has something to grid: real variations, or 2+ griddable * choice sets on a simple product (colour, size...). A single griddable set * still grids as one row of option cells, so one axis is enough. */ function addon_boss_product_can_grid( $product ): bool { if ( ! $product instanceof WC_Product ) { return false; } if ( $product->is_type( 'variable' ) ) { return true; } return function_exists( 'addon_boss_virtual_axes' ) && count( addon_boss_virtual_axes( $product ) ) >= 1; } // ================================================================= // STANDALONE SURFACE (product options on a plain product, no bundle). // Lifted from the earlier bundle-side layout into the shared engine so both // plugins expose it and it registers exactly once. Part 1: resolvers + key. // ================================================================= const ADDON_BOSS_STANDALONE_OPTIONS_KEY = 'bundle_boss_standalone_options'; /** * The sets a product offers on its OWN page: simple products only. The * resolver already refuses variable products (variations own that job); * this also refuses bundles and every other composite type. */ /** * The form field for one set's pick on a STANDALONE page. Deliberately * OUTSIDE WooCommerce's attribute_ namespace: on a variable product, * anything named attribute_* is treated by WooCommerce's scripts and its * variable add handler as a variation attribute, so our hidden selects * were read as unchosen attributes and the add button locked with * "please select some product options". Inside bundles the native * virtual-variation path keeps its attribute_ names; that is a different * form with different owners. */ function addon_boss_standalone_field_name( string $set_id ): string { return 'bbpick_' . sanitize_title( addon_boss_virtual_attr_name( $set_id ) ); } function addon_boss_standalone_option_sets( $product ): array { // The sets render on the product's own page (below the variations form on a // variable parent, the placement every rival converged on). A product type // this build does not carry sets for renders nothing, and its stored config // is left untouched for when the module that does arrives. if ( ! $product instanceof WC_Product || ! in_array( $product->get_type(), addon_boss_options_product_types(), true ) ) { return array(); } $bb_sets = addon_boss_product_option_sets( $product ); // A set of a kind nothing registered is dropped from the offer: the build // has no maths to price it, and offering it would sell an unchargeable // thing. Its stored config is untouched. $bb_kinds = addon_boss_option_set_kinds(); $bb_sets = array_values( array_filter( $bb_sets, static function ( $bb_s ) use ( $bb_kinds ) { return isset( $bb_kinds[ (string) ( $bb_s['kind'] ?? 'choices' ) ] ); } ) ); // Without conditional reveal a set's stored rule is ignored and it simply // shows, which is the honest reading of "show this when" with nothing to // evaluate it. if ( ! addon_boss_engine_can( 'reveal' ) ) { foreach ( $bb_sets as &$bb_set ) { $bb_set['when'] = array(); } unset( $bb_set ); } return $bb_sets; } /** * How a field's answer is named in the rule state. Sets are keyed by their own * id, so fields take a prefix nothing else can collide with: a set id is a * sanitised key and can never contain a colon. */ const ADDON_BOSS_FIELD_RULE_KEY = 'field:'; /** * What "there is an answer" and "there is not" are CALLED, for one field type. * * Two stored operators, four or five pairs of words. A tick box is ticked, not * "answered"; a text box is filled in; an upload has a file. One generic pair * for all of them reads like nothing anybody says out loud, and a rule you * cannot say out loud is a rule you cannot check. * * @param string $type The trigger field's type. * @return array{filled:string,empty:string} */ function addon_boss_field_op_words( string $type ): array { switch ( $type ) { case 'checkbox': $words = array( 'filled' => __( 'is ticked', 'addon-boss' ), 'empty' => __( 'is not ticked', 'addon-boss' ) ); break; case 'file': $words = array( 'filled' => __( 'has a file', 'addon-boss' ), 'empty' => __( 'has no file', 'addon-boss' ) ); break; case 'select': $words = array( 'filled' => __( 'is chosen', 'addon-boss' ), 'empty' => __( 'is not chosen', 'addon-boss' ) ); break; default: // Text, message, number, date, time, and any type a module adds // without saying otherwise. $words = array( 'filled' => __( 'is filled in', 'addon-boss' ), 'empty' => __( 'is empty', 'addon-boss' ) ); } // A module with a field type of its own names its own two words here. return (array) apply_filters( 'addon_boss_field_op_words', $words, $type ); } /** * A field's answer flattened to the one string a rule compares against. * * A field asked several times (repeatable, several uploads) answers with the * FIRST thing it was actually given, so "is filled in" means "they filled in at * least one", which is what a merchant writing the rule means by it. * * @param mixed $raw Whatever the request carries for the field. * @return string */ function addon_boss_field_rule_value( $raw ): string { if ( is_array( $raw ) ) { foreach ( $raw as $bb_v ) { $bb_flat = addon_boss_field_rule_value( $bb_v ); if ( '' !== $bb_flat ) { return $bb_flat; } } return ''; } if ( is_bool( $raw ) ) { return $raw ? '1' : ''; } return is_scalar( $raw ) ? trim( (string) $raw ) : ''; } /** * Add every field's answer to the rule state, so a rule can be triggered by a * field and not only by a set. * * Walked in ROW ORDER, and a field's own rule is judged against what came * BEFORE it. That is why the builder only offers earlier fields as triggers: it * removes the need for a fixpoint over field answers, and it makes a cycle * impossible by construction rather than by detection. It is also honest in the * merchant's own terms, because a rule cannot depend on an answer nobody has * been asked for yet. * * A field that is itself hidden is marked inactive, so a stale answer nobody * can see never satisfies the next rule along. That is the same discipline sets * have always had. * * @param array $fields The product's fields, in row order. * @param mixed $posted What the request carries under bb_pf. * @param array $picks Set id => picked keys; gains one entry per field. * @param array $active Set id => active; gains one entry per field. */ function addon_boss_add_field_rule_state( array $fields, $posted, array &$picks, array &$active ): void { foreach ( $fields as $bb_def ) { $bb_key = (string) ( $bb_def['key'] ?? '' ); if ( '' === $bb_key ) { continue; } $bb_id = ADDON_BOSS_FIELD_RULE_KEY . $bb_key; $bb_when = is_array( $bb_def['when'] ?? null ) ? $bb_def['when'] : array(); // Judged against the state so far, which is exactly the rows above it. $bb_vis = ! $bb_when || (bool) apply_filters( 'addon_boss_when_met', true, $bb_when, $picks, $active ); $picks[ $bb_id ] = addon_boss_field_rule_value( is_array( $posted ) ? ( $posted[ $bb_key ] ?? null ) : null ); $active[ $bb_id ] = $bb_vis; } } /** * Does a fired rule take this row out of the MARKUP, rather than just out of * sight? It never does, and this function exists to be the one place that says * why. * * A rule that can fire can also un-fire. Tick the box and the upload goes; * untick it and the upload has to come back, which it cannot do if the server * left it out of the page. So every conditional row is drawn and the stylesheet * decides what is on screen, which is what lets the storefront script move a * row either way without a reload. * * Nothing about the money changes. A hidden row is inert on the server whatever * its input happens to be carrying, so a stale value in a hidden field is read * by nobody, charged by nobody and required of nobody. * * @param array $when The stored rule. * @param bool $active Whether the rule currently leaves the row available. * @return bool Always false. */ function addon_boss_rule_removes_row( $when, bool $active ): bool { unset( $when, $active ); return false; } /** * One set's posted picks, cleaned: an array of sanitised keys in the SET'S * OWN option order, so the same picks always snapshot in the same order and * the cart id they hash to can never depend on click order. Duplicates * collapse to one. A pick-one set reads its scalar exactly as before, and an * array posted against one reads as no answer (a crafted shape, never a * fatal). A key the set does not carry is KEPT, at the end, so the resolver * still refuses it by name. Bounded before any work, so a crafted * thousand-entry payload costs nothing. * * @param array $set The resolved set (its 'multi' flag and options). * @param mixed $raw What the request carries for the set's field (slashed). * @return string[] Picked keys; empty when nothing was picked. */ function addon_boss_standalone_posted_picks( array $set, $raw ): array { $bb_multi = ! empty( $set['multi'] ); if ( is_array( $raw ) ) { if ( ! $bb_multi ) { return array(); } $bb_vals = array_slice( array_values( $raw ), 0, 100 ); } else { $bb_vals = array( $raw ); } $bb_picked = array(); foreach ( $bb_vals as $bb_v ) { if ( is_array( $bb_v ) ) { continue; // a nested array is noise, never a pick. } $bb_v = sanitize_text_field( wp_unslash( (string) $bb_v ) ); if ( '' !== $bb_v ) { $bb_picked[] = $bb_v; } } $bb_picked = array_values( array_unique( $bb_picked ) ); if ( ! $bb_multi ) { return array_slice( $bb_picked, 0, 1 ); } $bb_order = array(); foreach ( (array) ( $set['options'] ?? array() ) as $bb_opt ) { $bb_order[] = (string) ( $bb_opt['key'] ?? '' ); } return array_merge( array_values( array_intersect( $bb_order, $bb_picked ) ), array_values( array_diff( $bb_picked, $bb_order ) ) ); } /** * Resolve one request's standalone picks into cart snapshots via the SAME * resolver the bundle gate uses (addon_boss_option_snapshot), reading the * native field names (attribute_bbset-) from $source. WP_Error * naming the set when a required set has no pick or a posted key is not in * its set; the snapshot array otherwise (empty when nothing was picked). A * several-of set resolves every posted key and enforces its own bounds here, * server-side, whatever any client allowed. * * @param array $source Raw request fields (slashed; sanitised per field). * @return array|WP_Error */ /** * A several-of set's picks, checked against its bounds, as snapshots. * * Every posted key becomes its own snapshot carrying its own fee, and the * bounds are enforced HERE, whatever any client allowed. An optional set left * entirely alone is no answer rather than a short answer, so its at-least only * speaks once the shopper has engaged with it. * * This lives on its own because TWO surfaces order these sets: the product's * own page and the quantity grid's whole-order block. Two copies of a rule * about what a shopper may pick, and what each pick costs, is two rules that * drift, and the drift shows up as money. * * @param array $set A resolved set with multi set. * @param array $keys The keys the shopper picked, in any order. * @return array|WP_Error One snapshot per pick, or the shopper's own message. */ function addon_boss_multi_pick_snapshots( array $set, array $keys ) { $name = (string) ( $set['name'] ?? '' ); $count = count( $keys ); $min = max( 0, (int) ( $set['min'] ?? 0 ) ); $max = max( 0, (int) ( $set['max'] ?? 0 ) ); if ( 0 === $count ) { if ( ! empty( $set['required'] ) ) { return new WP_Error( 'bundle_boss', $min > 1 /* translators: 1: the smallest number of choices, 2: option set name */ ? sprintf( __( 'Choose at least %1$s for %2$s.', 'addon-boss' ), number_format_i18n( $min ), $name ) /* translators: %s: option set name */ : sprintf( __( 'Please choose %s.', 'addon-boss' ), $name ) ); } return array(); } if ( $count < $min ) { return new WP_Error( 'bundle_boss', /* translators: 1: the smallest number of choices, 2: option set name */ sprintf( __( 'Choose at least %1$s for %2$s.', 'addon-boss' ), number_format_i18n( $min ), $name ) ); } if ( $max > 0 && $count > $max ) { return new WP_Error( 'bundle_boss', /* translators: 1: the largest number of choices, 2: option set name */ sprintf( __( 'Choose up to %1$s for %2$s.', 'addon-boss' ), number_format_i18n( $max ), $name ) ); } $snapshots = array(); foreach ( $keys as $key ) { $snap = addon_boss_option_snapshot( $set, (string) $key ); if ( null === $snap ) { return new WP_Error( 'bundle_boss', /* translators: %s: option set name */ sprintf( __( 'That %s choice is not available any more. Please pick another.', 'addon-boss' ), $name ) ); } $snapshots[] = $snap; } return $snapshots; } function addon_boss_standalone_option_snapshots( WC_Product $product, array $source ) { $sets = addon_boss_standalone_option_sets( $product ); // Every set's raw pick first, then the activity map: a hidden set is // INERT, so its posted value is discarded (never charged, the crafted // POST case) and its required flag never fires (the blocked-checkout // case). Both are the category's admitted failure classes. A pick-one // set's entry stays the string it always was; a several-of set's entry // is its array of keys. $picks = array(); foreach ( $sets as $set ) { $field = addon_boss_standalone_field_name( $set['id'] ); $bb_pp = addon_boss_standalone_posted_picks( $set, $source[ $field ] ?? '' ); $picks[ $set['id'] ] = ! empty( $set['multi'] ) ? $bb_pp : (string) ( $bb_pp[0] ?? '' ); } $active = addon_boss_active_option_sets( $sets, $picks ); $snapshots = array(); foreach ( $sets as $set ) { if ( empty( $active[ $set['id'] ] ) ) { continue; // hidden means inert. } // A kind priced off its own panel (a measured size, a price table) // resolves through the module that registered it; the sets list above // already dropped any kind nothing did. $bb_kind = (string) ( $set['kind'] ?? 'choices' ); if ( 'choices' !== $bb_kind ) { $bb_cb = addon_boss_option_set_kinds()[ $bb_kind ]['snapshot'] ?? null; if ( ! is_callable( $bb_cb ) ) { continue; } $bb_snap = call_user_func( $bb_cb, $set, $source ); if ( is_wp_error( $bb_snap ) ) { return $bb_snap; } if ( is_array( $bb_snap ) ) { $snapshots[] = $bb_snap; } continue; } // Several-of: the bounds and the snapshots come from the one helper that // both ordering surfaces call, so the single-pick page and the quantity // grid can never disagree about what is allowed or what it costs. if ( ! empty( $set['multi'] ) ) { $bb_multi = addon_boss_multi_pick_snapshots( $set, (array) $picks[ $set['id'] ] ); if ( is_wp_error( $bb_multi ) ) { return $bb_multi; } foreach ( $bb_multi as $bb_snap ) { $snapshots[] = $bb_snap; } continue; } $picked = $picks[ $set['id'] ]; if ( '' === $picked ) { if ( $set['required'] ) { return new WP_Error( 'bundle_boss', /* translators: %s: option set name */ sprintf( __( 'Please choose %s.', 'addon-boss' ), $set['name'] ) ); } continue; } $snap = addon_boss_option_snapshot( $set, $picked ); if ( null === $snap ) { return new WP_Error( 'bundle_boss', /* translators: %s: option set name */ sprintf( __( 'That %s choice is not available any more. Please pick another.', 'addon-boss' ), $set['name'] ) ); } $snapshots[] = $snap; } return $snapshots; } /** * A snapshot's charge as a display suffix, or '' for no charge. The helper * that converts it is the one every bundle option figure goes through * (addon_boss_price_to_display); it only converts positive amounts, so the * magnitude converts and the sign is reapplied (deltas may be negative). */ function addon_boss_standalone_charge_suffix( array $snap, $product, string $context ): string { $fee = (float) ( $snap['delta'] ?? 0 ); if ( 0.0 === $fee || ! $product instanceof WC_Product ) { return ''; } $shown = addon_boss_price_to_display( $product, abs( $fee ), $context ); // A look-up table on the "sets the whole price" basis is not a surcharge: the // cell REPLACES the product price, so the line and this figure are the same // money. Printing "+£115.20" beside a £115.20 line reads as a double charge. if ( 'set' === ( $snap['table_basis'] ?? '' ) ) { return ' (' . wc_price( $shown ) . ')'; } return ' (' . ( $fee > 0 ? '+' : '-' ) . wc_price( $shown ) . ')'; } /** * The SAME charge suffix as plain text, for the readable order rows. The one * formatter builds the suffix (above); this only takes its markup off. * wp_strip_all_tags() ends in trim(), which ate the suffix's leading space: * the cart read "Sandalwood (+£2.00)" while the thank-you page, the order * screen and the emails read "Sandalwood(+£2.00)". The space is the suffix's * own first character, so it is put back after the strip; an empty suffix * stays exactly empty. */ function addon_boss_standalone_suffix_text( string $suffix ): string { if ( '' === $suffix ) { return ''; } return ' ' . trim( wp_strip_all_tags( $suffix ) ); } // ---- Product fields: priced custom inputs on the product's own page ---- // // A field shares the set's shape exactly: optional / required (or "required // once personalising"), an "available only when a set is picked" reveal, and a // charge. The money joins the SAME cart line as the sets (one set_price), so // the two paths can never drift. A hidden field (its reveal rule unmet) is // INERT: never resolved, never charged, its required flag never fires, the // same server-side discipline the sets keep. const ADDON_BOSS_PRODUCT_FIELDS_KEY = 'bundle_boss_product_fields'; const ADDON_BOSS_PRODUCT_FIELDS_META = '_bundle_boss_product_fields'; // The most instances one repeatable field can ever open, whatever the merchant // typed and whatever the browser posts. A shopper cannot be allowed to grow the // payload without limit, but the ceiling has to sit ABOVE the size runs // merchants actually take: quantity discounts make 100 and 150 normal // quantities, and a per-item field's promise is one entry per unit. Past a // handful the storefront folds the list into one paste-the-list box and the // cart folds the rows into a summary, so nothing ever draws hundreds of cards // however much this allows. const ADDON_BOSS_REPEAT_CEILING = 200; /** * One posted field value, cleaned to what its type is allowed to carry. * * The single-instance path and each instance of a repeatable one go through * this same function, so a repeated measured field cannot accept a shape the * plain one refuses. * * @param array $types The field type register. * @param string $type This field's type. * @param mixed $posted What arrived for it. * @return array|string|null */ function addon_boss_field_normalise_posted( array $types, string $type, $posted ) { if ( null === $posted ) { return null; } if ( is_array( $posted ) ) { // Only a MEASURED field posts an array (its two dimensions). Every // other type stays scalar-only, so an array smuggled in against a // text or file field is still dropped exactly as before. if ( empty( $types[ $type ]['is_measure'] ) ) { return null; } return array( 'w' => isset( $posted['w'] ) ? sanitize_text_field( wp_unslash( (string) $posted['w'] ) ) : '', 'h' => isset( $posted['h'] ) ? sanitize_text_field( wp_unslash( (string) $posted['h'] ) ) : '', ); } return wp_unslash( (string) $posted ); } /** * The priced fields a product offers on its OWN page, each field def carrying * its reveal rule ('when') folded in. Same surfaces as the sets (simple * products and variable parents); anything else offers none. * * @return array */ function addon_boss_standalone_product_fields( $product ): array { if ( ! $product instanceof WC_Product || ! ( $product->is_type( 'simple' ) || $product->is_type( 'variable' ) ) ) { return array(); } $out = array(); // One field row to its read-ready def, or null when this build cannot // offer its type. The exact logic the flat walk has always applied. $bb_read = static function ( array $row ) { if ( ( $row['row_type'] ?? '' ) !== 'field' || ! is_array( $row['field'] ?? null ) ) { return null; } $def = $row['field']; // Without conditional reveal a field's stored rule is ignored, so it // simply shows. The rule itself is left untouched. $def['when'] = ( addon_boss_engine_can( 'reveal' ) && is_array( $row['when'] ?? null ) ) ? $row['when'] : array(); // A field TYPE this build does not offer is inert: dropped from the set // every downstream reader uses, so it never prices, validates/blocks or // renders. A field priced by an unavailable fee MODE is KEPT (its input // still works; the fee is zeroed in resolve_fee). The stored config is // never rewritten, so the field returns whole with its module. if ( ! addon_boss_field_type_available( (string) ( $def['type'] ?? '' ) ) ) { return null; } return $def; }; // The walk keeps each group's identity in hand: a field inside a GROUP is // captured + priced exactly like a top-level one (grouping is presentation, // never a money boundary), and when the group is marked repeatable its // members are STAMPED with the group's repeat facts under '_grp', a derived // read-time key that is never stored. The module that owns repeats reads // the stamp; with no module the stamp is inert and every field asks once. // A quantity-grid product is never stamped: the grid captures a quantity // per variation and nothing per line, so there is no line quantity for a // block to follow and no per-line blocks to draw (the same deferral the // per-field per_item mode already makes there). $bb_grid = function_exists( 'addon_boss_product_uses_grid' ) && addon_boss_product_uses_grid( $product ); $bb_gi = -1; foreach ( (array) addon_boss_product_option_config( $product ) as $row ) { if ( ( $row['row_type'] ?? '' ) === 'group' ) { ++$bb_gi; $bb_gmode = (string) ( $row['repeat'] ?? '' ); $bb_stamp = ! $bb_grid && in_array( $bb_gmode, array( 'add', 'per_item' ), true ); $bb_kids = (array) ( $row['children'] ?? array() ); // The member keys, in order, so the module can read the whole // block's posted lists from any one member's seam call. $bb_keys = array(); if ( $bb_stamp ) { foreach ( $bb_kids as $bb_kid ) { $bb_kdef = is_array( $bb_kid ) ? $bb_read( $bb_kid ) : null; if ( null !== $bb_kdef && '' !== (string) ( $bb_kdef['key'] ?? '' ) ) { $bb_keys[] = (string) $bb_kdef['key']; } } } foreach ( $bb_kids as $bb_kid ) { $def = is_array( $bb_kid ) ? $bb_read( $bb_kid ) : null; if ( null === $def ) { continue; } if ( $bb_stamp ) { $def['_grp'] = array( 'index' => $bb_gi, 'label' => (string) ( $row['label'] ?? '' ), 'repeat' => $bb_gmode, 'repeat_max' => (int) ( $row['repeat_max'] ?? 0 ), 'repeat_label' => (string) ( $row['repeat_label'] ?? '' ), 'keys' => $bb_keys, ); } $out[] = $def; } continue; } $def = $bb_read( $row ); if ( null !== $def ) { $out[] = $def; } } return $out; } /** * Resolve one request's field inputs into cart snapshots via the SAME * personalisation resolvers the bundle add-ons use, so text / number / tick / * dropdown / file behave identically here and there. A field revealed "only * when" a set is picked reads its activity off the SAME set picks the option * snapshots use: hidden means INERT (its posted value discarded, never * charged; its required flag never fires). Returns WP_Error naming the field * when a required (or "required once personalising") field is left empty; the * snapshot array otherwise (empty when nothing was filled). * * Each snapshot freezes the resolved fee. A 'once' fee is a WHOLE-LINE charge * (the price seam spreads it across the line quantity so quantity can never * multiply it); every other mode is per-unit and multiplies natively. * * @param array $source Raw request fields (slashed; sanitised per field). * @return array|WP_Error */ function addon_boss_standalone_field_snapshots( WC_Product $product, array $source ) { $fields = addon_boss_standalone_product_fields( $product ); if ( ! $fields ) { return array(); } $types = function_exists( 'addon_boss_personalisation_field_types' ) ? addon_boss_personalisation_field_types() : array(); // Field reveal rides on the SAME set picks the option snapshots resolve, so // a field shown "only when Style is Printed" is inert while Plain is picked. // The same reader as the resolver: a several-of set's entry is its array. $sets = addon_boss_standalone_option_sets( $product ); $picks = array(); foreach ( $sets as $set ) { $f = addon_boss_standalone_field_name( $set['id'] ); $bb_pp = addon_boss_standalone_posted_picks( $set, $source[ $f ] ?? '' ); $picks[ $set['id'] ] = ! empty( $set['multi'] ) ? $bb_pp : (string) ( $bb_pp[0] ?? '' ); } $active_sets = addon_boss_active_option_sets( $sets, $picks ); // Fields join the rule state too, so a rule can be triggered by a tick box // and not only by an option set. addon_boss_add_field_rule_state( $fields, $source['bb_pf'] ?? null, $picks, $active_sets ); // The variation being bought, so a field restricted to certain variations // stays inert for the rest. $chosen_variation = isset( $source['variation_id'] ) ? absint( $source['variation_id'] ) : 0; // First pass: resolve every ACTIVE, input-bearing field. A section label is // display-only, so it carries no value and never resolves. $resolved_rows = array(); $any_filled = false; foreach ( $fields as $def ) { $key = (string) ( $def['key'] ?? '' ); $type = (string) ( $def['type'] ?? '' ); if ( '' === $key || ! empty( $types[ $type ]['display_only'] ) ) { continue; } if ( ! addon_boss_field_applies_to_variation( $def, $chosen_variation ) ) { continue; // inert for this variation: never required, never charged. } $when = is_array( $def['when'] ?? null ) ? $def['when'] : array(); if ( $when && ! (bool) apply_filters( 'addon_boss_when_met', true, $when, $picks, $active_sets ) ) { // The module that evaluates reveal rules answered no: hidden means // inert, never resolved, never charged, never blocking. With no // evaluator registered a rule was already wiped on read, and the // default answer keeps everything visible. continue; } $posted = $source['bb_pf'][ $key ] ?? null; // A module that asks one field several times (repeatable fields) // answers this seam with one entry per instance: the posted value, // the instance's own label, its number from 1, and whether its money // is whole-line (a per-item repeat already scales with the quantity). // With nothing registered every field asks exactly once, as it always // has, and a stored repeat setting waits, unhonoured and untouched. $bb_instances = apply_filters( 'addon_boss_field_instances', null, $def, $posted, $source ); if ( is_array( $bb_instances ) ) { foreach ( $bb_instances as $bb_inst ) { $resolved = addon_boss_personalisation_resolve_value( $def, addon_boss_field_normalise_posted( $types, $type, $bb_inst['value'] ?? null ) ); if ( null !== $resolved ) { $any_filled = true; } $resolved_rows[] = array( 'def' => $def, 'resolved' => $resolved, 'instance' => (int) ( $bb_inst['instance'] ?? 0 ), 'label' => (string) ( $bb_inst['label'] ?? '' ), 'spread' => ! empty( $bb_inst['spread'] ), ); } continue; } $resolved = addon_boss_personalisation_resolve_value( $def, addon_boss_field_normalise_posted( $types, $type, $posted ) ); if ( null !== $resolved ) { $any_filled = true; } $resolved_rows[] = array( 'def' => $def, 'resolved' => $resolved ); } // A module that resolves several fields as ONE block (a repeatable group) // reorders the rows here so the line reads block by block, the way the // shopper filled it in, and the required gate below accuses the first gap // in READING order. With nothing registered the rows stand exactly as the // loop built them, which is the order every line has always had. $resolved_rows = (array) apply_filters( 'addon_boss_field_resolved_rows', $resolved_rows ); // Second pass: gate required / "required once personalising", then freeze // the fee for each filled field into a snapshot. $once_charged = array(); $snapshots = array(); foreach ( $resolved_rows as $row ) { $def = $row['def']; $resolved = $row['resolved']; $instance = (int) ( $row['instance'] ?? 0 ); // A repeatable field names each one (the instances seam carried the // name), so an unfilled third card reads "Please complete Name 3" and // the shopper knows which to go back to rather than being sent to // look at the field in general. $label = '' !== (string) ( $row['label'] ?? '' ) ? (string) $row['label'] : (string) ( $def['label'] ?? '' ); if ( null === $resolved ) { $blocks = ! empty( $def['required'] ) || ( 'engaged' === ( $def['require_when'] ?? '' ) && $any_filled ); if ( $blocks ) { return new WP_Error( 'bundle_boss', /* translators: %s: the field's label */ sprintf( __( 'Please complete %s.', 'addon-boss' ), $label ) ); } continue; } // A floor on the file count ("send at least two views"). Checked HERE, on // the server, because the browser count is a courtesy: the add-to-cart post // is what decides, and a short set must not reach the cart at all. Only a // field that WAS filled is measured, so an optional upload left empty is // still optional. $min_files = max( 0, (int) ( $def['min_files'] ?? 0 ) ); if ( $min_files > 1 && 'file' === ( $def['type'] ?? '' ) ) { $sent = count( array_filter( array_map( 'trim', explode( ',', (string) $resolved['value'] ) ) ) ); if ( $sent < $min_files ) { return new WP_Error( 'bundle_boss', sprintf( /* translators: 1: the field's label, 2: the smallest number of files accepted, 3: how many arrived */ __( 'Almost there: %1$s needs at least %2$s files and you have added %3$s so far. Please add the rest.', 'addon-boss' ), (string) ( $def['label'] ?? '' ), number_format_i18n( $min_files ), number_format_i18n( $sent ) ) ); } } // The product's own per-unit base, so a percent field takes its share of // the same figure the line is priced from. // The instance number is what a "once" fee is counted against, so each one // the shopper opens carries its own setup charge. Passing 0 for every // instance, as a field that asks once does, would let the first engraving // charge and silently give away the rest. // // This is also where the merchant chooses between the two ways a repeated // charge can behave, using the fee mode they already set: // unit a charge per instance ON EVERY ITEM. Two mugs, three engravings, // six charges. The category's usual reading of a per-item fee. // once a charge per instance for the WHOLE LINE. Three engravings cost // three setups however many mugs are bought, because the price seam // spreads a "once" fee across the units. $fee = addon_boss_personalisation_resolve_fee( $def, $resolved, $instance, $once_charged, addon_boss_options_base_price( $product ) ); $snap = array( 'key' => (string) $def['key'], 'label' => $label, 'type' => (string) ( $def['type'] ?? 'text' ), 'value' => (string) $resolved['value'], 'display' => (string) $resolved['display'], 'url' => (string) ( $resolved['url'] ?? '' ), 'fee' => (float) $fee, 'fee_mode' => (string) ( $def['fee_mode'] ?? 'unit' ), ); // Whatever a module froze alongside the value (the typeface and colour // pick for a placement field that offers one) rides this seam, so the // cart row, the order line and a reorder all state the exact print. // With nothing registered the snapshot stays byte-identical to the one // this engine has always written. $snap = (array) apply_filters( 'addon_boss_standalone_field_snapshot', $snap, $def, $source ); if ( $instance > 0 ) { // Which one of several this is. Written ONLY for a repeatable field, // so an ordinary field's snapshot stays byte-identical to the one it // has always written and every order already placed reads back // unchanged. $snap['instance'] = $instance; if ( ! empty( $row['spread'] ) ) { // A per-item repeat opens one instance for every unit bought, so // the instances already scale with the quantity. WooCommerce then // multiplies the unit price by that same quantity, which would // charge six names on six shirts thirty-six times over. Marked to // be spread across the units, exactly as a "once" fee is, so the // line lands back on one charge per name. $snap['spread'] = true; } } $snapshots[] = $snap; } return $snapshots; } /** * A field snapshot's frozen charge as a display suffix, or '' for no charge. * Fees are never negative (the sanitiser floors at zero), so the sign is * always '+'. Converts through the SAME helper the option rows use, so a * field's money reads in the same basis as a set's on the same line. */ function addon_boss_standalone_field_charge_suffix( array $fsnap, $product, string $context ): string { $fee = (float) ( $fsnap['fee'] ?? 0 ); if ( $fee <= 0.0 || ! $product instanceof WC_Product ) { return ''; } return ' (+' . wc_price( addon_boss_price_to_display( $product, $fee, $context ) ) . ')'; } // ================================================================= // STANDALONE SURFACE part 2: native-WooCommerce cart/order path (validate, // capture, price, cart display, order line, order-again). Lifted from // slot-options; registers once via the load-once engine (no double-charge). // ================================================================= // ---- Validate + capture ----------------------------------------- add_filter( 'woocommerce_add_to_cart_validation', 'addon_boss_validate_standalone_options', 10, 6 ); function addon_boss_validate_standalone_options( $passed, $product_id, $quantity = 1, $variation_id = 0, $variations = array(), $cart_item_data = array() ) { if ( ! $passed ) { return $passed; } // A bundle add configures its components through the bundle's own gate. if ( isset( $_REQUEST['bundle_selections'] ) || isset( $_REQUEST['bundle_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- presence check only, to stand down for the bundle's own flow. return $passed; } // Order again hands the restored snapshots through (6th argument): they // were validated at the original add and deliberately keep their charge. if ( is_array( $cart_item_data ) && ( ! empty( $cart_item_data['bundle_boss'] ) || isset( $cart_item_data[ ADDON_BOSS_STANDALONE_OPTIONS_KEY ] ) || isset( $cart_item_data[ ADDON_BOSS_STANDALONE_UPLOAD_KEY ] ) || isset( $cart_item_data[ ADDON_BOSS_PRODUCT_FIELDS_KEY ] ) ) ) { return $passed; } $product = wc_get_product( absint( $product_id ) ); if ( ! $product || ( ! addon_boss_standalone_option_sets( $product ) && ! addon_boss_standalone_product_fields( $product ) ) ) { return $passed; } $result = addon_boss_standalone_option_snapshots( $product, $_REQUEST ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- WooCommerce's add-to-cart flow carries no nonce; each field is sanitised and resolved against the stored set inside. if ( is_wp_error( $result ) ) { wc_add_notice( $result->get_error_message(), 'error' ); return false; } // Per-option stock: refuse an over-order, counting what the cart already // holds, less the line this request is replacing. $bb_stock = addon_boss_check_option_stock( $product, is_array( $result ) ? $result : array(), (int) $quantity, addon_boss_editing_cart_line( absint( $product_id ) ) ); if ( is_wp_error( $bb_stock ) ) { wc_add_notice( $bb_stock->get_error_message(), 'error' ); return false; } // Fields share the gate: a required (or "required once personalising") // field left empty blocks the add, unless it is hidden by an unmet reveal // rule, in which case it is inert and never asked for. if ( addon_boss_standalone_product_fields( $product ) ) { $fields = addon_boss_standalone_field_snapshots( $product, $_REQUEST ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- each field sanitised and resolved inside. if ( is_wp_error( $fields ) ) { wc_add_notice( $fields->get_error_message(), 'error' ); return false; } } return $passed; } add_filter( 'woocommerce_add_cart_item_data', 'addon_boss_capture_standalone_options', 10, 4 ); function addon_boss_capture_standalone_options( $cart_item_data, $product_id, $variation_id = 0, $quantity = 1 ) { if ( ! is_array( $cart_item_data ) ) { $cart_item_data = array(); } // A bundle add configures its components through the bundle's own gate, so // the picks are not re-read here. The one thing that gate cannot know is // our per-option count, and a jar sold inside a bundle comes off the same // shelf as one sold on its own page, so that is checked before the bundle // reaches the cart. The payload already carries validated selections. if ( ! empty( $cart_item_data['bundle_boss'] ) ) { $bb_demand = addon_boss_bundle_option_demand( $cart_item_data['bundle_boss']['selections'] ?? array(), max( 1, (int) $quantity ) ); foreach ( $bb_demand as $bb_row ) { $bb_short = addon_boss_check_option_stock( null, array( array( 'set' => $bb_row['set'], 'key' => $bb_row['key'], 'label' => $bb_row['label'], ), ), (int) $bb_row['qty'] ); if ( is_wp_error( $bb_short ) ) { throw new Exception( esc_html( $bb_short->get_error_message() ) ); } } return $cart_item_data; } // A line arriving with snapshots already made (order again, or any // programmatic add that built its own picks) keeps them: they were // resolved and priced once and must not be re-read from the request. // Stock is the exception. What was on the shelf when the first order was // placed can be gone now, so the count is asked again even though the // picks are not. if ( isset( $cart_item_data[ ADDON_BOSS_STANDALONE_OPTIONS_KEY ] ) || isset( $cart_item_data[ ADDON_BOSS_STANDALONE_UPLOAD_KEY ] ) || isset( $cart_item_data[ ADDON_BOSS_PRODUCT_FIELDS_KEY ] ) ) { $bb_restored = $cart_item_data[ ADDON_BOSS_STANDALONE_OPTIONS_KEY ] ?? array(); $bb_product = is_array( $bb_restored ) && $bb_restored ? wc_get_product( absint( $product_id ) ) : null; if ( $bb_product ) { $bb_stock = addon_boss_check_option_stock( $bb_product, $bb_restored, max( 1, (int) $quantity ) ); if ( is_wp_error( $bb_stock ) ) { throw new Exception( esc_html( $bb_stock->get_error_message() ) ); } } return $cart_item_data; } $product = wc_get_product( absint( $product_id ) ); if ( ! $product || ( ! addon_boss_standalone_option_sets( $product ) && ! addon_boss_standalone_product_fields( $product ) ) ) { return $cart_item_data; } $result = addon_boss_standalone_option_snapshots( $product, $_REQUEST ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- WooCommerce's add-to-cart flow carries no nonce; each field is sanitised and resolved against the stored set inside. if ( is_wp_error( $result ) ) { // Direct adds (Store API, programmatic) skip the form handler's // validation gate; throwing keeps a required choice from selling // unchosen, the same discipline as addon_boss_require_configuration. throw new Exception( esc_html( $result->get_error_message() ) ); } // Per-option stock enforced here, because this is the only seam every add // passes through: WC_Cart::add_to_cart never fires the validation filter // itself, so a Store API, quick-order or otherwise programmatic add would // reach the cart unchecked. The quantity being added arrives as this // filter's own argument, which is the only number worth checking: a posted // one can be absent (a JSON request carries none) or simply differ. $bb_stock = addon_boss_check_option_stock( $product, is_array( $result ) ? $result : array(), max( 1, (int) $quantity ), addon_boss_editing_cart_line( absint( $product_id ) ) ); if ( is_wp_error( $bb_stock ) ) { throw new Exception( esc_html( $bb_stock->get_error_message() ) ); } if ( $result ) { // Distinct picks make distinct cart_item_data, which WooCommerce's // generate_cart_id() hashes: different combinations never merge // into one line, identical ones still do. No extra key needed. $cart_item_data[ ADDON_BOSS_STANDALONE_OPTIONS_KEY ] = $result; } // Fields resolve and freeze the same way, under their own key so a // fields-only line still hashes distinctly per input (the frozen fee is // what the price seam charges). $fields = addon_boss_standalone_product_fields( $product ); if ( $fields ) { $fresult = addon_boss_standalone_field_snapshots( $product, $_REQUEST ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- each field sanitised and resolved inside. if ( is_wp_error( $fresult ) ) { // Direct adds (Store API, programmatic) skip the form gate; throwing // keeps a required field from selling unfilled, the same discipline // the option snapshots keep above. throw new Exception( esc_html( $fresult->get_error_message() ) ); } if ( $fresult ) { $cart_item_data[ ADDON_BOSS_PRODUCT_FIELDS_KEY ] = $fresult; } } return $cart_item_data; } // Order again: restore the snapshots from the order line so the re-order // charges what the original charged (the reorder-undercharge lesson; the // bundle path restores its selections the same way). add_filter( 'woocommerce_order_again_cart_item_data', 'addon_boss_standalone_order_again', 10, 2 ); function addon_boss_standalone_order_again( $cart_item_data, $item ) { $snaps = $item->get_meta( '_bundle_boss_standalone_options' ); if ( ! empty( $snaps ) && is_array( $snaps ) ) { $cart_item_data[ ADDON_BOSS_STANDALONE_OPTIONS_KEY ] = $snaps; } // The upload comes back only if its file still exists (kept uploads // survive cleanup; a vanished file restores as no file, never an error). $up = $item->get_meta( ADDON_BOSS_STANDALONE_UPLOAD_META ); if ( is_array( $up ) && function_exists( 'addon_boss_personalisation_resolve_upload' ) && addon_boss_personalisation_resolve_upload( (string) ( $up['ref'] ?? '' ) ) ) { $cart_item_data[ ADDON_BOSS_STANDALONE_UPLOAD_KEY ] = $up; } // The fields restore straight from the frozen snapshots, so a re-order // charges exactly what the original did (a file field carries its // reference; a vanished upload simply reads as no file when re-resolved). $fsnaps = $item->get_meta( ADDON_BOSS_PRODUCT_FIELDS_META ); if ( ! empty( $fsnaps ) && is_array( $fsnaps ) ) { $cart_item_data[ ADDON_BOSS_PRODUCT_FIELDS_KEY ] = $fsnaps; } return $cart_item_data; } // ---- Price ------------------------------------------------------ /** * The product's price in the STORE'S BASE currency, for building a cart * price that a currency switcher then converts exactly once. * * get_price() returns the VIEW price, which a switcher (WOOCS, Aelia, FOX, * WPML multi-currency) has already converted to the active currency. Adding * raw option fees to that and handing the sum to set_price() lets the * switcher convert the whole line AGAIN, a confirmed double charge (a * banner that should cost the equivalent of 37 was billed 134 instead of * 74 under WOOCS). When the active currency differs from the store's base, * we read the UNCONVERTED base (the 'edit' context bypasses the price * filters) so base + fees is one clean base-currency figure the switcher * converts once. When no switcher is converting, which is every ordinary * store and even a switcher store viewing in its base currency, active * equals base and this returns exactly get_price(), so nothing else is * touched: sales, tax, and role-based pricing all behave as before. */ function addon_boss_options_base_price( WC_Product $product ): float { // One currency rule for the whole plugin: the bundle sum reads components // through addon_boss_base_price() (core) with this exact logic, so the // standalone line shares it rather than keeping a second copy that can drift. return addon_boss_base_price( $product ); } add_action( 'woocommerce_before_calculate_totals', 'addon_boss_price_standalone_options', 10 ); function addon_boss_price_standalone_options( $cart ): void { if ( is_admin() && ! defined( 'DOING_AJAX' ) ) { return; } foreach ( $cart->get_cart() as $item ) { $bb_pos_snaps = defined( 'ADDON_BOSS_POSITIONS_KEY' ) ? (array) ( $item[ ADDON_BOSS_POSITIONS_KEY ] ?? array() ) : array(); // A module can claim a line that carries no option data at all (a // quantity-banded product bought plain still needs its unit repriced). // Bundled lines are never claimable: the bundle owns their price. $bb_forced = apply_filters( 'addon_boss_price_line_forced', false, $item ); if ( ! empty( $item['bundle_boss'] ) || ( ! $bb_forced && empty( $item[ ADDON_BOSS_STANDALONE_OPTIONS_KEY ] ) && empty( $item[ ADDON_BOSS_PRODUCT_FIELDS_KEY ] ) && ! $bb_pos_snaps ) ) { continue; } // A FRESH product each pass: this hook runs repeatedly and the line's // own object already carries the previous pass's price, so deriving // from it would compound the fees. On a variable line the VARIATION // is the base (the parent would answer with its cheapest variation, // the exact undercharge every rival's changelog admits to). $base_id = (int) ( $item['variation_id'] ?? 0 ); $base = wc_get_product( $base_id ? $base_id : (int) ( $item['product_id'] ?? 0 ) ); if ( ! $base ) { continue; } // Fees accumulate in THREE buckets, because a discount module must be // able to tell them apart: unit fees scale with the quantity (an option // delta, a per-item charge) and may fairly be discounted with the // product; position fees are per-item print and embroidery money, held // apart so a shop can tier its options while its needlework holds firm // (Stefanos, 23 Aug); spread fees are whole-line charges divided across // the units (a setup charge is one piece of work at any quantity) and // must never be touched by anything. $unit_fees = 0.0; $position_fees = 0.0; $spread_fees = 0.0; $qty = max( 1, (int) ( $item['quantity'] ?? 1 ) ); $table_set_base = null; foreach ( (array) ( $item[ ADDON_BOSS_STANDALONE_OPTIONS_KEY ] ?? array() ) as $snap ) { // A look-up table on the "sets the whole price" basis REPLACES the product // base (a 60x90 sign IS 68.00, not the product price plus 68). Every other // option (an add-basis table, a choice delta, a measured charge) adds on. if ( 'set' === ( $snap['table_basis'] ?? '' ) ) { $table_set_base = (float) ( $snap['delta'] ?? 0 ); continue; } $unit_fees += (float) ( $snap['delta'] ?? 0 ); } // Product-field fees fold into the SAME per-unit price. A 'once' field is // a whole-line charge, so it is spread across the units (fee / qty) and // WooCommerce's per-unit x quantity lands back on exactly one fee; every // other mode is per-unit and multiplies natively. Deliberately NOT // rounded here: the unit price is an intermediate WooCommerce rounds into // the line total (£12 over 7 units is £1.714…, and pre-rounding loses 3p). foreach ( (array) ( $item[ ADDON_BOSS_PRODUCT_FIELDS_KEY ] ?? array() ) as $fsnap ) { $fee = (float) ( $fsnap['fee'] ?? 0 ); if ( 0.0 === $fee ) { continue; } // A per-item repeat opened one instance for every unit being bought, // and the instances were frozen at that moment. Lower the quantity in // the cart afterwards and the instances above the new one are not // being bought any more, so they must stop charging: six names on a // line cut to two shirts is two names. The stored values are left // alone, so putting the quantity back puts the names back. if ( ! empty( $fsnap['spread'] ) && (int) ( $fsnap['instance'] ?? 0 ) > $qty ) { continue; } // Two things are whole-line charges and so are spread across the units: // a "once" fee by its mode, and one instance of a per-item repeatable // field, because those instances ALREADY scale with the quantity and // would otherwise be multiplied by it a second time. $spread = ( 'once' === ( $fsnap['fee_mode'] ?? 'unit' ) ) || ! empty( $fsnap['spread'] ); if ( $spread ) { $spread_fees += $fee / $qty; } else { $unit_fees += $fee; } } // Chosen positions charge exactly the same way: 'once' is a setup charge for // the line however many are bought, so it is spread across the units and // WooCommerce's per-unit times quantity lands back on one fee; 'unit' is per // item and multiplies natively. Same seam, same tax treatment, so a position // can never be taxed differently from the thing it is printed on. foreach ( $bb_pos_snaps as $psnap ) { $pfee = (float) ( $psnap['fee'] ?? 0 ); if ( 0.0 !== $pfee ) { if ( 'once' === ( $psnap['fee_mode'] ?? 'once' ) ) { $spread_fees += $pfee / $qty; } else { $position_fees += $pfee; } } // Tidying up a logo is one piece of work on one file, so it is charged // once whatever the position's own mode is. Folded into the fee it // followed that mode, and a per-item position multiplied it. $phelp = (float) ( $psnap['help_fee'] ?? 0 ); if ( 0.0 !== $phelp ) { $spread_fees += $phelp / $qty; } } // Raw plus raw, exactly like the bundle's unit seam: set_price() takes // the store's entry basis and WooCommerce taxes it natively. Per unit; // the line quantity multiplies through WooCommerce's own maths. Floored // at zero: a merchant can price an option BELOW zero (a "strip it back, // -5" choice), and a line cheaper than its own options must read as free, // never a negative price that quietly discounts the rest of the cart. // // The parts pass through one filter before they are summed, so a module // can band the base by quantity or discount the per-unit money, each // kind under its own tick, while the spread fees stay exactly as // computed. With nothing hooked the sum is identical to base + fees. $line_base = ( null !== $table_set_base ) ? $table_set_base : addon_boss_options_base_price( $base ); $bb_parts = apply_filters( 'addon_boss_line_price_parts', array( 'base' => $line_base, 'unit_fees' => $unit_fees, 'position_fees' => $position_fees, 'spread_fees' => $spread_fees, // True when an option (a table set to own the whole price) // replaced the product's base, so a module knows the base is // not the product's own price. Informational; never summed. 'base_is_replaced' => ( null !== $table_set_base ), ), $item, $base, $qty ); $item['data']->set_price( max( 0.0, (float) $bb_parts['base'] + (float) $bb_parts['unit_fees'] + (float) ( $bb_parts['position_fees'] ?? 0 ) + (float) $bb_parts['spread_fees'] ) ); } } // ---- Display + order -------------------------------------------- add_filter( 'woocommerce_get_item_data', 'addon_boss_standalone_option_item_data', 10, 2 ); function addon_boss_standalone_option_item_data( $data, $cart_item ) { if ( ! empty( $cart_item['bundle_boss'] ) ) { return $data; } // The upload's cart row, whether or not the line also carries options. $up = $cart_item[ ADDON_BOSS_STANDALONE_UPLOAD_KEY ] ?? null; if ( is_array( $up ) && '' !== (string) ( $up['name'] ?? '' ) ) { $data[] = array( 'name' => '' !== (string) ( $up['label'] ?? '' ) ? (string) $up['label'] : __( 'Your file', 'addon-boss' ), 'value' => (string) $up['name'], ); } // The variation when there is one: charge suffixes convert to display basis // through the product's own tax class. Resolved before the options guard so // a fields-only line (no option sets) still shows its field rows. $vid = (int) ( $cart_item['variation_id'] ?? 0 ); $product = wc_get_product( $vid ? $vid : (int) ( $cart_item['product_id'] ?? 0 ) ); // Product-field rows: Label -> the shopper's value (a file as a link), with // any per-unit charge shown in the same basis the option rows use. One // field asked per unit becomes a WALL at trade quantities (fifty shirts, // fifty rows), so past a handful one field's instances fold into a single // summary row HERE, in the cart display only: the first few values quoted, // the rest counted, the whole charge on the one row. The order line keeps // every row untouched, because the packing bench works from those. // Bucket by field key to decide what folds, but WALK THE SNAPSHOTS IN THE // ORDER THEY WERE FILLED IN. Emitting bucket by bucket instead reordered // the whole list into field-major, so a tankard with two engravings read // "Engraving 1: Words, Engraving 2: Words, Engraving 1: Font, ..." and // whoever engraves it reads across the blocks rather than down one. Money // was never wrong; the instruction was. A folded bucket takes the place of // its FIRST member and its later members are skipped, so folding still // collapses a wall of rows without shuffling everything around it. $bb_fsnaps = (array) ( $cart_item[ ADDON_BOSS_PRODUCT_FIELDS_KEY ] ?? array() ); // The order is decided once, at CAPTURE, and must not be second-guessed // here. A repeatable group reorders its own members into block order as // they are resolved (the addon_boss_field_resolved_rows seam), and it does // that IN PLACE: each group is sorted where the merchant put it and every // other field keeps its own position. // // This used to re-sort the whole list by instance number instead. It got // the blocks right and everything else wrong, because a field outside a // group carries no instance number, so it read as instance zero and was // lifted above block one. A merchant who put "Delivery note" last saw it // printed first. Reading the stored order is both simpler and correct. $bb_buckets = array(); foreach ( $bb_fsnaps as $bb_fi => $fsnap ) { $bb_buckets[ (string) ( $fsnap['key'] ?? $bb_fi ) ][] = $fsnap; } $bb_folded = array(); foreach ( $bb_fsnaps as $bb_fi => $bb_snap ) { $bb_key = (string) ( $bb_snap['key'] ?? $bb_fi ); $bb_bunch = $bb_buckets[ $bb_key ] ?? array( $bb_snap ); $bb_fold = count( $bb_bunch ) > 8 && ! empty( $bb_bunch[0]['spread'] ); if ( $bb_fold ) { if ( isset( $bb_folded[ $bb_key ] ) ) { continue; // this bucket already printed its summary row } $bb_folded[ $bb_key ] = true; } else { $bb_bunch = array( $bb_snap ); // one row for this one snapshot } if ( $bb_fold ) { $bb_names = array(); $bb_sum = 0.0; foreach ( $bb_bunch as $fsnap ) { $bb_d = trim( (string) ( $fsnap['display'] ?? '' ) ); if ( '' !== $bb_d ) { $bb_names[] = $bb_d; } $bb_sum += (float) ( $fsnap['fee'] ?? 0 ); } // "Shirt × 12": the instance word (the label with its number taken // back off) and how many the row holds. $bb_word = trim( (string) preg_replace( '/\s*[\d,.\x{00a0}]+$/u', '', (string) ( $bb_bunch[0]['label'] ?? '' ) ) ); $bb_word = '' !== $bb_word ? $bb_word : __( 'Detail', 'addon-boss' ); $bb_shown = array_slice( $bb_names, 0, 3 ); $bb_rest = count( $bb_bunch ) - count( $bb_shown ); $bb_syn = $bb_bunch[0]; $bb_syn['fee'] = $bb_sum; $data[] = array( /* translators: 1: what each one is called, e.g. "Shirt", 2: how many of them. */ 'name' => sprintf( __( '%1$s × %2$s', 'addon-boss' ), $bb_word, number_format_i18n( count( $bb_bunch ) ) ), 'value' => esc_html( implode( ', ', $bb_shown ) ) /* translators: %s: how many further entries the row is not listing. */ . ( $bb_rest > 0 ? ' ' . esc_html( sprintf( __( 'and %s more', 'addon-boss' ), number_format_i18n( $bb_rest ) ) ) : '' ) . addon_boss_standalone_field_charge_suffix( $bb_syn, $product, 'cart' ), ); continue; } foreach ( $bb_bunch as $fsnap ) { $disp = trim( (string) ( $fsnap['display'] ?? '' ) ); if ( '' === $disp ) { continue; } $val = ! empty( $fsnap['url'] ) ? '' . esc_html( $disp ) . '' : esc_html( $disp ); $data[] = array( 'name' => '' !== (string) ( $fsnap['label'] ?? '' ) ? (string) $fsnap['label'] : __( 'Detail', 'addon-boss' ), 'value' => $val . esc_html( addon_boss_standalone_field_style_suffix( $fsnap ) ) . addon_boss_standalone_field_charge_suffix( $fsnap, $product, 'cart' ), ); } } if ( empty( $cart_item[ ADDON_BOSS_STANDALONE_OPTIONS_KEY ] ) ) { return $data; } foreach ( (array) $cart_item[ ADDON_BOSS_STANDALONE_OPTIONS_KEY ] as $snap ) { $label = (string) ( $snap['label'] ?? '' ); if ( '' === $label ) { continue; } $data[] = array( 'name' => '' !== (string) ( $snap['set_name'] ?? '' ) ? (string) $snap['set_name'] : __( 'Option', 'addon-boss' ), 'value' => $label . addon_boss_standalone_charge_suffix( $snap, $product, 'cart' ), ); } return $data; } add_action( 'woocommerce_checkout_create_order_line_item', 'addon_boss_standalone_order_line_meta', 10, 3 ); function addon_boss_standalone_order_line_meta( $item, $cart_item_key, $values ): void { if ( ! empty( $values['bundle_boss'] ) ) { return; } // The upload persists onto the order line and its file is marked kept, // so the orphan cleanup never eats an ordered file. $up = $values[ ADDON_BOSS_STANDALONE_UPLOAD_KEY ] ?? null; if ( is_array( $up ) && '' !== (string) ( $up['ref'] ?? '' ) ) { $item->add_meta_data( ADDON_BOSS_STANDALONE_UPLOAD_META, $up, true ); $item->add_meta_data( '' !== (string) ( $up['label'] ?? '' ) ? (string) $up['label'] : __( 'Your file', 'addon-boss' ), (string) ( $up['name'] ?? '' ) ); if ( function_exists( 'addon_boss_personalisation_keep_upload' ) ) { addon_boss_personalisation_keep_upload( (string) $up['ref'] ); } } // Product fields: the structured copy (the reorder source of truth) plus a // readable row per filled field. Persisted even when the line carries no // option sets, so a fields-only product reorders and reads on the order // screen and in emails. $fsnaps = $values[ ADDON_BOSS_PRODUCT_FIELDS_KEY ] ?? array(); if ( ! empty( $fsnaps ) && is_array( $fsnaps ) ) { $item->add_meta_data( ADDON_BOSS_PRODUCT_FIELDS_META, array_values( $fsnaps ), true ); $fvid = (int) ( $values['variation_id'] ?? 0 ); $fproduct = wc_get_product( $fvid ? $fvid : (int) ( $values['product_id'] ?? 0 ) ); foreach ( $fsnaps as $fsnap ) { $flabel = (string) ( $fsnap['label'] ?? '' ); $fdisp = (string) ( $fsnap['display'] ?? '' ); if ( '' === $flabel || '' === $fdisp ) { continue; } $item->add_meta_data( $flabel, $fdisp . addon_boss_standalone_field_style_suffix( $fsnap ) . addon_boss_standalone_suffix_text( addon_boss_standalone_field_charge_suffix( $fsnap, $fproduct, 'cart' ) ) ); } } $snaps = $values[ ADDON_BOSS_STANDALONE_OPTIONS_KEY ] ?? array(); if ( empty( $snaps ) || ! is_array( $snaps ) ) { return; } // The structured copy is the reorder source of truth; the readable rows // mirror the cart lines for the order screen and emails. $item->add_meta_data( '_bundle_boss_standalone_options', array_values( $snaps ), true ); $vid = (int) ( $values['variation_id'] ?? 0 ); $product = wc_get_product( $vid ? $vid : (int) ( $values['product_id'] ?? 0 ) ); foreach ( $snaps as $snap ) { $label = (string) ( $snap['label'] ?? '' ); if ( '' === $label ) { continue; } $item->add_meta_data( '' !== (string) ( $snap['set_name'] ?? '' ) ? (string) $snap['set_name'] : __( 'Option', 'addon-boss' ), $label . addon_boss_standalone_suffix_text( addon_boss_standalone_charge_suffix( $snap, $product, 'cart' ) ) ); } } // ================================================================= // The cart edit loop: a configured line offers "Edit options", the product // page comes back with every choice prefilled, and saving REPLACES the line. // // The prefill IS the failed-add machinery: the render already restores // whatever the request carries (bb_pf values, set picks, style picks, upload // references), so the edit only has to write the line's snapshots back into // $_REQUEST and the page draws itself filled in. Each option-set kind can // register a 'seed' callback that turns its snapshot back into its own // request fields, and a field type can do the same for its posted shape; the // engine's own kinds and types need neither. // // The replace is atomic on WooCommerce's own terms: the update is an // ordinary add-to-cart, validated by the same gates as any add, and only // AFTER it succeeds is the original line removed (and the new line stood in // its place). A refused update therefore leaves the original exactly as it // was, with the reason on screen. Identical picks hash to the identical cart // id, so an edit that changes nothing merges back into the very line being // edited and only the quantity is pinned to what was posted. // // Free in every build. A line inside a bundle is not offered the link (the // bundle's own edit flow owns it), and BundleBoss can join through the same // filters when it does. // ================================================================= /** * Does this cart line carry anything this engine configured onto it? * * The edit link is contextual: a plain line offers nothing to edit. A bundle * line stands down here however much it carries, because the bundle's own * edit flow owns it. Filterable so another plugin (BundleBoss) can claim its * own lines into, or out of, the loop. * * @param array $item One cart line. * @return bool */ function addon_boss_cart_line_carries_options( array $item ): bool { $carries = false; if ( empty( $item['bundle_boss'] ) ) { $carries = ! empty( $item[ ADDON_BOSS_STANDALONE_OPTIONS_KEY ] ) || ! empty( $item[ ADDON_BOSS_PRODUCT_FIELDS_KEY ] ) || ! empty( $item[ ADDON_BOSS_STANDALONE_UPLOAD_KEY ] ) || ( defined( 'ADDON_BOSS_POSITIONS_KEY' ) && ! empty( $item[ ADDON_BOSS_POSITIONS_KEY ] ) ); } return (bool) apply_filters( 'addon_boss_cart_line_carries_options', $carries, $item ); } /** * The cart line being edited in THIS request, or null. * * Two spellings mean the same line: bb_edit rides the link from the cart, and * bb_edit_line rides the form's own hidden field, so a refused update * re-renders still wearing its edit state. The key is only ever trusted after * it resolves to a real line in this visitor's own cart (the cart is bound to * their session, so a key from anywhere else resolves to nothing). * * @return array{key:string,item:array}|null */ function addon_boss_edit_line_state(): ?array { $key = ''; foreach ( array( 'bb_edit', 'bb_edit_line' ) as $bb_param ) { // Read ONCE, through the boundary, and judged afterwards. Testing the // raw value and sanitising it on the next line reads the input twice // and leaves the comparison looking at bytes nothing has cleaned. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only lookup; the key must resolve inside this visitor's own session cart to mean anything. $bb_asked = isset( $_REQUEST[ $bb_param ] ) ? sanitize_text_field( wp_unslash( (string) $_REQUEST[ $bb_param ] ) ) : ''; if ( '' !== $bb_asked ) { $key = $bb_asked; break; } } if ( '' === $key || ! function_exists( 'WC' ) || null === WC()->cart ) { return null; } $item = WC()->cart->get_cart_item( $key ); if ( ! $item || ! is_array( $item ) || ! addon_boss_cart_line_carries_options( $item ) ) { return null; } return array( 'key' => $key, 'item' => $item ); } /** * The edit state ONLY when this product page is the edited line's own * product, which is what the chrome (banner, hidden field, button wording, * quantity) keys off. A stray bb_edit for some other product changes nothing. * * @param WC_Product|null $product The product being rendered, when the caller has it. * @return array{key:string,item:array}|null */ function addon_boss_edit_page_state( $product = null ): ?array { $state = addon_boss_edit_line_state(); if ( ! $state ) { return null; } $line_pid = (int) ( $state['item']['product_id'] ?? 0 ); $page_pid = 0; if ( $product instanceof WC_Product ) { $page_pid = $product->get_id(); } elseif ( isset( $GLOBALS['product'] ) && $GLOBALS['product'] instanceof WC_Product ) { $page_pid = $GLOBALS['product']->get_id(); } elseif ( function_exists( 'is_product' ) && is_product() ) { $page_pid = (int) get_queried_object_id(); } return $line_pid === $page_pid ? $state : null; } /** * The URL that opens a line for editing: the product's own page, the chosen * variation carried as WooCommerce's native attribute parameters (its form * preselects from them), and the line named by its cart item key. */ function addon_boss_edit_line_url( array $item, string $key ): string { $product = wc_get_product( (int) ( $item['product_id'] ?? 0 ) ); if ( ! $product || ! $product->is_purchasable() ) { return ''; } $args = array(); foreach ( (array) ( $item['variation'] ?? array() ) as $bb_attr => $bb_val ) { if ( '' !== (string) $bb_val ) { $args[ (string) $bb_attr ] = (string) $bb_val; } } $args['bb_edit'] = $key; return add_query_arg( urlencode_deep( $args ), $product->get_permalink() ); } add_filter( 'woocommerce_cart_item_name', 'addon_boss_edit_cart_link', 20, 3 ); /** * The "Edit options" link under a configured line's name, on the cart page * and in the classic mini cart (both print their names through this same * filter). Contextual: only a line that actually carries options offers it. * The checkout review table is for checking, not editing, so it stays clean. * A grid product's lines are made by the grid and edited through the grid. */ function addon_boss_edit_cart_link( $name, $cart_item = null, $cart_item_key = '' ) { if ( ! is_array( $cart_item ) || '' === (string) $cart_item_key ) { return $name; } if ( function_exists( 'is_checkout' ) && is_checkout() ) { return $name; } if ( ! addon_boss_cart_line_carries_options( $cart_item ) ) { return $name; } $bb_parent = wc_get_product( (int) ( $cart_item['product_id'] ?? 0 ) ); if ( ! $bb_parent || addon_boss_product_uses_grid( $bb_parent ) ) { return $name; } $url = addon_boss_edit_line_url( $cart_item, (string) $cart_item_key ); if ( '' === $url ) { return $name; } $link = '' . esc_html__( 'Edit options', 'addon-boss' ) . ''; /** * The rendered edit link, for another plugin to reword, restyle or veto * (return '' to offer nothing for this line). * * @param string $link The anchor markup. * @param array $cart_item The cart line. * @param string $cart_item_key Its key. * @param string $url The edit URL. */ $link = (string) apply_filters( 'addon_boss_edit_line_link', $link, $cart_item, (string) $cart_item_key, $url ); if ( '' === $link ) { return $name; } static $bb_css = false; if ( ! $bb_css ) { $bb_css = true; addon_boss_inline_css( '.bb-edit-line{display:block;width:max-content;margin-top:2px;font-size:.85em;font-weight:600;text-decoration:underline;text-underline-offset:2px;opacity:.85;}.bb-edit-line:hover{opacity:1;}' ); } return $name . ' ' . $link; } /** * Write one line's frozen choices back into $_REQUEST, exactly where the * failed-add prefill machinery reads them, so the product page renders * already filled in: set picks, field values (each repeat instance under its * own index), a size field's two dimensions, upload references (the render * turns them back into their chips), style picks, chosen positions. * * Values are wp_slash()ed on the way in because everything that reads the * request unslashes, exactly as it would a browser's own post. * * @param array $item The cart line. */ function addon_boss_edit_seed_request( array $item ): void { $product = wc_get_product( (int) ( $item['product_id'] ?? 0 ) ); if ( ! $product ) { return; } // A crafted scalar where an array belongs (?bb_pf=x on the edit URL) must // not fatal the array writes below; the seed owns these keys on an edit // arrival, so a scalar imposter is simply cleared. foreach ( array( 'bb_pf', 'bb_pf_style', 'bb_pos' ) as $bb_arr_key ) { if ( isset( $_REQUEST[ $bb_arr_key ] ) && ! is_array( $_REQUEST[ $bb_arr_key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- shape guard only. unset( $_REQUEST[ $bb_arr_key ] ); } } // The chosen variation, in WooCommerce's own attribute parameters; its // form preselects from the request natively. foreach ( (array) ( $item['variation'] ?? array() ) as $bb_attr => $bb_val ) { if ( '' !== (string) $bb_val ) { $_REQUEST[ (string) $bb_attr ] = wp_slash( (string) $bb_val ); } } // Option sets: each snapshot back through its kind. The engine's own // 'choices' kind is one field carrying the option's key; a several-of // set carries EVERY pick, so its snapshots accumulate into the array its // multi control posts rather than overwriting to the last one standing. // A kind priced off its own panel (a measured set, a price table) // answers through the 'seed' callback its module registered, and a kind // with neither seeds nothing rather than guessing. $bb_sets_by_id = array(); foreach ( addon_boss_standalone_option_sets( $product ) as $bb_set ) { $bb_sets_by_id[ (string) $bb_set['id'] ] = $bb_set; } $bb_kinds = addon_boss_option_set_kinds(); $bb_multi_seed = array(); // field name => every picked key, in snapshot order. foreach ( (array) ( $item[ ADDON_BOSS_STANDALONE_OPTIONS_KEY ] ?? array() ) as $bb_snap ) { if ( ! is_array( $bb_snap ) ) { continue; } $bb_set = $bb_sets_by_id[ (string) ( $bb_snap['set'] ?? '' ) ] ?? null; if ( ! $bb_set ) { continue; // the set has since left the product: nothing to prefill it into. } $bb_kind = (string) ( $bb_set['kind'] ?? 'choices' ); $bb_cb = $bb_kinds[ $bb_kind ]['seed'] ?? null; if ( is_callable( $bb_cb ) ) { $bb_fields = (array) call_user_func( $bb_cb, $bb_set, $bb_snap ); } elseif ( 'choices' === $bb_kind ) { if ( ! empty( $bb_set['multi'] ) ) { $bb_multi_seed[ addon_boss_standalone_field_name( (string) $bb_set['id'] ) ][] = (string) ( $bb_snap['key'] ?? '' ); continue; } $bb_fields = array( addon_boss_standalone_field_name( (string) $bb_set['id'] ) => (string) ( $bb_snap['key'] ?? '' ) ); } else { $bb_fields = array(); } foreach ( $bb_fields as $bb_k => $bb_v ) { $_REQUEST[ (string) $bb_k ] = wp_slash( (string) $bb_v ); } } foreach ( $bb_multi_seed as $bb_k => $bb_keys ) { $_REQUEST[ (string) $bb_k ] = wp_slash( array_values( $bb_keys ) ); } // Product fields: snapshots regrouped by field, a repeatable field's // instances back into the list shape it posts (bb_pf[key][0], [1], ...), // everything else back to its scalar. A type whose posted shape is not // its stored value (a measured size stores "3x2" and posts w and h) // answers through the 'seed' callback its module registered against the // type. A frozen style pick (typeface, colour) rides back under // bb_pf_style, where the picker's own prefill reads. $bb_defs_by_key = array(); foreach ( addon_boss_standalone_product_fields( $product ) as $bb_def ) { $bb_defs_by_key[ (string) ( $bb_def['key'] ?? '' ) ] = $bb_def; } $bb_types = addon_boss_personalisation_field_types(); $bb_groups = array(); foreach ( (array) ( $item[ ADDON_BOSS_PRODUCT_FIELDS_KEY ] ?? array() ) as $bb_fsnap ) { if ( is_array( $bb_fsnap ) && '' !== (string) ( $bb_fsnap['key'] ?? '' ) ) { $bb_groups[ (string) $bb_fsnap['key'] ][] = $bb_fsnap; } } foreach ( $bb_groups as $bb_fkey => $bb_snaps ) { $bb_def = $bb_defs_by_key[ $bb_fkey ] ?? null; if ( ! $bb_def ) { continue; // the field has since left the product. } $bb_seed_cb = $bb_types[ (string) ( $bb_def['type'] ?? 'text' ) ]['seed'] ?? null; $bb_repeat = false; $bb_vals = array(); foreach ( $bb_snaps as $bb_fsnap ) { $bb_inst = (int) ( $bb_fsnap['instance'] ?? 0 ); if ( $bb_inst > 0 ) { $bb_repeat = true; } // SLOT-TRUE: an instance's value seeds back into its own numbered // slot, never packed down into a hole a skipped instance left. In a // repeatable GROUP the members share the block numbering, so a font // picked only on block two must land under [1] for both the render // and the resolver to reunite it with block two's other fields. $bb_vals[ max( 0, $bb_inst - 1 ) ] = is_callable( $bb_seed_cb ) ? call_user_func( $bb_seed_cb, $bb_def, $bb_fsnap ) : (string) ( $bb_fsnap['value'] ?? '' ); if ( isset( $bb_fsnap['font'] ) || isset( $bb_fsnap['colour'] ) ) { $_REQUEST['bb_pf_style'][ $bb_fkey ] = wp_slash( array( 'font' => (string) ( $bb_fsnap['font'] ?? '' ), 'colour' => (string) ( $bb_fsnap['colour'] ?? '' ), ) ); } } if ( $bb_repeat ) { // Fill the gaps with the empty string a blank input would have // posted, so the list is contiguous and every renderer's positional // read stays true to the blocks the shopper actually had. $bb_top = max( array_keys( $bb_vals ) ); for ( $bb_s = 0; $bb_s <= $bb_top; $bb_s++ ) { if ( ! isset( $bb_vals[ $bb_s ] ) ) { $bb_vals[ $bb_s ] = ''; } } ksort( $bb_vals ); } $_REQUEST['bb_pf'][ $bb_fkey ] = wp_slash( $bb_repeat ? array_values( $bb_vals ) : reset( $bb_vals ) ); } // The product-level upload box, by its stored reference. $bb_up = $item[ ADDON_BOSS_STANDALONE_UPLOAD_KEY ] ?? null; if ( is_array( $bb_up ) && '' !== (string) ( $bb_up['ref'] ?? '' ) ) { $_REQUEST['bb_standalone_upload_ref'] = wp_slash( (string) $bb_up['ref'] ); } // Chosen positions, back into the bb_pos shape their panels post. if ( defined( 'ADDON_BOSS_POSITIONS_KEY' ) ) { foreach ( (array) ( $item[ ADDON_BOSS_POSITIONS_KEY ] ?? array() ) as $bb_psnap ) { if ( ! is_array( $bb_psnap ) ) { continue; } $bb_pkey = (string) ( $bb_psnap['set'] ?? '' ) . ':' . (string) ( $bb_psnap['id'] ?? '' ); if ( ':' === $bb_pkey ) { continue; } $_REQUEST['bb_pos'][ $bb_pkey ] = wp_slash( array( 'text' => (string) ( $bb_psnap['text'] ?? '' ), 'file' => (string) ( $bb_psnap['file'] ?? '' ), 'method' => (string) ( $bb_psnap['method'] ?? '' ), 'help' => ! empty( $bb_psnap['help'] ) ? '1' : '', ) ); } } /** * Whatever else a module or another plugin froze onto this line, seeded * back the same way. BundleBoss joins the loop here. * * @param array $item The cart line being edited. */ do_action( 'addon_boss_edit_seed', $item ); } add_action( 'template_redirect', 'addon_boss_edit_prefill_boot' ); /** * Arriving on a product page from an edit link: seed the request from the * line so the page renders prefilled. Only on a plain arrival; a posted form * (a refused update re-rendering) carries the shopper's own values, and * overwriting those with the line's stored ones would throw away exactly the * typing the failed-add machinery exists to keep. */ function addon_boss_edit_prefill_boot(): void { if ( ! function_exists( 'is_product' ) || ! is_product() ) { return; } $bb_method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( (string) $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- presence check only: a posted form keeps its own values. if ( 'POST' === $bb_method || isset( $_REQUEST['add-to-cart'] ) ) { return; } if ( ! isset( $_REQUEST['bb_edit'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- presence check only. return; } $state = addon_boss_edit_page_state(); if ( ! $state ) { // The link outlived the line (bought, removed, or another tab got // there first). Say so kindly and render the page fresh. if ( function_exists( 'wc_add_notice' ) ) { wc_add_notice( __( 'That item is not in your cart any more, so this page starts fresh.', 'addon-boss' ), 'notice' ); } return; } addon_boss_edit_seed_request( $state['item'] ); } add_action( 'woocommerce_before_add_to_cart_form', 'addon_boss_edit_banner' ); /** * The banner above the form, so the page says plainly that this is an edit: * what is being changed, that nothing happens until it is saved, and the way * back out. role="status" so assistive tech announces the mode. */ function addon_boss_edit_banner(): void { $state = addon_boss_edit_page_state(); if ( ! $state ) { return; } addon_boss_inline_css( '.bb-edit-banner{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 14px;margin:0 0 18px;padding:12px 16px;border-radius:10px;border:1.5px solid color-mix(in srgb,var(--aob-accent,#fd4730) 35%,transparent);background:color-mix(in srgb,var(--aob-accent,#fd4730) 6%,var(--aob-surface,#fff));}' . '.bb-edit-banner strong{font-weight:700;}' . '.bb-edit-banner .bb-edit-cancel{margin-left:auto;font-weight:600;text-decoration:underline;text-underline-offset:2px;white-space:nowrap;}' ); echo '
'; echo '' . esc_html__( 'You are changing an item from your cart.', 'addon-boss' ) . ' '; echo '' . esc_html__( 'Your choices are filled in below. Nothing changes until you save.', 'addon-boss' ) . ''; echo '' . esc_html__( 'Back to the cart without saving', 'addon-boss' ) . ''; echo '
'; } add_action( 'woocommerce_before_add_to_cart_button', 'addon_boss_edit_hidden_field', 5 ); /** * The hidden field naming the line this form replaces. Inside the native * add-to-cart form, so it rides the update post however the theme submits. */ function addon_boss_edit_hidden_field(): void { $state = addon_boss_edit_page_state(); if ( ! $state ) { return; } echo ''; } add_filter( 'woocommerce_product_single_add_to_cart_text', 'addon_boss_edit_button_text', 20, 2 ); /** * The button says what it will do: update, not add. The platform's own verb * (the cart page's own button is "Update cart"), so the shopper meets one * vocabulary. The live total beside it already shows the recalculated price. */ function addon_boss_edit_button_text( $text, $product = null ) { return addon_boss_edit_page_state( $product instanceof WC_Product ? $product : null ) ? __( 'Update cart', 'addon-boss' ) : $text; } add_filter( 'woocommerce_quantity_input_args', 'addon_boss_edit_quantity_prefill', 10, 2 ); /** * The quantity box starts at the line's own quantity, so saving untouched * keeps it. A posted quantity (a refused update re-rendering) wins, exactly * as it does for every other field. */ function addon_boss_edit_quantity_prefill( $args, $product = null ) { if ( isset( $_POST['quantity'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- presence check only: the template already honours a posted quantity. return $args; } $state = addon_boss_edit_page_state( $product instanceof WC_Product ? $product : null ); if ( $state && is_array( $args ) ) { $args['input_value'] = max( 1, (int) ( $state['item']['quantity'] ?? 1 ) ); } return $args; } add_filter( 'woocommerce_add_to_cart_sold_individually_found_in_cart', 'addon_boss_edit_sold_individually', 10, 5 ); /** * A sold-individually product refuses a second identical line, but the line * being EDITED is not "another one": the update replaces it. Only that exact * line is excused; everything else keeps WooCommerce's answer. */ function addon_boss_edit_sold_individually( $found, $product_id = 0, $variation_id = 0, $cart_item_data = array(), $cart_id = '' ) { if ( ! $found || ! isset( $_REQUEST['bb_edit_line'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- presence check; the key still has to resolve to this visitor's own line below. return $found; } $edit_key = sanitize_text_field( wp_unslash( (string) $_REQUEST['bb_edit_line'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( '' === $edit_key || null === WC()->cart ) { return $found; } return WC()->cart->find_product_in_cart( (string) $cart_id ) === $edit_key ? false : $found; } /** * Whether THIS request replaced a line (and which product), for the redirect * and the message. A request-scoped latch, set only by the replace below. * * @param bool|null $set Pass true to latch; null to read. * @return bool */ function addon_boss_edit_replaced( ?bool $set = null ): bool { static $bb_replaced = false; if ( null !== $set ) { $bb_replaced = $set; } return $bb_replaced; } add_action( 'woocommerce_add_to_cart', 'addon_boss_edit_apply_replace', 20, 6 ); /** * The atomic half of the update: runs only AFTER WooCommerce accepted the * add, so a refused update never reaches here and the original line stands * untouched with the reason on screen. * * Three shapes, one outcome each: * - Identical picks merged back into the edited line itself (same cart id): * the posted quantity is the whole answer, so it is pinned rather than * summed, and the line is byte-identical to before. * - New picks made a new line: the original is removed and the new line * stands exactly where it stood, so the cart reads as updated in place. * Nothing is left in the removed pile, so there is no ghost to restore. * - New picks merged into ANOTHER existing identical line: the original is * removed and the merge stands, which is how WooCommerce has always * folded identical lines together. */ function addon_boss_edit_apply_replace( $cart_item_key, $product_id = 0, $quantity = 1, $variation_id = 0, $variation = array(), $cart_item_data = array() ): void { if ( ! isset( $_REQUEST['bb_edit_line'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the key must resolve to a line of the same product in this visitor's own cart below. return; } $edit_key = sanitize_text_field( wp_unslash( (string) $_REQUEST['bb_edit_line'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $cart = function_exists( 'WC' ) ? WC()->cart : null; if ( '' === $edit_key || ! $cart ) { return; } $old = $cart->get_cart_item( $edit_key ); if ( ! $old || ! is_array( $old ) ) { return; // the link outlived the line: this was an ordinary add. } // Only a line of the same product can be replaced by this form. A stale // or crossed-up marker must never eat an unrelated line. if ( (int) ( $old['product_id'] ?? 0 ) !== (int) $product_id ) { return; } if ( $edit_key === (string) $cart_item_key ) { // Nothing about the picks changed, so WooCommerce merged the update // into the very line being edited (its quantity plus the posted one). // The posted quantity is the whole answer. $cart->set_quantity( $cart_item_key, max( 1, (int) $quantity ) ); addon_boss_edit_replaced( true ); return; } $bb_keys = array_keys( $cart->cart_contents ); $bb_pos = array_search( $edit_key, $bb_keys, true ); $cart->remove_cart_item( $edit_key ); // Atomic replace: nothing waits in the removed pile to be restored. unset( $cart->removed_cart_contents[ $edit_key ] ); // A fresh line lands at the end of the cart; stand it where the original // stood so the cart reads as updated in place. A merge into another // existing line keeps that line's own place. $bb_contents = $cart->cart_contents; if ( false !== $bb_pos && array_key_last( $bb_contents ) === (string) $cart_item_key && isset( $bb_contents[ $cart_item_key ] ) ) { $bb_new = $bb_contents[ $cart_item_key ]; unset( $bb_contents[ $cart_item_key ] ); $cart->cart_contents = array_slice( $bb_contents, 0, (int) $bb_pos, true ) + array( (string) $cart_item_key => $bb_new ) + array_slice( $bb_contents, (int) $bb_pos, null, true ); } addon_boss_edit_replaced( true ); } add_filter( 'woocommerce_add_to_cart_redirect', 'addon_boss_edit_redirect_to_cart', 20 ); /** * A saved update goes back to the cart, where the shopper came from and * where the changed line is. An ordinary add keeps whatever the store does. */ function addon_boss_edit_redirect_to_cart( $url ) { return addon_boss_edit_replaced() ? wc_get_cart_url() : $url; } add_filter( 'wc_add_to_cart_message_html', 'addon_boss_edit_update_message', 10, 2 ); /** * The message says what happened: updated, not added. */ function addon_boss_edit_update_message( $message, $products = array() ) { if ( ! addon_boss_edit_replaced() ) { return $message; } $bb_titles = array(); foreach ( array_keys( (array) $products ) as $bb_pid ) { $bb_t = get_the_title( (int) $bb_pid ); if ( '' !== $bb_t ) { $bb_titles[] = $bb_t; } } if ( ! $bb_titles ) { return $message; } /* translators: %s: the product's name */ return esc_html( sprintf( __( '%s has been updated in your cart.', 'addon-boss' ), implode( ', ', $bb_titles ) ) ); } // ================================================================= // STANDALONE SURFACE part 3: file-upload field config (upload meta/key, // config reader, sanitiser, active-rule check). Lifted from slot-options. // ================================================================= const ADDON_BOSS_STANDALONE_UPLOAD_META = '_bundle_boss_standalone_upload'; const ADDON_BOSS_STANDALONE_UPLOAD_KEY = 'bundle_boss_standalone_upload'; // ================================================================= // STANDALONE SURFACE part 4: front-end render + styles + boot JS. Lifted // from slot-options; registers once via the load-once engine. The one // external stylesheet is provided per-host via the addon_boss_engine_frontend_css // filter (BundleBoss -> css/bundle.css), so BundleBoss renders identically. // ================================================================= // ---- Render ----------------------------------------------------- /** * The merchant's Colours choices, sanitised: hex per known token, nothing * else. The skin has three layers: neutral defaults that work anywhere, a * block theme's published palette flowing in through the var() chains, and * these choices over both. Only the four named tokens store, each as a hex * colour or not at all. * * @param array $raw The posted (or stored) map. * @return array */ function addon_boss_sanitize_colours( array $raw ): array { $clean = array(); foreach ( array( 'accent', 'sel', 'receipt', 'wrap' ) as $bb_key ) { $bb_val = $raw[ $bb_key ] ?? ''; $bb_hex = is_string( $bb_val ) ? sanitize_hex_color( trim( $bb_val ) ) : ''; if ( $bb_hex ) { $clean[ $bb_key ] = $bb_hex; } } return $clean; } /** * The merchant Colours overrides as one inline CSS block, or '' with none * set. It only ever RE-STATES tokens (plus the wrapper's panel chrome when * the optional tint is on: a tinted wrapper reads as a deliberate panel * again, padding and hairline back). Every value passes sanitize_hex_color * again at output, so a corrupted store prints nothing unsafe. */ function addon_boss_colour_override_css(): string { $stored = get_option( 'addon_boss_colours', array() ); $cols = addon_boss_sanitize_colours( is_array( $stored ) ? $stored : array() ); if ( ! $cols ) { return ''; } $css = ''; $tokens = ''; foreach ( array( 'accent', 'sel', 'receipt' ) as $bb_key ) { if ( isset( $cols[ $bb_key ] ) ) { $tokens .= '--aob-' . $bb_key . ':' . $cols[ $bb_key ] . ';'; } } if ( '' !== $tokens ) { // Every surface the colours are supposed to dress has to carry the // tokens, or a merchant sets them and one whole kind of product ignores // them. The quantity grid was missing: a grid product has no // .bb-standalone-options wrapper at all, so --aob-accent was unset there // and the Colours screen appeared to do nothing to anyone selling by // grid. Same lesson as the stylesheet enqueue above, which had to list // its surfaces for the same reason. // // This list must MATCH the skin block in css/options.css, which derives // the legacy --bb-* accent from --aob-accent. A custom property resolves // on the element that declares it, so if the skin derives one level up // from where these tokens land, the merchant's colour never reaches the // derived values. Listing .bb-quantity-grid here while the stylesheet // said .bb-standalone-grid is exactly how the grid kept its old accent // while appearing to be covered. Both names, in both places. // // .bundle-boss-form is deliberately NOT in the stylesheet's derive list: // bundles are BundleBoss's surface and keep their own brand-colour // setting, which writes --bb-accent at :root. Carrying --aob-* there is // harmless and long-standing; deriving the legacy palette there would // repaint another plugin's pages from this screen. // /** * Which storefront surfaces the merchant's colours dress. * * Filterable so a sibling plugin can put its own surface in the list * without this file learning that plugin's name. StockBoss's supplier * price grid is the first taker: it sits on a merchant's own product * page, and a brand colour that dressed everything except it would read * as a broken settings screen. * * A surface joining this list has to declare these same token names at * ZERO specificity of its own, via :where(), or the two plain class * rules tie and the winner comes down to which stylesheet happened to * print last. * * @param array $surfaces CSS selectors. */ $surfaces = (array) apply_filters( 'addon_boss_colour_surfaces', array( '.bb-standalone-options', '.bb-standalone-grid', '.bb-quantity-grid', '.bb-tray-peek.bb-st-peek', '.bundle-boss-form' ) ); $surfaces = array_filter( array_map( 'strval', $surfaces ) ); if ( $surfaces ) { $css .= implode( ',', $surfaces ) . '{' . $tokens . '}'; } } if ( isset( $cols['wrap'] ) ) { // A grid product has no options panel, so the tint has to name the grid's // own wrapper too or "Options background" does nothing to a grid seller. $css .= '.bb-standalone-options,.bb-standalone-grid{--aob-wrap:' . $cols['wrap'] . ';padding:12px;border:1px solid var(--aob-line,#e6e4df);}'; } return $css; } add_action( 'wp_enqueue_scripts', 'addon_boss_standalone_options_styles', 21 ); function addon_boss_standalone_options_styles(): void { if ( ! function_exists( 'is_product' ) || ! is_product() ) { return; } $queried = wc_get_product( get_queried_object_id() ); // Every surface this stylesheet dresses has to be listed, or a product using // only one of them renders with none of it. Positions and product fields were // both missing: a product offering only areas to personalise loaded no // stylesheet at all and its tiles came out as bare theme buttons. $bb_has_positions = function_exists( 'addon_boss_product_positions' ) && addon_boss_product_positions( $queried ); $bb_has_fields = function_exists( 'addon_boss_standalone_product_fields' ) && addon_boss_standalone_product_fields( $queried ); /** * A sibling surface asking to be DRESSED, and nothing more. * * Distinct from addon_boss_standalone_has_extra on purpose. has_extra means * "render the options panel, I have something to sell in it", and the * receipt renders with it. StockBoss's supplier configurator needs only the * stylesheet and the merchant's colours (it joined addon_boss_colour_surfaces * above); when it answered has_extra instead, every supplier-priced product * grew an EMPTY options panel whose receipt echoed the meaningless base * price as a Grand total. Two totals on one screen and only one of them real. * * @param bool $dresses * @param WC_Product $queried */ $bb_dress_only = (bool) apply_filters( 'addon_boss_standalone_worth_dressing', false, $queried ); if ( ! addon_boss_standalone_option_sets( $queried ) && ! apply_filters( 'addon_boss_standalone_has_extra', false, $queried ) && ! $bb_has_positions && ! $bb_has_fields && ! $bb_dress_only ) { return; } // The chip/swatch base styles, provided through the filter (AddOnBoss -> // css/options.css): register with that URL when given, else a bare handle // to carry the inline styles. $bb_css = (string) apply_filters( 'addon_boss_engine_frontend_css', '' ); wp_register_style( 'addon-boss-options-front', '' !== $bb_css ? $bb_css : false, array(), ADDON_BOSS_VERSION ); wp_enqueue_style( 'addon-boss-options-front' ); wp_add_inline_style( 'addon-boss-options-front', // The owner-approved hybrid skin: each group is a white card (surface, // hairline, the 15px radius, a soft double shadow) standing DIRECTLY // on the theme's own page; group names read as treatment B's tracked // uppercase labels. The wrapper paints nothing by default (his // correction: a fixed tint fights somebody's store, transparent fights // nobody's), so --aob-wrap resolves to transparent and the border and // padding stay off. The Colours screen's optional tint sets the token // AND restores the panel chrome, in its own later rule. Every colour // and shape rides a --aob-* token (defined in css/options.css, with a // theme-inherit chain and the merchant Colours overrides); the literal // fallbacks here keep the surface styled even with no stylesheet. '.bb-standalone-options{margin:0 0 1.1em;padding:0;border:0;border-radius:calc(var(--aob-radius,15px) + 4px);background:var(--aob-wrap,transparent);color:var(--aob-text,#3a3f47);font-size:max(16px,1em);}' // The card. Groups nested inside a titled section stand down to plain // rows (a card in a card is noise); the section carries the chrome. . '.bb-standalone-options .bb-attr-group{margin:0 0 12px;padding:15px 16px;border:1px solid var(--aob-line,#e6e4df);border-radius:var(--aob-radius,15px);background:var(--aob-surface,#fff);box-shadow:0 1px 2px rgba(24,26,30,.04),0 10px 28px rgba(24,26,30,.05);}' . '.bb-standalone-options > .bb-attr-group:last-child,.bb-standalone-options .bb-po-section:last-child{margin-bottom:0;}' . '.bb-standalone-options .bb-attr-group[hidden]{display:none !important;}' . '.bb-standalone-options .bb-chip-delta{color:inherit;opacity:.75;font-size:0.85em;font-variant-numeric:tabular-nums;}' // A stock-limited option that has sold out renders as a dimmed, disabled // chip with a "Sold out" line; a low one carries a quiet "Only N left". . '.bb-standalone-options .bb-chips .bb-chip{flex-wrap:wrap;}' . '.bb-standalone-options .bb-chips .bb-chip.bb-chip--soldout{opacity:.6;}' . '.bb-standalone-options .bb-chips .bb-chip[disabled]{cursor:not-allowed;}' // A full-width flex line drops the stock note onto its own row, centred // under the name, so it never butts against the label. . '.bb-standalone-options .bb-chip-stock{flex-basis:100%;width:100%;margin:3px 0 0;line-height:1;text-align:center;font-size:.72em;font-weight:700;letter-spacing:.02em;}' . '.bb-standalone-options .bb-chip-stock--out{color:#b3261e;}' . '.bb-standalone-options .bb-chip-stock--low{color:#8a6d00;}' . '.bb-standalone-options .bb-opt-note{margin:9px 0 2px;padding:9px 12px;border-left:3px solid var(--aob-accent,#fd4730);background:color-mix(in srgb,var(--aob-accent,#fd4730) 6%,var(--aob-surface,#fff));border-radius:0 8px 8px 0;font-size:0.9em;color:var(--aob-text,#3a3f47);line-height:1.45;}' . '.bb-standalone-options .bb-opt-note[hidden]{display:none;}' // Several-of: the sentence that states the rule ("Choose up to 3.") // sits quietly under the chips; a refused pick hands it the accent for // a moment, so the answer arrives at the control, not in a theme // notice at the top of the page. . '.bb-standalone-options .bb-multi-hint{margin:8px 0 0;font-size:0.85em;color:var(--aob-mut,#7d8590);transition:color .2s ease;}' . '.bb-standalone-options .bb-multi-hint.is-refused{color:var(--aob-accent,#fd4730);font-weight:700;}' // A several-of long set's checkbox rows: plain native ticks in a quiet // column, each row a comfortable touch target with its price at rest // on the right, the same anatomy as the chips laid on its side. . '.bb-standalone-options .bb-checks{display:flex;flex-direction:column;gap:2px;max-width:420px;}' . '.bb-standalone-options .bb-check-row{display:flex;align-items:center;gap:10px;margin:0;padding:8px 10px;border-radius:9px;font-size:0.95em;color:var(--aob-text,#3a3f47);cursor:pointer;}' . '.bb-standalone-options .bb-check-row:hover{background:var(--aob-soft,#faf9f7);}' . '.bb-standalone-options .bb-check-row input{margin:0;accent-color:var(--aob-accent,#fd4730);width:17px;height:17px;flex:none;}' . '.bb-standalone-options .bb-check-row .bb-check-name{flex:1 1 auto;min-width:0;}' . '.bb-standalone-options .bb-check-row .bb-chip-delta{white-space:nowrap;}' // A look-up table on the storefront: an axis label + a row of size // buttons (the shared chip), and a live price line that never shifts. . '.bb-standalone-options .bb-tbl-store{display:flex;flex-direction:column;gap:12px;}' . '.bb-standalone-options .bb-tbl-axisrow{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;}' . '.bb-standalone-options .bb-tbl-axislab{font-size:0.9em;color:var(--aob-mut,#7d8590);min-width:56px;}' . '.bb-standalone-options .bb-tbl-priceline{font-size:1.05em;color:var(--aob-ink,#16181d);font-variant-numeric:tabular-nums;}' . '.bb-standalone-options .bb-tbl-priceline b{font-weight:700;}' . '.bb-standalone-options .bb-tbl-priceline[hidden]{display:none;}' . '.bb-standalone-options .bb-tbl-pricenote{color:#b91c1c;font-size:0.9em;}' // Typed sizes: a number box per option, with the band it landed in stated // beside it, so the shopper reads which row of the price list they are on. . '.bb-standalone-options .bb-tbl-typedlab{display:inline-flex;align-items:center;gap:8px;margin:0;}' . '.bb-standalone-options .bb-tbl-typedin{width:112px;padding:9px 11px;border:1.5px solid var(--aob-line,#e6e4df);border-radius:10px;background:var(--aob-surface,#fff);color:var(--aob-ink,#16181d);font-size:max(16px,0.95em);line-height:1.3;box-sizing:border-box;font-variant-numeric:tabular-nums;}' . '.bb-standalone-options .bb-tbl-typedin:focus{border-color:var(--aob-ink,#16181d);outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--aob-ink,#16181d) 7%,transparent);}' . '.bb-standalone-options .bb-tbl-unit{font-size:0.85em;color:var(--aob-mut,#7d8590);font-weight:700;}' // A list: the native control, themed only as far as the box around it, so the // platform's own picker (and its type-ahead) is left alone. . '.bb-standalone-options .bb-tbl-listlab{display:inline-flex;align-items:center;gap:8px;margin:0;}' . '.bb-standalone-options .bb-tbl-sel{min-width:150px;max-width:100%;padding:10px 11px;border:1.5px solid var(--aob-line,#e6e4df);border-radius:10px;background:var(--aob-surface,#fff);color:var(--aob-ink,#16181d);font-size:max(16px,0.95em);line-height:1.3;}' . '.bb-standalone-options .bb-tbl-sel:focus{border-color:var(--aob-ink,#16181d);outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--aob-ink,#16181d) 7%,transparent);}' . '.bb-standalone-options .bb-tbl-sel option:disabled{color:var(--aob-mut,#7d8590);}' // A long choices set rendered as the list control (D6): the same // themed select, block-level at a comfortable reading width. . '.bb-standalone-options select.bb-list-sel{display:block;width:100%;max-width:420px;}' . '.bb-standalone-options .bb-tbl-bandnote{font-size:0.85em;color:var(--aob-mut,#7d8590);}' . '.bb-standalone-options .bb-tbl-bandnote:empty{display:none;}' // An unsold combination: the option greys out and cannot be picked, so a // shopper never reaches a dead end (the same signal as a sold-out variation). . '.bb-standalone-options .bb-tbl-opt:disabled,.bb-standalone-options .bb-tbl-opt.bb-tbl-off{opacity:.45;cursor:not-allowed;text-decoration:line-through;box-shadow:none;}' . '.bb-standalone-options .bb-when-revealed{box-shadow:inset 3px 0 0 var(--aob-accent,#fd4730),0 1px 2px rgba(24,26,30,.04),0 10px 28px rgba(24,26,30,.05);}' // A group renders as a titled section: ONE card owning its members, // which sit inside as plain rows split by hairlines. . '.bb-standalone-options .bb-po-section{margin:0 0 12px;border:1px solid var(--aob-line,#e6e4df);border-radius:var(--aob-radius,15px);background:var(--aob-surface,#fff);box-shadow:0 1px 2px rgba(24,26,30,.04),0 10px 28px rgba(24,26,30,.05);overflow:hidden;}' . '.bb-standalone-options .bb-po-section .bb-attr-group{margin:0;border:0;border-radius:0;box-shadow:none;background:none;}' . '.bb-standalone-options .bb-po-section .bb-attr-group + .bb-attr-group{border-top:1px solid var(--aob-line,#e6e4df);}' . '.bb-standalone-options .bb-po-section .bb-when-revealed{box-shadow:inset 3px 0 0 var(--aob-accent,#fd4730);}' . '.bb-standalone-options .bb-po-sectionname{padding:14px 16px 4px;}' // The header row: name left, echo right. The echo is the receipt and // the required marking in one: accent "Choose" while unanswered, the // value and its money once answered, the good green once done. . '.bb-standalone-options .bb-ghead{display:flex;width:100%;justify-content:space-between;align-items:baseline;gap:12px;margin:0;padding:0;background:none;border:0;font:inherit;color:inherit;text-align:left;box-shadow:none;}' . 'button.bb-ghead{cursor:pointer;}' . '.bb-standalone-options .bb-ghead:focus-visible{outline:2px solid var(--aob-accent,#fd4730);outline-offset:2px;border-radius:6px;}' // Treatment B's group label: a small tracked uppercase eyebrow in a // tone between muted and ink, and the "(optional)" suffix stepping // back out of the uppercase into its own quiet weight. . '.bb-standalone-options .bb-attr-name{font-size:11px;font-weight:800;letter-spacing:.09em;text-transform:uppercase;color:color-mix(in srgb,var(--aob-mut,#7d8590) 80%,var(--aob-ink,#16181d));margin:0;}' . '.bb-standalone-options .bb-gopt{text-transform:none;letter-spacing:normal;}' . '.bb-gopt{font-weight:600;color:var(--aob-mut,#7d8590);font-size:11.5px;}' // The Required flag: one pill everywhere the word appears (set heads, // field labels, the fold family's section rows). Accent ink on the // accent-soft ground, small caps, never plain red text. The literal // ground declares first, so a browser without color-mix() keeps a // readable pill instead of dropping the declaration. . '.bb-standalone-options .bb-addon-req,.bb-standalone-options .bb-po-reqflag{display:inline-block;color:var(--aob-accent,#fd4730);background:#fff1ee;background:color-mix(in srgb,var(--aob-accent,#fd4730) 9%,var(--aob-surface,#fff));border:0;border-radius:99px;padding:2px 9px;font-size:10.5px;font-weight:700;letter-spacing:.05em;line-height:1.5;text-transform:none;font-variant-caps:all-small-caps;white-space:nowrap;vertical-align:middle;}' . '.bb-gecho{font-size:12.5px;color:var(--aob-mut,#7d8590);text-align:right;font-variant-numeric:tabular-nums;}' . '.bb-gecho.bb-gecho-todo{color:var(--aob-accent,#fd4730);font-weight:700;}' . '.bb-gecho.bb-gecho-done{color:var(--aob-good,#1a7f37);font-weight:600;}' // A required group a blocked add-to-cart pointed at: the same accent the // Choose echo wears, drawn as an inset edge so nothing reflows, gone the // moment the group is answered. The card shadow rides along so the miss // state never flattens the card. . '.bb-standalone-options .bb-attr-group.bb-gmiss{box-shadow:inset 3px 0 0 var(--aob-accent,#fd4730),0 1px 2px rgba(24,26,30,.04),0 10px 28px rgba(24,26,30,.05);}' . '.bb-standalone-options .bb-po-section .bb-attr-group.bb-gmiss{box-shadow:inset 3px 0 0 var(--aob-accent,#fd4730);}' // The words of the block, at the offender: the group's sentence in the // same accent the Choose echo wears, filled by the gate and gone the // moment the group is answered. Empty and [hidden] until then. . '.bb-standalone-options .bb-gmiss-note{margin:6px 0 0;font-size:12.5px;font-weight:700;color:var(--aob-accent,#fd4730);}' . '.bb-standalone-options .bb-gmiss-note[hidden]{display:none;}' . '.bb-gbody{padding-top:11px;}' // A conditional group stays out of sight until its trigger is picked, // then appears in place. No "available when" line on the front end. // (This is the hiding-versus-visibility round's outcome. The paragraph // that used to sit here still described the visible-locked design the // stylesheet had already stopped drawing, which is exactly how a reader // ends up trusting a comment over the CSS underneath it.) . '.bb-attr-group.bb-glocked{display:none;}' . '.bb-lockline{display:none;}' // Scope disclosure: how many decisions the page holds, so the visible // groups never read as "that is all there is". Green once every one // of them is answered. . '.bb-progress{font-size:12.5px;color:var(--aob-mut,#7d8590);text-align:right;padding:0 4px 9px;font-variant-numeric:tabular-nums;}' . '.bb-progress.bb-prog-done{color:var(--aob-good,#1a7f37);font-weight:700;}' . '.bb-progress[hidden]{display:none;}' . '.bb-mdims{display:flex;gap:10px 16px;flex-wrap:wrap;margin:8px 0 6px;}' . '.bb-mdims[hidden]{display:none;}' . '.bb-mdim{display:inline-flex;align-items:center;gap:6px;font-size:0.92em;color:var(--aob-mut,#7d8590);}' . '.bb-mdim input{width:96px;border:1.5px solid var(--aob-line,#e6e4df);border-radius:10px;padding:9px 11px;text-align:right;font-size:max(16px,0.92em);background:var(--aob-surface,#fff);color:var(--aob-ink,#16181d);font-variant-numeric:tabular-nums;}' . '.bb-mdim input:focus{border-color:var(--aob-ink,#16181d);outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--aob-ink,#16181d) 7%,transparent);}' // The size echo panel: the soft ground, the money in ink, per gallery. . '.bb-mprice{margin:8px 0 4px;padding:8px 11px;background:var(--aob-soft,#faf9f7);border:1px solid var(--aob-line,#e6e4df);border-radius:9px;font-size:0.9em;font-variant-numeric:tabular-nums;}' . '.bb-mprice b{font-size:1.1em;color:var(--aob-ink,#16181d);}' . '.bb-mprice[hidden]{display:none;}' . '.bb-mprice-note{color:var(--aob-mut,#7d8590);font-size:0.88em;}' . '.bb-mchips{margin-bottom:4px;}' . '.bb-mdims-note{color:inherit;opacity:.7;font-size:0.82em;font-weight:400;}' . '.bb-mchip-custom{border-style:dashed;}' // The receipt card: per-source lines in the muted tone, an explicit // Total row in the ink, everything tabular, sitting immediately // above the theme's own add-to-cart button (which is never // repainted). The card enters with a deliberate HAND-OFF (his // correction: the options run must visibly finish before the money // starts): a spacing shift, then treatment B's ledger rule drawn // across the card's top, 2px in the ink unless the merchant's // receipt-highlight colour recolours it. On narrow screens it may // pin to the bottom of the options flow, but only when the peek bar // has NOT mounted: two pinned totals stacking is worse than either // alone. . '.bb-standalone-total{display:flex;flex-direction:column;gap:2px;margin:20px 0 0;padding:16px 16px 14px;border:1px solid var(--aob-line,#e6e4df);border-top:2px solid var(--aob-receipt,var(--aob-ink,#16181d));border-radius:var(--aob-radius,15px);background:var(--aob-surface,#fff);box-shadow:0 1px 2px rgba(24,26,30,.04),0 10px 28px rgba(24,26,30,.05);font-size:1em;color:var(--aob-ink,#16181d);font-variant-numeric:tabular-nums;}' . '.bb-standalone-total .bb-st-lines{display:flex;flex-direction:column;gap:3px;}' . '.bb-st-line{display:flex;justify-content:space-between;align-items:baseline;gap:12px;font-size:0.82em;color:var(--aob-mut,#7d8590);}' . '.bb-st-line-n{min-width:0;overflow-wrap:anywhere;}' . '.bb-st-line-a{white-space:nowrap;}' . '.bb-st-row{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding-top:7px;font-weight:800;}' . '.bb-st-lab{font-size:1em;}' . '.bb-st-sum{text-align:right;}' . '.bb-standalone-total b{font-size:1.25em;font-weight:800;}' . '.bb-st-note{color:var(--aob-mut,#7d8590);font-size:0.88em;text-align:right;font-weight:400;}' . '.bb-standalone-total[hidden]{display:none;}' . '@media (max-width:782px){.bb-standalone-total:not(.bb-has-peek){position:sticky;bottom:10px;z-index:5;}}' // The standalone peek bar reuses the bundle bar's shell; only the CTA // needs its own paint (filled, never outline: themes force text // white). It stands in for the theme's own add button, so it wears // the ink/surface pair: readable by construction on any theme, where // an accent fill went white-on-highlighter the moment a theme's // palette lent us something pale. . '.bb-st-peek .bb-peek-cta{background:var(--aob-ink,#16181d);color:var(--aob-surface,#fff);border:0;border-radius:10px;padding:11px 18px;min-height:44px;font-weight:700;font-size:0.95em;cursor:pointer;box-shadow:0 4px 12px -8px var(--aob-ink,#16181d);}' . '.bb-st-peek .bb-peek-cta:hover{filter:brightness(0.95);}' ); // The merchant's Colours choices land LAST, as their own block, so they // override the neutral defaults and the theme inherit alike. $bb_colour_css = addon_boss_colour_override_css(); if ( '' !== $bb_colour_css ) { wp_add_inline_style( 'addon-boss-options-front', $bb_colour_css ); } // The boot rides the script loader, NEVER inline hook output: block // themes pass that output through content filters that entity-encode // ampersands (&& arrived as && on Twenty Twenty-Five and the // whole script died on a syntax error). Loader-printed footer scripts // bypass content filtering entirely; the handle dedupes the once-only. wp_register_script( 'addon-boss-standalone', false, array(), ADDON_BOSS_VERSION, true ); wp_enqueue_script( 'addon-boss-standalone' ); // One handle, one order: the engine's own driver first, then whatever each // module appends on the filter, so a module's script can lean on the // conventions the driver establishes (the live-charge and echo data // attributes, and the bb-pick-change / bb-group-clear / bb-live-change // events) without sharing a scope with it. wp_add_inline_script( 'addon-boss-standalone', (string) apply_filters( 'addon_boss_standalone_boot_js', addon_boss_standalone_boot_js() ) ); } add_action( 'woocommerce_before_add_to_cart_button', 'addon_boss_render_standalone_options' ); /** * D6: how many choices a chip row carries before the set switches to the * list control. Past this, chips sprawl into a wall; the native select * scans, searches (type-ahead) and fits a phone. Display only, and the * merchant can pin the chips per set. */ const ADDON_BOSS_CHIP_LIST_THRESHOLD = 12; /** * The swatch shape a choices set renders with (D1, display only). * * An explicit merchant choice always wins. Absent means square, except that * a set whose options carry photos prefers the photo card by itself: a * merchant who mapped photos onto choices meant them to be seen. * * @param array $set A resolved (or stored) set. * @return string 'square', 'circle' or 'card'. */ function addon_boss_option_set_shape( array $set ): string { $explicit = (string) ( $set['shape'] ?? '' ); if ( in_array( $explicit, array( 'square', 'circle', 'card' ), true ) ) { return $explicit; } foreach ( (array) ( $set['options'] ?? array() ) as $bb_o ) { if ( ! empty( $bb_o['image_id'] ) ) { return 'card'; } } return 'square'; } /** * Whether a label already says "optional" in its own words. Such a label is * its own marking, and the "(optional)" suffix beside it would read as a * stutter: "Optional extras (optional)". The comparison uses the same * translated word the suffix prints, so it follows the store's language. */ function addon_boss_label_says_optional( string $label ): bool { $word = trim( __( '(optional)', 'addon-boss' ), '() ' ); return '' !== $word && false !== mb_stripos( $label, $word ); } /** * The "(optional)" marker for a label, or '' when the label already carries * the word itself. The one source of truth for the suffix: field labels, set * headers and fold summaries all mark themselves through here. * * @param string $label The label being marked. * @param string $class The class the marker wears where it renders. */ function addon_boss_optional_marker( string $label, string $class = 'bb-gopt' ): string { if ( addon_boss_label_says_optional( $label ) ) { return ''; } return ' ' . esc_html__( '(optional)', 'addon-boss' ) . ''; } /** * Whether a group's name and its only member's name are near-duplicates * ("How it is finished" holding "Finish"), so stacking both headings would * say the same thing twice. Case-insensitive containment either way round; * the shorter name must have some substance (three characters), so a trivial * fragment can never collapse an unrelated pair. */ function addon_boss_labels_near_duplicate( string $a, string $b ): bool { $a = trim( $a ); $b = trim( $b ); if ( '' === $a || '' === $b ) { return false; } $needle = mb_strlen( $a ) <= mb_strlen( $b ) ? $a : $b; $stack = ( $needle === $a ) ? $b : $a; return mb_strlen( $needle ) >= 3 && false !== mb_stripos( $stack, $needle ); } /** * A group's header row: the name on the left, the live echo on the right. * The heading marks the STATE in the same grammar the field labels wear: * the platform's word "Required" (drawn by the skin as the accent-soft * pill) or "(optional)", unless the name already says so in its own words. * The echo stays the VALUE half of the marking: "Choose" in the accent * while an answer is owed, the pick and its money once given, "None" for * an optional group left alone, and NOTHING while a conditional group * waits for its trigger (the greyed body plus its availability line carry * that state; the research is clear that a status word like "Locked" is * mechanism language a shopper should not have to read). A plain row, not * a control: every group stays open, so there is nothing to toggle. */ function addon_boss_standalone_group_head( string $name, bool $required ): string { return '
' . '' . esc_html( $name ) . ( $required ? ' ' . esc_html__( 'Required', 'addon-boss' ) . '' : addon_boss_optional_marker( $name ) ) . '' . '' . '
'; } /** * The tags and attributes wp_kses() is allowed to keep in the option-set and * field markup this engine prints. One shared allowlist for every sink on this * surface: the structural elements, the form controls with their own * attributes, links and images, and the global class, id, style, data and aria set. * Every value is still escaped at its sink (esc_attr/esc_html); this is the * outer gate that proves it to a reader, and to Plugin Check, at the echo. */ function addon_boss_kses_allowed(): array { $global = array( 'class' => true, 'id' => true, 'style' => true, 'title' => true, 'role' => true, 'hidden' => true, 'tabindex' => true, 'data-*' => true, 'aria-label' => true, 'aria-hidden' => true, 'aria-live' => true, 'aria-pressed' => true, 'aria-checked' => true, // A sold-out choice carries both `disabled` and `aria-disabled`. The // first is allowed on form elements below; without this one kses would // strip the second silently, so the chip would look and behave disabled // while telling a screen reader nothing at all. 'aria-disabled' => true, 'aria-expanded' => true, 'aria-describedby' => true, 'aria-required' => true, 'aria-labelledby' => true, ); $form = array_merge( $global, array( 'type' => true, 'name' => true, 'value' => true, 'checked' => true, 'selected' => true, 'disabled' => true, 'required' => true, 'placeholder' => true, 'min' => true, 'max' => true, 'step' => true, 'maxlength' => true, 'minlength' => true, 'pattern' => true, 'accept' => true, 'multiple' => true, 'readonly' => true, 'inputmode' => true, 'autocomplete' => true, 'size' => true, 'rows' => true, 'cols' => true, 'for' => true, 'form' => true, ) ); return array( 'div' => $global, 'span' => $global, 'p' => $global, 'section' => $global, 'fieldset' => $global, 'legend' => $global, 'ul' => $global, 'ol' => $global, 'li' => $global, 'strong' => $global, 'em' => $global, 'b' => $global, 'i' => $global, 'small' => $global, 'code' => $global, 'br' => $global, 'hr' => $global, 'abbr' => $global, 'h2' => $global, 'h3' => $global, 'h4' => $global, 'label' => $form, 'input' => $form, 'select' => $form, 'option' => $form, 'optgroup' => $form, 'textarea' => $form, 'button' => $form, 'a' => array_merge( $global, array( 'href' => true, 'target' => true, 'rel' => true, ) ), 'img' => array_merge( $global, array( 'src' => true, 'alt' => true, 'width' => true, 'height' => true, 'loading' => true, 'srcset' => true, 'sizes' => true, ) ), 'table' => $global, 'thead' => $global, 'tbody' => $global, 'tfoot' => $global, 'tr' => $global, 'th' => array_merge( $global, array( 'scope' => true, 'colspan' => true, 'rowspan' => true, ) ), 'td' => array_merge( $global, array( 'colspan' => true, 'rowspan' => true, ) ), ); } /** * The pieces a group wears when it carries a reveal rule: the data attributes * the reveal script reads, the locked-or-revealed class, and the availability * line stating the condition in the customer's words. All three come from the * module that evaluates such rules; with nothing registered a rule was * already wiped on read, and a group wears none of them. * * @param array $when The stored rule. * @param bool $active Whether the group is currently active. * @param array $sets The resolved sets (for the trigger's name and option). * @return array{attrs:string,class:string,lock:string} */ function addon_boss_standalone_reveal_bits( array $when, bool $active, array $sets ): array { $bits = array( 'attrs' => '', 'class' => '', 'lock' => '', ); if ( ! $when ) { return $bits; } return (array) apply_filters( 'addon_boss_reveal_group_bits', $bits, $when, $active, $sets ); } /** * A field snapshot's frozen style as a display suffix, or '' when the field * carries none. Rides the same rows the charge suffix does, so the cart line * and the order line both read "Maria (Script, #d4af37) (+5.00)". */ function addon_boss_standalone_field_style_suffix( array $fsnap ): string { $style = trim( (string) ( $fsnap['style'] ?? '' ) ); return '' !== $style ? ' (' . $style . ')' : ''; } /** * One product-FIELD group on the standalone product page. Reuses the shared * personalisation field renderer verbatim; the field posts inside the native * add-to-cart form as bb_pf[], and its data-bb-fee feeds the live total. * Wrapped in a .bb-attr-group so it flows with the option-set groups (and, in * Phase 2, can carry the same reveal rule the sets do). */ function addon_boss_render_standalone_field( $product, array $field, array $when = array(), array $sets = array(), bool $active = true ): void { if ( ! function_exists( 'addon_boss_personalisation_render_field' ) || empty( $field['type'] ) || empty( $field['key'] ) ) { return; } // Prefill on a failed add re-render so the shopper never loses what they // typed. Display only, on the storefront where WooCommerce's add-to-cart // carries no nonce; the submitted value is re-resolved and re-validated // server-side at add-to-cart, and every render path escapes at output. $posted = null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_REQUEST['bb_pf'][ $field['key'] ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $posted = map_deep( wp_unslash( $_REQUEST['bb_pf'][ $field['key'] ] ), 'sanitize_textarea_field' ); } // Extra group attributes (a placement's settings for the module that // draws on the product photo) ride this seam, escaped by the module that // builds them; with nothing registered a group carries none. $bb_extra_attrs = (string) apply_filters( 'addon_boss_field_group_extra_attrs', '', $field, $product ); // The same reveal rule the sets carry: a field shown "only when" a set is // picked renders VISIBLE and LOCKED until then (never hidden), reads its // trigger off the same select, carries a plain availability line, and its // fee drops out of the live total while locked. The reveal module's script // keeps it in step with the picks client-side, mirroring the server. $bb_reveal = addon_boss_standalone_reveal_bits( $when, $active, $sets ); $when_attrs = $bb_reveal['attrs']; // The group states its field kind (bb-fk-text, bb-fk-number, ...) so the // stylesheet can lay kinds out by nature: two short fields standing next // to each other pair onto one row where the wrapper is wide enough (D5), // with no admin control and no script. Which kinds pair is entirely the // stylesheet's decision; the markup just stops hiding what each group is. $group_class = 'bundle-item-attribute bb-attr-group bb-field-group bb-fk-' . sanitize_html_class( (string) $field['type'] ) . $bb_reveal['class']; // Which variations this field is for, carried onto the group so the page // scripts can mirror the server's inertness rule: a field restricted to // other variations must not gate the add, count in the progress line or // price into the live total while a variation it does not apply to is the // one being bought. The server has always stood such a field down; the // page just had no way to know. $bb_vars_attr = ''; if ( ! empty( $field['only_variations'] ) ) { $bb_vars_attr = ' data-bb-vars="' . esc_attr( implode( ',', array_map( 'absint', (array) $field['only_variations'] ) ) ) . '"'; } $when_attrs .= $bb_vars_attr; // A type whose group carries its own wiring (an upload field's endpoint // attributes) rides the callback its module registered. $bb_ftypes = addon_boss_personalisation_field_types(); $upload_attrs = is_callable( $bb_ftypes[ (string) $field['type'] ]['group_attrs'] ?? null ) ? (string) call_user_func( $bb_ftypes[ (string) $field['type'] ]['group_attrs'], $field, $product ) : ''; // The words a blocked add says at this field, in the field's own label // (gauntlet P1); the sets' twin above explains the pattern. Only a // required field can block, so only a required field carries them. $bb_miss_attr = ''; if ( ! empty( $field['required'] ) ) { /* translators: %s: the field's own label, e.g. "Engraving" */ $bb_miss_attr = ' data-bb-miss="' . esc_attr( sprintf( __( 'Fill in %s first.', 'addon-boss' ), (string) ( $field['label'] ?? '' ) ) ) . '"'; } echo wp_kses( '
', addon_boss_kses_allowed() ); if ( '' !== $bb_miss_attr ) { echo ''; } if ( '' !== $bb_reveal['lock'] ) { echo '
' . esc_html( $bb_reveal['lock'] ) . '
'; } // A mode that displays as something other than its stored self (a percent // charge resolves to its actual money, off the same base the server prices // from) is adjusted by the module that owns the mode, so the shopper reads // "+£3.00" rather than a percentage they have to do arithmetic on and the // live total needs no maths of its own. The server recomputes the exact // charge at add-to-cart, so what is shown is what is charged. $render_field = (array) apply_filters( 'addon_boss_render_field_def', $field, $product ); echo '
'; // A field the merchant set to repeat asks several times, through the // module that owns repeats: it answers true when it drew the cards. Every // other field renders exactly the single input it always has. if ( ! (bool) apply_filters( 'addon_boss_render_repeat_field', false, $render_field, $posted ) ) { echo wp_kses( (string) addon_boss_personalisation_render_field( $render_field, 'bb_pf[' . $field['key'] . ']', $posted, 'block' ), addon_boss_kses_allowed() ); } // Whatever a module offers inside this group beyond the input itself (the // typeface and colour chips of a placement field) renders here, through // the module's own listener; a build without one prints nothing. do_action( 'addon_boss_standalone_field_extra', $field, $product ); echo '
'; echo '
'; } function addon_boss_render_standalone_options(): void { global $product; if ( ! $product instanceof WC_Product || ! $product->is_purchasable() ) { return; } $sets = addon_boss_standalone_option_sets( $product ); if ( ! $sets && ! addon_boss_standalone_product_fields( $product ) && ! apply_filters( 'addon_boss_standalone_has_extra', false, $product ) ) { return; // nothing to show: no sets, no fill-in fields, and no module with something of its own. } // Prefill picks first (a failed add re-renders in the same request, and an // edit arrival seeds the line's picks), then the activity map, so a // conditional set arrives hidden or revealed in the same state the server // will enforce. A several-of set's entry is its array of keys; a pick-one // set's stays the string it always was. // Cleaned HERE, at the boundary, rather than trusting the helpers to do it // out of sight. Both are also called with an already-collected $source // array, so the boundary is the caller's job at every one of their sites // and only these two read the request directly. $prefill = array(); foreach ( $sets as $set ) { $field = addon_boss_standalone_field_name( $set['id'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display prefill only; picks are validated against the stored set on submit. $bb_raw = isset( $_REQUEST[ $field ] ) ? map_deep( wp_unslash( $_REQUEST[ $field ] ), 'sanitize_text_field' ) : ''; $bb_pp = addon_boss_standalone_posted_picks( $set, $bb_raw ); $prefill[ $set['id'] ] = ! empty( $set['multi'] ) ? $bb_pp : (string) ( $bb_pp[0] ?? '' ); } $active = addon_boss_active_option_sets( $sets, $prefill ); // Fields join the rule state, so the first paint already agrees with what // the server will enforce for a rule triggered by a tick box. // sanitize_textarea_field, not its one-line sibling: a message field's // newlines are part of the answer, and stripping them here would make the // first paint disagree with the value the box is about to show. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display prefill only; every answer is re-read and validated on submit. $bb_pf = isset( $_REQUEST['bb_pf'] ) ? map_deep( wp_unslash( $_REQUEST['bb_pf'] ), 'sanitize_textarea_field' ) : null; addon_boss_add_field_rule_state( addon_boss_standalone_product_fields( $product ), $bb_pf, $prefill, $active ); // The panel owns every group. A scope line announces how many decisions // exist so the visible groups never read as the whole story (the // illusion-of-completeness risk the visibility research named). Answered // groups refine in place; conditional groups render VISIBLE-LOCKED, never // hidden, so a dependent choice is always discoverable. // The pairs, for the chips. A product that counts per combination hands // its whole table to the page, so picking a colour can grey the sizes that // colour has none of, at the moment of the pick rather than at the button. // An attribute rather than a script tag: a block theme's summary region // content-filters what it finds there, and an inline script has already // been mangled into a syntax error once. $bb_pairs = function_exists( 'addon_boss_combo_stock_all' ) ? addon_boss_combo_stock_all( $product ) : array(); $bb_pairs_attr = ''; if ( $bb_pairs ) { $bb_pairs_attr = ' data-bb-pairs="' . esc_attr( (string) wp_json_encode( array( 'sets' => addon_boss_combo_sets( $product ), 'q' => $bb_pairs ) ) ) . '"'; } echo '
'; /* translators: 1: total number of option groups on the page, 2: how many the shopper has picked so far */ echo ''; // Display-basis base price, shared by the chips (which state each choice as // a whole price) and the total line below. Empty on a variable product, // whose base only exists once a variation is chosen. $base_disp = $product->is_type( 'variable' ) ? '' : (string) wc_get_price_to_display( $product ); // Whole-price chips ("4XL is £26") read well for ONE priced set, where the // choice IS the product's price. On a product with SEVERAL priced sets // (add-ons: print +£35, design +£40, lamination +£16.67...) every set would // repeat the base and no option would read as the add-on it is. So with two // or more priced sets, chips show the "+£35" surcharge instead. $priced_sets = 0; foreach ( $sets as $s ) { foreach ( $s['options'] as $o ) { if ( 0.0 !== (float) $o['delta'] ) { ++$priced_sets; break; } } } $bb_sets_by_id = array(); foreach ( $sets as $bb_s ) { $bb_sets_by_id[ $bb_s['id'] ] = $bb_s; } // Walk the unified config so fields interleave with sets in the merchant's // arranged order. Set rows resolve to their prepared entry; field rows // render inline. A set that no longer resolves (deleted) is skipped, as // before. // One render sequence with explicit group_open / group_close markers, so // each section's boundaries are exact (never guessed by position). A // group is a real container; its children render between the markers. // The layout to draw in, and, in the order the groups will render, whether // each one holds something the shopper has to answer. Worked out from the // config rather than from the render sequence, so the guarantee that a // required group is never folded away does not depend on a marker. $bb_layout_now = addon_boss_options_layout( $product ); $bb_fold_open = array(); $bb_grp_meta = array(); foreach ( (array) addon_boss_product_option_config( $product ) as $bb_grow ) { if ( ( $bb_grow['row_type'] ?? '' ) !== 'group' ) { continue; } $bb_gkids = (array) ( $bb_grow['children'] ?? array() ); $bb_greq = false; foreach ( $bb_gkids as $bb_gkid ) { if ( ! empty( $bb_gkid['required'] ) || ! empty( $bb_gkid['field']['required'] ) ) { $bb_greq = true; break; } } // One heading, not two. A group holding exactly ONE set whose name // near-duplicates its own ("How it is finished" holding "Finish") // collapses into a single card wearing the group's richer name: // stacked, both headings would say the same thing twice. Decided // here from the config, so the walk below needs no lookahead. $bb_gkid_one = ( 1 === count( $bb_gkids ) && is_array( $bb_gkids[0] ?? null ) ) ? $bb_gkids[0] : array(); $bb_gcollapse = '' !== (string) ( $bb_gkid_one['set'] ?? '' ) && isset( $bb_sets_by_id[ (string) $bb_gkid_one['set'] ] ) && addon_boss_labels_near_duplicate( (string) ( $bb_grow['label'] ?? '' ), (string) $bb_sets_by_id[ (string) $bb_gkid_one['set'] ]['name'] ); $bb_grp_meta[] = array( 'req' => $bb_greq, 'collapse' => $bb_gcollapse, ); } $bb_collapse_label = null; $bb_repeat_grp = false; // inside a group whose FIELDS a module drew as one repeating block. foreach ( addon_boss_config_render_sequence( addon_boss_product_option_config( $product ) ) as $bb_cfg ) { if ( ( $bb_cfg['row_type'] ?? '' ) === 'group_open' ) { $g_label = (string) ( $bb_cfg['label'] ?? '' ); // What the config prepass decided for this group, taken in the // same order the groups render, so it needs no key on the sequence. $bb_gm = (array) array_shift( $bb_grp_meta ); $g_req = ! empty( $bb_gm['req'] ); // A group marked repeatable renders AS A UNIT through the module // that owns repeats: the module opens the section and draws every // field child inside the instance blocks, so the walk must not // draw them again; a set child is not part of the block and still // renders below, and the close is the plain section's own. With // no module registered the answer stays false and the group is // the plain always-open section it has always been. if ( (bool) apply_filters( 'addon_boss_render_repeat_group', false, (array) ( $bb_cfg['group'] ?? array() ), $product, $g_req ) ) { $bb_fold_open[] = false; $bb_repeat_grp = true; continue; } if ( ! empty( $bb_gm['collapse'] ) ) { // The near-duplicate 1:1 case draws NO group chrome under any // layout: a section (or a one-question fold) whose title repeats // its content's heading is noise, not structure. The member set // renders as its own card, wearing the group's name. $bb_fold_open[] = 'collapsed'; $bb_collapse_label = $g_label; continue; } // A layout beyond the engine's own stacked flow draws its own group // chrome through the module that registered it, answering with // whether this group FOLDED so the closer knows which tag to close. // With nothing registered (or the module standing aside for this // group) every group is the engine's plain section. $bb_go = apply_filters( 'addon_boss_render_group_open', null, $bb_layout_now, $g_label, $g_req ); if ( is_array( $bb_go ) ) { $bb_fold_open[] = ! empty( $bb_go['fold'] ); continue; } $bb_fold_open[] = false; // A group is a plain section: an optional title, then its body. It is // not collapsible, and shows no title at all when it is nameless. echo '
'; if ( '' !== $g_label ) { echo '
' . esc_html( $g_label ) . '
'; } echo '
'; continue; } if ( ( $bb_cfg['row_type'] ?? '' ) === 'group_close' ) { $bb_closed = array_pop( $bb_fold_open ); if ( 'collapsed' !== $bb_closed ) { echo '
' . ( $bb_closed ? '' : '
' ); } $bb_collapse_label = null; // a collapsed group is spent once closed. $bb_repeat_grp = false; continue; } if ( ( $bb_cfg['row_type'] ?? '' ) === 'field' ) { if ( $bb_repeat_grp ) { continue; // drawn inside the group's instance blocks already. } $bb_fdef = (array) ( $bb_cfg['field'] ?? array() ); // A field type this build does not offer is inert: not rendered (it // is dropped from pricing and validation in the same breath, via // addon_boss_standalone_product_fields). if ( ! addon_boss_field_type_available( (string) ( $bb_fdef['type'] ?? '' ) ) ) { continue; } // Without conditional reveal a field's stored rule is ignored, so it // renders unlocked (its data is dropped from the priced set too). $bb_fwhen = ( addon_boss_engine_can( 'reveal' ) && is_array( $bb_cfg['when'] ?? null ) ) ? $bb_cfg['when'] : array(); $bb_factive = ! $bb_fwhen || (bool) apply_filters( 'addon_boss_when_met', true, $bb_fwhen, $prefill, $active ); if ( addon_boss_rule_removes_row( $bb_fwhen, $bb_factive ) ) { continue; // a fired HIDE rule takes the row off the page, not just out of reach. } addon_boss_render_standalone_field( $product, $bb_fdef, $bb_fwhen, $sets, $bb_factive ); continue; } $set = $bb_sets_by_id[ (string) ( $bb_cfg['set'] ?? '' ) ] ?? null; if ( ! $set ) { continue; } if ( addon_boss_rule_removes_row( $set['when'] ?? array(), ! empty( $active[ $set['id'] ] ) ) ) { continue; // a fired HIDE rule takes the set off the page, not just out of reach. } $field = addon_boss_standalone_field_name( $set['id'] ); $selected = $prefill[ $set['id'] ]; // Several-of: the selected keys as a list (one entry or none on a // pick-one set), the bounds, and the sentence that states them. // The sentence is the control's own always-visible words: it is // how a shopper learns the chips take more than one, and it is // the copy standing right there when a pick past the bound is // refused. $bb_sel_keys = array_values( array_filter( array_map( 'strval', (array) $selected ), 'strlen' ) ); $bb_is_multi = ! empty( $set['multi'] ); $bb_min = $bb_is_multi ? max( 0, (int) ( $set['min'] ?? 0 ) ) : 0; $bb_max = $bb_is_multi ? max( 0, (int) ( $set['max'] ?? 0 ) ) : 0; $bb_hint = ''; if ( $bb_is_multi ) { if ( $bb_min > 1 && $bb_max > 0 ) { /* translators: 1: the smallest number of choices, 2: the largest number of choices */ $bb_hint = sprintf( __( 'Choose at least %1$s and up to %2$s.', 'addon-boss' ), number_format_i18n( $bb_min ), number_format_i18n( $bb_max ) ); } elseif ( $bb_min > 1 ) { /* translators: %s: the smallest number of choices */ $bb_hint = sprintf( __( 'Choose at least %s.', 'addon-boss' ), number_format_i18n( $bb_min ) ); } elseif ( $bb_max > 0 ) { /* translators: %s: the largest number of choices */ $bb_hint = sprintf( __( 'Choose up to %s.', 'addon-boss' ), number_format_i18n( $bb_max ) ); } else { $bb_hint = __( 'Choose as many as you like.', 'addon-boss' ); } } $bb_hint_html = $bb_is_multi ? '
' . esc_html( $bb_hint ) . '
' : ''; $bb_multi_attrs = $bb_is_multi ? ' data-multi="1" data-min="' . esc_attr( (string) $bb_min ) . '" data-max="' . esc_attr( (string) $bb_max ) . '"' : ''; $bb_sreveal = addon_boss_standalone_reveal_bits( is_array( $set['when'] ?? null ) ? $set['when'] : array(), ! empty( $active[ $set['id'] ] ), $sets ); $when_attrs = $bb_sreveal['attrs']; $group_class = 'bundle-item-attribute bb-attr-group' . $bb_sreveal['class']; // A collapsed 1:1 group hands the set its own name: the one heading // wears the group's words, and everything else about the set (its // field name, its echo, its required marking) is exactly itself. $bb_head_name = $set['name']; if ( null !== $bb_collapse_label ) { $bb_head_name = (string) $bb_collapse_label; $bb_collapse_label = null; } // The words a blocked add says AT this group (gauntlet P1: the coral // outline alone was wordless). Composed server-side so the copy ships // translated, exactly the shrink notice's discipline; the gate script // fills the live slot below with it and retires it on answer. Only a // group that can actually block carries one: required, or several-of // with an at-least bound the server would refuse a short pick against. $bb_miss_attr = ''; if ( ! empty( $set['required'] ) || ( $bb_is_multi && $bb_min > 1 ) ) { /* translators: %s: the option set's name, e.g. "Candle scent" */ $bb_miss_attr = ' data-bb-miss="' . esc_attr( sprintf( __( 'Choose %s first.', 'addon-boss' ), $bb_head_name ) ) . '"'; } echo wp_kses( '
', addon_boss_kses_allowed() ); echo wp_kses( addon_boss_standalone_group_head( $bb_head_name, ! empty( $set['required'] ) ), addon_boss_kses_allowed() ); if ( '' !== $bb_miss_attr ) { echo ''; } if ( '' !== $bb_sreveal['lock'] ) { echo '
' . esc_html( $bb_sreveal['lock'] ) . '
'; } echo '
'; // A kind priced off its own panel renders through the module that // registered it; the sets list already dropped any kind nothing did. if ( 'choices' !== ( $set['kind'] ?? 'choices' ) ) { $bb_kcb = addon_boss_option_set_kinds()[ (string) $set['kind'] ]['render'] ?? null; if ( is_callable( $bb_kcb ) ) { call_user_func( $bb_kcb, $product, $set ); } echo '
'; continue; } // The bundle component's shape exactly: a hidden select the form // posts natively, driven by the chip row below. // The price wording each choice carries, shared by the chip row and the // list control below. A set that varies in price states each choice as // a WHOLE price (base + that choice's delta), so a dearer size reads // as "£26", never "+£2" aimed at the shopper; every choice in the set // then shows its price, so no single choice is singled out as the // expensive one. A flat set (colours at £0) states nothing; a variable // product has no base here, so it keeps the +/- bump. The HTML // survives for dual-basis (ex/inc VAT) price plugins; attributes and // option text get the plain string. // // A SEVERAL-OF set never qualifies, however few priced sets the page // has: whole prices are the one-of-these-is-your-price story, and its // picks STACK, so three chips each dressed as the finished price would // promise three different totals for one line. Its choices keep the // +delta form, which stays true whatever else is ticked. $set_varies = false; foreach ( $set['options'] as $o ) { if ( 0.0 !== (float) $o['delta'] ) { $set_varies = true; break; } } // The merchant's Prices on choices setting (Pro) overrides the automatic // pick: 'delta' forces the +bump, 'full' forces whole prices wherever a // base exists, 'hidden' shows no figures at all. The several-of stacking // truth holds under EVERY mode: its picks add up, so its chips never // dress as a finished price. Hidden only silences the printed badge; // the delta DATA still rides the control, so the total and the charge // are untouched by a display choice. $bb_spmode = function_exists( 'addon_boss_grid_size_prices' ) ? addon_boss_grid_size_prices( $product ) : ''; $absolute = '' === $bb_spmode || 'hidden' === $bb_spmode ? ( $set_varies && '' !== $base_disp && $priced_sets <= 1 && ! $bb_is_multi ) : ( 'full' === $bb_spmode && '' !== $base_disp && ! $bb_is_multi ); $bb_price_bits = function ( array $opt ) use ( $absolute, $base_disp, $product, $bb_spmode ) { $delta = (float) $opt['delta']; $bump_html = ''; if ( 'hidden' === $bb_spmode ) { $bump_html = ''; } elseif ( $absolute ) { $bump_html = wc_price( (float) $base_disp + wc_get_price_to_display( $product, array( 'price' => $delta, 'qty' => 1 ) ) ); } elseif ( 0.0 !== $delta ) { $bump_html = ( $delta > 0 ? '+' : '-' ) . wc_price( wc_get_price_to_display( $product, array( 'price' => abs( $delta ), 'qty' => 1 ) ) ); } return array( 'html' => $bump_html, 'text' => $bump_html ? trim( wp_strip_all_tags( $bump_html ) ) : '', // The display-basis delta rides the control as data, so the page // total can reconcile product + options without re-deriving. 'delta' => 0.0 !== $delta ? wc_get_price_to_display( $product, array( 'price' => $delta, 'qty' => 1 ) ) : 0.0, ); }; // The reveal region for per-option notes: shown for the chosen option, // only rendered when at least one option in the set carries a note. // Shared by both controls, so a long set keeps its notes. // // A several-of set keeps a line PER PICK, each named. One region held // one note, so ticking a second choice silently replaced the first // one's description: pick Front chest and then Left sleeve and what // the chest print actually is disappeared off the screen. Naming them // is not decoration either; with several answers up at once, an // unattributed sentence belongs to nobody. $bb_has_desc = false; $bb_sel_desc = ''; $bb_lines = array(); foreach ( $set['options'] as $bb_o ) { if ( ! empty( $bb_o['desc'] ) ) { $bb_has_desc = true; } if ( in_array( (string) $bb_o['key'], $bb_sel_keys, true ) && '' !== (string) ( $bb_o['desc'] ?? '' ) ) { $bb_sel_desc = (string) $bb_o['desc']; $bb_lines[] = '' . esc_html( $bb_o['label'] ) . ' ' . esc_html( (string) $bb_o['desc'] ) . ''; } } if ( ! $bb_has_desc ) { $bb_note = ''; } elseif ( $bb_is_multi ) { $bb_note = '
' . implode( '', $bb_lines ) . '
'; } else { $bb_note = '
' . esc_html( $bb_sel_desc ) . '
'; } // D6: past the threshold a chip row sprawls, so the set renders as the // same NATIVE list control the price tables use (the platform's own // picker, whose type-ahead is the search), unless the merchant pinned // the chips for this set. The select is the same posted field wearing // the same classes the gate, the answered reading and the miss marking // already drive; each option carries the label, the display-basis // delta and the note for the scripts, and states its price in words. // A several-of long set cannot be a single select, and the platform's // multiple select is a control shoppers famously fight, so it renders // as plain checkbox rows instead: each row posts the same field // natively and carries the same data the page scripts read. // A colour or photo set stays as chips no matter how many options it // holds. Pouring eighteen colours into a ' . '' . esc_html( $opt['label'] ) . '' . ( '' !== $bb_p['text'] ? ' ' . wp_kses_post( $bb_p['html'] ) . '' : '' ) . ''; } echo '
'; echo wp_kses( $bb_hint_html . $bb_note . '', addon_boss_kses_allowed() ); continue; } echo ''; echo wp_kses( $bb_note . '', addon_boss_kses_allowed() ); continue; } // The bundle component's shape exactly: a hidden select the form // posts natively, driven by the chip row below. A several-of set's is // the same select made multiple (its name picks up the array brackets, // its data-attribute stays the bare field the chip driver looks up), // so every pick posts natively and nothing else changes shape. echo ''; // The swatch shape (D1, display only): square and circle ride the row // as a class the stylesheet reads; the photo card rebuilds each // chip's own anatomy (the visual filling it, name and price // beneath). Same button, same classes the scripts drive, same data // attributes, same hidden select: the shape never touches keys, // money or the form. $bb_shape = addon_boss_option_set_shape( $set ); // The whole-row rule at render: a row that cannot be uniform in dots // renders uniform pills. That now gates STORED swatches too, not just // the name guess: a supplier feed (or a merchant mid-edit) can hand // one option a colour and its siblings none, and the one dot chip in // a row of pills was exactly the mixed-shape defect the sanitiser's // comment warns about (the camo cap: one white tile, two bare pills). // Storage is untouched; the dot returns the moment the row completes. // The card shape keeps every swatch either way: every option cards // alike there, so a colour fill mixes no shapes. $bb_row_dots = addon_boss_swatch_row_uniform( $set['options'] ); echo '
'; foreach ( $set['options'] as $opt ) { $swatch = ( $bb_row_dots || 'card' === $bb_shape ) ? ( $opt['swatch'] ? $opt['swatch'] : addon_boss_swatch_from_name( $opt['label'] ) ) : ''; $is_sel = in_array( (string) $opt['key'], $bb_sel_keys, true ); // Per-option stock: read the live count, so a sold-out option renders // as a disabled "Sold out" chip up front (the add-to-cart guard stays // the backstop) and a low one nudges with what is left. The low // threshold is the store's own, the one WooCommerce products use. $bb_soldout = false; $bb_left = null; $bb_ql = addon_boss_option_stock_left( (string) ( $set['id'] ?? '' ), (string) $opt['key'] ); if ( null !== $bb_ql ) { $bb_soldout = $bb_ql <= 0; if ( ! $bb_soldout ) { $bb_low = (int) get_option( 'woocommerce_notify_low_stock_amount', 2 ); if ( $bb_ql <= max( 1, $bb_low ) ) { $bb_left = $bb_ql; } } } // The set's display toggle: when off, the numbers stay hidden and a // sold-out option is simply not offered (the add-to-cart guard still // blocks it). Absent means on, so existing sets are unchanged. if ( isset( $set['show_stock'] ) && empty( $set['show_stock'] ) ) { if ( $bb_soldout ) { continue; } $bb_left = null; } $bb_p = $bb_price_bits( $opt ); // No live price engine on a plain product page, so the price rides // the chip itself; a dot-only chip carries it in the title. $bump_html = $bb_p['html']; $bump = $bb_p['text']; $title = $opt['label'] . ( $bump ? ' ' . $bump : '' ); $bb_card = ( 'card' === $bb_shape ); $classes = 'bb-chip' . ( $bb_card ? ' bb-chip--card' : ( $swatch ? ' bb-chip--swatch' : '' ) ) . ( $is_sel ? ' is-selected' : '' ) . ( $bb_soldout ? ' bb-chip--soldout' : '' ); $delta_disp = $bb_p['delta']; // A mapped photo rides the chip through the module that owns the // swap; a build without one puts no image on any chip, and the // stored image id is untouched. The SET goes with it, because // whether picking should move the product's own photograph is a // property of the set rather than of one choice. $img_attrs = (string) apply_filters( 'addon_boss_option_photo_attrs', '', $opt, $product, $set ); echo wp_kses( ''; } echo wp_kses( '
' . $bb_hint_html . $bb_note . '', addon_boss_kses_allowed() ); } // Whatever a module offers on this surface beyond the sets and fields // (the artwork upload box) renders here, after the sets, through the // module's own listener. do_action( 'addon_boss_standalone_options_extra', $product, $sets, $prefill ); // The receipt the category calls a breakdown: per-source lines (the base // with its size where one is measured, each fee-bearing choice, repeats // as "N × thing") above an explicit Total row, live with every pick, so // the number on the chip, the number in the receipt and the number in // the cart are one story. The server paints the first state (a simple // product arrives with its base line and total filled, no script // needed); the script rebuilds the lines from the same per-control walk // that computes the figure. On a variable page the whole receipt joins // the moment a variation is chosen. $bb_rcpt_name = $product->get_name(); echo '
' . '
' . ( '' !== $base_disp ? '
' . esc_html( $bb_rcpt_name ) . '' . wp_kses_post( wc_price( (float) $base_disp ) ) . '
' : '' ) . '
' // "Grand total", not "Total": the receipt above it already shows a price // against the product and against each option, so a bare "Total" reads // like one more line rather than the sum of all of them. . '
' . esc_html__( 'Grand total', 'addon-boss' ) . '' . '' . ( '' !== $base_disp ? wp_kses_post( wc_price( (float) $base_disp ) ) : '' ) . '
' . '' . '' . '
'; echo ''; } /** * The chip driver as a STRING for the script loader. bundle.js only boots * inside a bundle form and no BundleBoss script loads on a plain product * page, so this self-contained binding mirrors its chip-to-select contract * (same markup, same toggle-off semantics, change event for themes that * listen). It attaches via wp_add_inline_script in the enqueue, because * printing it inline into hook output let block themes content-filter it * into a syntax error. */ function addon_boss_standalone_boot_js(): string { ob_start(); try { ?> ( function () { // Clearing a group's own pick keeps the page honest with the server, // which discards a hidden group's value anyway. A module that decides a // group must clear (the reveal evaluator hiding one) asks through the // bb-clear-group event rather than reaching into this driver. function bbClearGroup( group ) { var select = group.querySelector( 'select.bundle-attr-select' ); if ( select && select.multiple ) { Array.prototype.forEach.call( select.options, function ( o ) { o.selected = false; } ); } else if ( select && select.value ) { select.value = ''; } group.querySelectorAll( '.bb-chip.is-selected' ).forEach( function ( c ) { c.classList.remove( 'is-selected' ); c.setAttribute( 'aria-pressed', 'false' ); } ); // A several-of long set's checkbox rows blank the same way. group.querySelectorAll( '.bb-checks input:checked' ).forEach( function ( c ) { c.checked = false; } ); // A field group blanks its own input so a hidden field submits // nothing (the server treats it as inert regardless; the page // just stays honest and the fee leaves the live total). if ( group.classList.contains( 'bb-field-group' ) ) { group.querySelectorAll( '[name^="bb_pf"]' ).forEach( function ( el ) { if ( 'checkbox' === el.type || 'radio' === el.type ) { el.checked = false; } else { el.value = ''; } } ); } // Whatever a module keeps inside this group (typed dimensions, a live // charge it computed) is its own to clear; the event says now is the // moment. group.dispatchEvent( new CustomEvent( 'bb-group-clear', { bubbles: true } ) ); } document.addEventListener( 'bb-clear-group', function ( e ) { if ( e.target && e.target.classList ) { bbClearGroup( e.target ); } } ); // A refused pick points at the sentence that already states the bound: // the hint takes the accent for a moment, and its aria-live region has // just been re-read, so the answer arrives at the control, kindly. function bbHintRefuse( group ) { var hint = group.querySelector( '.bb-multi-hint' ); if ( ! hint ) { return; } hint.classList.add( 'is-refused' ); setTimeout( function () { hint.classList.remove( 'is-refused' ); }, 1200 ); } // The note region, rebuilt from what is CHOSEN rather than from what // was just clicked. A several-of set keeps a line per pick, each // named: one region holding one note meant a second tick silently // wiped the first one's description off the page. function bbNotes( group ) { var note = group.querySelector( '.bb-opt-note' ); if ( ! note ) { return; } var many = note.classList.contains( 'bb-opt-note--many' ); var on = group.querySelectorAll( '.bb-chip.is-selected, .bb-checks input:checked' ); if ( ! many ) { // Pick-one: the chosen option's note, exactly as before. A // select-driven long set answers through its own handler. var one = on[ on.length - 1 ]; var txt = one ? ( one.dataset.desc || '' ) : ''; note.textContent = txt; note.hidden = ( '' === txt ); return; } var html = ''; Array.prototype.forEach.call( on, function ( el ) { var d = el.dataset.desc || ''; if ( ! d ) { return; } var line = document.createElement( 'span' ); line.className = 'bb-opt-noteline'; var b = document.createElement( 'b' ); b.textContent = el.dataset.label || ''; line.appendChild( b ); line.appendChild( document.createTextNode( ' ' + d ) ); html += line.outerHTML; } ); note.innerHTML = html; note.hidden = ( '' === html ); } // ── Pairs: a colour AND a size ──────────────────────────────────── // A product whose counts are kept per combination hands its table to // the page. Picking a colour then re-reads every size against THAT // colour, so the size the supplier has none of in it goes dim at the // moment of the pick. With nothing picked yet a choice is dimmed only // when it is gone in every combination, because it is still available // in some of them and dimming it would be a lie. // // A courtesy, not the rule: the server counts the same pairs again on // add, and it is the server that decides what a shopper buys. var bbPairsRoot = document.querySelector( '.bb-standalone-options[data-bb-pairs]' ); var bbPairs = null; if ( bbPairsRoot ) { try { bbPairs = JSON.parse( bbPairsRoot.getAttribute( 'data-bb-pairs' ) ); } catch ( err ) { bbPairs = null; } } function bbPairGroups() { var out = {}; if ( ! bbPairs || ! bbPairs.sets ) { return out; } bbPairs.sets.forEach( function ( sid ) { var g = bbPairsRoot.querySelector( '.bb-attr-group[data-set="' + sid + '"]' ); if ( g ) { out[ sid ] = g; } } ); return out; } function bbPairPicked( group ) { var chip = group ? group.querySelector( '.bb-chip.is-selected' ) : null; if ( chip ) { return chip.dataset.value || ''; } var sel = group ? group.querySelector( 'select.bundle-attr-select' ) : null; return sel && sel.value ? sel.value : ''; } function bbPairsApply() { if ( ! bbPairs || ! bbPairs.sets || bbPairs.sets.length < 2 ) { return; } var groups = bbPairGroups(); var sets = bbPairs.sets.filter( function ( sid ) { return groups[ sid ]; } ); if ( sets.length < 2 ) { return; } sets.forEach( function ( sid, idx ) { var others = sets.filter( function ( o ) { return o !== sid; } ); groups[ sid ].querySelectorAll( '.bb-chip' ).forEach( function ( chip ) { var key = chip.dataset.value || ''; var any = false; // available in at least one live combination var known = false; // this choice appears in the table at all Object.keys( bbPairs.q ).forEach( function ( pair ) { var parts = pair.split( '|' ); if ( parts[ idx ] !== key ) { return; } // Every other set that HAS a pick must match it; a set // with no pick yet matches anything. var fits = true; others.forEach( function ( o ) { var oi = sets.indexOf( o ); var on = bbPairPicked( groups[ o ] ); if ( on && parts[ oi ] !== on ) { fits = false; } } ); if ( ! fits ) { return; } known = true; if ( bbPairs.q[ pair ] > 0 ) { any = true; } } ); // A pair the table does not hold is a combination the supplier // does not make, so it is dimmed exactly like an empty one: // White stopping at XXL is as real as White XXL selling out. // Only a product holding no table at all leaves chips alone. if ( ! known && ! bbPairs.q ) { return; } if ( ! known && ! Object.keys( bbPairs.q ).length ) { return; } chip.classList.toggle( 'bb-chip--soldout', ! any ); if ( any ) { chip.removeAttribute( 'disabled' ); chip.removeAttribute( 'aria-disabled' ); } else { chip.setAttribute( 'disabled', 'disabled' ); chip.setAttribute( 'aria-disabled', 'true' ); // A pick that has just become impossible cannot stand: // leaving it selected would send a shopper to a button // that refuses them. if ( chip.classList.contains( 'is-selected' ) ) { chip.classList.remove( 'is-selected' ); chip.setAttribute( 'aria-pressed', 'false' ); var sel = groups[ sid ].querySelector( 'select.bundle-attr-select' ); if ( sel ) { sel.value = ''; sel.dispatchEvent( new Event( 'change', { bubbles: true } ) ); } } } } ); } ); } if ( bbPairs ) { bbPairsApply(); document.addEventListener( 'bb-pick-change', bbPairsApply ); } document.addEventListener( 'click', function ( e ) { var chip = e.target.closest ? e.target.closest( '.bb-standalone-options .bb-chip' ) : null; if ( ! chip ) { return; } var wrap = chip.closest( '.bb-chips' ); var group = chip.closest( '.bb-attr-group' ); var select = group && wrap ? group.querySelector( 'select[data-attribute="' + wrap.dataset.forSelect + '"]' ) : null; if ( ! select ) { return; } if ( '1' === ( group.dataset.multi || '' ) ) { // Several-of: each chip toggles for itself and the picks // accumulate. Going past the up-to bound is refused HERE, at // the chip, with the hint's own words; the server enforces // the same bound regardless. var on = chip.classList.contains( 'is-selected' ); var max = parseInt( group.dataset.max, 10 ) || 0; if ( ! on && max > 0 && wrap.querySelectorAll( '.bb-chip.is-selected' ).length >= max ) { bbHintRefuse( group ); return; } chip.classList.toggle( 'is-selected', ! on ); chip.setAttribute( 'aria-pressed', on ? 'false' : 'true' ); var onKeys = []; wrap.querySelectorAll( '.bb-chip.is-selected' ).forEach( function ( c ) { onKeys.push( c.dataset.value ); } ); Array.prototype.forEach.call( select.options, function ( o ) { o.selected = onKeys.indexOf( o.value ) >= 0; } ); } else if ( chip.classList.contains( 'is-selected' ) ) { chip.classList.remove( 'is-selected' ); chip.setAttribute( 'aria-pressed', 'false' ); select.value = ''; } else { wrap.querySelectorAll( '.bb-chip' ).forEach( function ( c ) { c.classList.remove( 'is-selected' ); c.setAttribute( 'aria-pressed', 'false' ); } ); chip.classList.add( 'is-selected' ); chip.setAttribute( 'aria-pressed', 'true' ); select.value = chip.dataset.value; } bbNotes( group ); select.dispatchEvent( new Event( 'change', { bubbles: true } ) ); // Modules that react to a pick (a reveal chain re-evaluating) listen // for this rather than reaching into the driver. group.dispatchEvent( new CustomEvent( 'bb-pick-change', { bubbles: true, detail: { chip: chip } } ) ); bbTotal(); } ); // A several-of long set's checkbox rows: a tick is a pick, the up-to // bound refuses the tick that would pass it, and the same pick event // fires for the modules. document.addEventListener( 'change', function ( e ) { var c = e.target; if ( ! c || ! c.matches || ! c.matches( '.bb-standalone-options .bb-checks input' ) ) { return; } var group = c.closest( '.bb-attr-group' ); if ( ! group ) { return; } if ( c.checked ) { var max = parseInt( group.dataset.max, 10 ) || 0; if ( max > 0 && group.querySelectorAll( '.bb-checks input:checked' ).length > max ) { c.checked = false; bbHintRefuse( group ); return; } } bbNotes( group ); group.dispatchEvent( new CustomEvent( 'bb-pick-change', { bubbles: true, detail: {} } ) ); bbTotal(); } ); // A long set's list control (D6) is its own select: a change is the // pick. The note follows the chosen option exactly as it follows a // chip, the same pick event fires for the modules, and the total // refigures. document.addEventListener( 'change', function ( e ) { var s = e.target; if ( ! s || ! s.matches || ! s.matches( '.bb-standalone-options select.bb-list-sel' ) ) { return; } var group = s.closest( '.bb-attr-group' ); var noteEl = group ? group.querySelector( '.bb-opt-note' ) : null; if ( noteEl ) { var o = s.selectedOptions && s.selectedOptions[ 0 ]; var t = ( s.value && o ) ? ( o.dataset.desc || '' ) : ''; noteEl.textContent = t; noteEl.hidden = ( '' === t ); } if ( group ) { group.dispatchEvent( new CustomEvent( 'bb-pick-change', { bubbles: true, detail: {} } ) ); } bbTotal(); } ); // The reconciliation: product plus options equals total, one story // from the top of the page to the cart. The base is the product's // display price on a simple, the chosen variation's on a variable. var bbVarBase = null; function bbFmt( box, amount ) { // The merchant's own money format, the same recipe the printed // prices use: separators, decimals and symbol position, so the // receipt's figures read exactly like the rest of the page. var neg = amount < 0; // Each of these is always printed, so an empty value is the // merchant's setting, not a missing attribute. Two of those // settings are falsy: a zero-decimal currency (SEK, JPY, HUF, // ISK) and a blank thousands separator. An `||` default // overrules precisely the merchants who chose them. var dec = parseInt( box.dataset.decimals, 10 ); if ( ! isFinite( dec ) || dec < 0 ) { dec = 2; } var ts = undefined === box.dataset.ts ? ',' : box.dataset.ts; var ds = undefined === box.dataset.ds ? '.' : box.dataset.ds; var s = Math.abs( amount ).toFixed( dec ); var parts = s.split( '.' ); parts[ 0 ] = parts[ 0 ].replace( /\B(?=(\d{3})+(?!\d))/g, ts ); s = parts.join( ds ); var sym = box.dataset.symbol || '', out; switch ( box.dataset.pos ) { case 'right': out = s + sym; break; case 'left_space': out = sym + ' ' + s; break; case 'right_space': out = s + ' ' + sym; break; default: out = sym + s; } return ( neg ? '-' : '' ) + out; } // VAT-toggle compatibility (Tax Switch for WooCommerce and its kind): // paint the toggle plugin's own dual-basis markup so a computed total // answers the ex/inc switch, exactly as the bundle total does. Without // it the number froze in one basis after every repaint. function bbToggleActive() { if ( window.wtsCompatibilityObject ) { return true; } var b = document.querySelector( ".bb-standalone-total" ); return !! ( b && "1" === b.dataset.wts ); // the server said the toggle plugin runs here. } function bbEscape( s ) { var d = document.createElement( "i" ); d.textContent = String( s ); return d.innerHTML; } function bbOtherBasis( box, amount ) { // Our own server-computed rate first: the toggle plugin's compat // object only exists where one of its shims loads, and a page can // be covered in its dual prices without one (see data-wts). var rate = parseFloat( box.dataset.wtsRate ) || parseFloat( ( window.wtsCompatibilityObject || {} ).baseTaxRate ) || 0; var f = 1 + rate / 100; return ( "incl" === box.dataset.taxBasis ) ? amount / f : amount * f; } function bbInclActive() { var active = document.querySelector( ".wts-price-wrapper .wts-active" ); return !! ( active && active.classList.contains( "wts-price-incl" ) ); } function bbMoneyHTML( box, amount ) { var incl = ( "incl" === box.dataset.taxBasis ) ? amount : bbOtherBasis( box, amount ); var excl = ( "incl" === box.dataset.taxBasis ) ? bbOtherBasis( box, amount ) : amount; var inclOn = bbInclActive(); // hide the off-basis side with wts-inactive, not just mark the shown side active (Tax Switch hides .wts-inactive) return "" + "" + bbEscape( bbFmt( box, excl ) ) + "" + "" + bbEscape( bbFmt( box, incl ) ) + "" + ""; } // Write money into an element; template may carry one %s ("%s total"). // Plain textContent with no toggle plugin, dual markup with one. function bbSetMoney( el, box, amount, template ) { if ( ! el ) { return; } if ( ! bbToggleActive() ) { var text = bbFmt( box, amount ); el.textContent = template ? template.replace( "%s", text ) : text; return; } var html = bbMoneyHTML( box, amount ); el.innerHTML = template ? bbEscape( template ).replace( "%s", html ) : html; } // wdevs-tax-switch-changed: the toggle event for late painters. Re-point // every dual-basis price we drew (total, and the peek copy of it) to the // chosen side; both bases are already in the markup so we only move the // active/inactive classes, idempotent with the plugin document-wide pass. function bbRepointTax( incl ) { document.querySelectorAll( ".wts-price-wrapper" ).forEach( function ( w ) { var i = w.querySelector( ".wts-price-incl" ), x = w.querySelector( ".wts-price-excl" ); if ( i ) { i.classList.toggle( "wts-active", incl ); i.classList.toggle( "wts-inactive", ! incl ); } if ( x ) { x.classList.toggle( "wts-active", ! incl ); x.classList.toggle( "wts-inactive", incl ); } } ); } document.addEventListener( "wdevs-tax-switch-changed", function ( ev ) { var d = ( ev && ev.detail ) || {}; var incl = typeof d.displayIncludingTax === "boolean" ? d.displayIncludingTax : typeof d.displayIncludingVat === "boolean" ? d.displayIncludingVat : bbInclActive(); bbRepointTax( incl ); } ); // The quantity being bought, read from the THEME'S OWN field (the same // selector the repeat script follows), because a second quantity control // of ours is how these plugins break on page builders. The live total // multiplies exactly as the cart will: at qty 3 a page quoting one // unit's figure while the cart adds three units' worth is the // category's classic one-star trigger. function bbQty() { var root = document.querySelector( '.bb-standalone-options' ); var form = ( root && root.closest ) ? root.closest( 'form' ) : null; if ( ! form ) { form = document.querySelector( 'form.cart' ); } var q = form ? form.querySelector( 'input.qty, input[name="quantity"]' ) : null; return q ? Math.max( 1, parseInt( q.value, 10 ) || 1 ) : 1; } // A field restricted to certain variations is INERT for the rest: the // server never requires it and never charges it, so the page must not // gate on it, count it or price it either. No variation chosen yet // means the field stands, the server's own rule. function bbVarInert( group ) { var only = ( group.dataset.bbVars || '' ).trim(); if ( ! only ) { return false; } var form = group.closest ? group.closest( 'form' ) : null; var vin = form ? form.querySelector( 'input[name="variation_id"]' ) : null; var vid = vin ? parseInt( vin.value, 10 ) || 0 : 0; if ( vid <= 0 ) { return false; } return ( ',' + only + ',' ).indexOf( ',' + vid + ',' ) < 0; } // A money cell for one receipt line: plain text normally, the VAT // toggle plugin's dual-basis markup when one is on, exactly as // bbSetMoney paints the total itself. function bbMoneyCell( box, amount ) { return bbToggleActive() ? bbMoneyHTML( box, amount ) : bbEscape( bbFmt( box, amount ) ); } // The label a fee-bearing FIELD control lines under: the field's own // label (its first text node, before the Required word and the // counter), or the tick-box's caption. function bbFeeLabel( el ) { var f = el.closest ? el.closest( '.bb-addon-field' ) : null; if ( ! f ) { return ''; } var lab = f.querySelector( '.bb-addon-label' ) || f.querySelector( '.bb-addon-check span' ); if ( ! lab ) { return ''; } var n = lab.childNodes[ 0 ]; return ( ( n && 3 === n.nodeType ) ? n.nodeValue : ( lab.textContent || '' ) ).trim(); } // What a repeatable field's instances are collectively called, read // off the wrap's own naming pattern ("Engraving %d" -> "Engraving"), // so the receipt speaks the merchant's word for the thing. function bbRepThing( wrap ) { return ( wrap.getAttribute( 'data-bb-repeat-label' ) || '' ).replace( '%d', '' ).trim(); } function bbTotal() { var box = document.querySelector( '.bb-standalone-total' ); if ( ! box ) { return; } // Two pots, exactly the split the price seam makes: per-unit money // multiplies by the quantity; whole-line money joins the total once // however many are bought (the server spreads it across the units, // so per-unit times quantity lands back on this same figure). // Alongside the figure, the same walk now collects the receipt's // per-source lines, so the breakdown and the total are one pass // and can never disagree. var opts = 0; var flat = 0; var perLines = []; // per-unit sources: multiply with the quantity. var flatLines = []; // whole-line sources: once for the line. var repAgg = []; // repeatable fields, aggregated per wrap. var sizeTexts = []; // a measured or table size joins the BASE line. var sizeCharge = 0; var zeroPicks = []; // free picks (a colour, a size) name the BASE line. // The client half of the price-parts seam: a module may take a share // off PER-UNIT option money (the quantity-discount toggle), exactly // as the server's parts filter does, and the same figure feeds the // line and the total so the receipt still reconciles. Whole-line // money is never offered to it, and a module's own negative store // charge passes through each function's own sign guard untouched. // Empty by default: a no-op. // Alongside the share cut, keep the RAW per-unit fee sum. Every // per-unit fee source passes through here exactly once (whole-line // money is guarded away at every call site), so this one line is // the whole accounting. Positive only: a module's own negative // store charge (the quantity-discount share) is a reduction of the // base, not an option fee, and counting it would fold the discount // into the very figure other modules price options FROM. var bbRawUnit = 0; var bbRawPos = 0; function bbFeeShare( a, kind ) { kind = kind || 'opt'; if ( a > 0 ) { if ( 'pos' === kind ) { bbRawPos += a; } else { bbRawUnit += a; } } ( window.addonBossFeeShare || [] ).forEach( function ( f ) { a = f( a, kind ); } ); return a; } document.querySelectorAll( '.bb-standalone-options .bb-attr-group:not([hidden]):not(.bb-glocked) .bb-chip.is-selected[data-delta]' ).forEach( function ( c ) { var d = bbFeeShare( parseFloat( c.dataset.delta ) || 0 ); opts += d; if ( 0 !== d && ! c.closest( '.bb-style-chips' ) ) { perLines.push( { n: c.dataset.label || '', a: d } ); } else if ( 0 === d && ! c.closest( '.bb-style-chips' ) && c.dataset.label ) { // A free pick earns no line of its own, but it IS what is // being bought: the colour and the size join the base line, // so the receipt says "Team shirt, M, Navy", not just the // product's name. zeroPicks.push( c.dataset.label ); } } ); // A long set's list control (D6): the chosen option carries the same // display-basis delta a chip would, and lines under its own label. document.querySelectorAll( '.bb-standalone-options .bb-attr-group:not([hidden]):not(.bb-glocked) select.bb-list-sel' ).forEach( function ( s ) { var o = s.selectedOptions && s.selectedOptions[ 0 ]; if ( ! s.value || ! o ) { return; } var d = bbFeeShare( parseFloat( o.dataset.delta ) || 0 ); opts += d; if ( 0 !== d ) { perLines.push( { n: o.dataset.label || '', a: d } ); } else if ( o.dataset.label ) { zeroPicks.push( o.dataset.label ); } } ); // A several-of long set's checkbox rows: every tick adds its own // fee and lines under its own label, exactly like a selected chip. document.querySelectorAll( '.bb-standalone-options .bb-attr-group:not([hidden]):not(.bb-glocked) .bb-checks input:checked[data-delta]' ).forEach( function ( c ) { var d = bbFeeShare( parseFloat( c.dataset.delta ) || 0 ); opts += d; if ( 0 !== d ) { perLines.push( { n: c.dataset.label || '', a: d } ); } } ); // A module that computes its own live figure (a measured size, a // price-table cell) writes it onto its store element as // data-bb-live-charge; this driver only ever reads the number. The // words for the receipt ride beside it as data-bb-size-text, so a // priced size joins the base line the way the group itself tells // the band-price story. document.querySelectorAll( '.bb-standalone-options .bb-attr-group:not([hidden]):not(.bb-glocked) [data-bb-live-charge]' ).forEach( function ( g ) { var c = bbFeeShare( parseFloat( g.dataset.bbLiveCharge ) || 0 ); opts += c; if ( '' !== ( g.dataset.bbLiveCharge || '' ) ) { sizeCharge += c; if ( g.dataset.bbSizeText ) { sizeTexts.push( g.dataset.bbSizeText ); } } } ); // Product-field fees, one CONTROL at a time, each priced by the // same rule the server charges by. A dropdown prices by its CHOSEN // option only (every option carries its own fee, and summing the // lot as descendants was the every-option bug). A mode whose maths // lives in a module pre-computes onto data-bb-live-fee and this // driver reads that instead; a filled or ticked flat fee adds as // itself. Per-unit charges multiply with the quantity. Two things // are whole-line money and join FLAT instead: a 'once' fee (a // setup charge for the line however many are bought), and any // instance of a per-item repeatable field, whose instances // already scale with the quantity and must not be multiplied by // it a second time. This is the live estimate; the server // re-resolves the exact charge at add-to-cart (spreading // once-fees, rejecting tampering). var bbWholeLine = function ( el, mode ) { if ( 'once' === mode ) { return true; } // A once-mode DROPDOWN wears mode "select" (price by the // chosen option), so its whole-line fact rides the marker. if ( el.dataset && '1' === ( el.dataset.bbFeeOnce || '' ) ) { return true; } return !! ( el.closest && el.closest( '.bb-repeat[data-bb-repeat="per_item"]' ) ); }; // One receipt entry for a fee a field control earned. Instances // of a repeatable field aggregate under their wrap ("2 × // engraving"); everything else lines under the field's label. // The count is INSTANCES, not controls: a group's block holds // several priced fields and is still one engraving. var bbFeeLine = function ( el, due, whole ) { if ( due <= 0 ) { return; } var wrap = el.closest ? el.closest( '.bb-repeat' ) : null; if ( wrap ) { var it = el.closest( '[data-bb-repeat-item]' ); for ( var ri = 0; ri < repAgg.length; ri++ ) { if ( repAgg[ ri ].wrap === wrap ) { repAgg[ ri ].sum += due; if ( it && -1 === repAgg[ ri ].items.indexOf( it ) ) { repAgg[ ri ].items.push( it ); } return; } } repAgg.push( { wrap: wrap, items: it ? [ it ] : [], sum: due, whole: whole } ); return; } ( whole ? flatLines : perLines ).push( { n: bbFeeLabel( el ), a: due } ); }; document.querySelectorAll( '.bb-standalone-options .bb-attr-group:not([hidden]):not(.bb-glocked) [data-bb-fee]' ).forEach( function ( el ) { // A field inert for the chosen variation never charges, so its // fee must leave the figure the moment that variation is picked. var vg = el.closest ? el.closest( '.bb-attr-group' ) : null; if ( vg && bbVarInert( vg ) ) { return; } if ( 'OPTION' === el.tagName ) { return; } // priced through its select, below. if ( 'SELECT' === el.tagName ) { var opt = el.selectedOptions && el.selectedOptions[ 0 ]; if ( el.value && opt ) { var sfee = parseFloat( opt.dataset.bbFee ) || 0; var swhole = bbWholeLine( el, el.dataset.bbFeeMode || '' ); if ( ! swhole ) { sfee = bbFeeShare( sfee ); } if ( swhole ) { flat += sfee; } else { opts += sfee; } bbFeeLine( el, sfee, swhole ); } return; } var fee = parseFloat( el.dataset.bbFee ) || 0; if ( fee <= 0 ) { return; } var mode = el.dataset.bbFeeMode || ''; var whole = bbWholeLine( el, mode ); var due = 0; if ( undefined !== el.dataset.bbLiveFee ) { // A mode whose maths lives in a module pre-computed its // figure onto the control; this driver only reads it. due = parseFloat( el.dataset.bbLiveFee ) || 0; } else { var filled = ( 'checkbox' === el.type ) ? el.checked : ( ( el.value || '' ).trim() !== '' ); if ( filled ) { due = fee; } } if ( ! whole ) { due = bbFeeShare( due ); } if ( whole ) { flat += due; } else { opts += due; } bbFeeLine( el, due, whole ); } ); // Fees a companion module (positions) pre-computed and tagged by // mode. The receipt is the one true total on the page, so it folds // them in with the SAME once-versus-per-item rule its own fields // use: a 'once' fee joins the flat whole-line money, a per-item fee // joins opts and multiplies with the quantity. The server re-resolves // the exact charge at add-to-cart; this is the honest live estimate. document.querySelectorAll( '.bb-pp-xfee[data-bb-xfee]' ).forEach( function ( el ) { var xamt = parseFloat( el.dataset.bbXfee ) || 0; if ( xamt <= 0 ) { return; } var xwhole = 'once' === ( el.dataset.bbXfeeMode || '' ); var xdue = xwhole ? xamt : bbFeeShare( xamt, 'pos' ); if ( xwhole ) { flat += xdue; } else { opts += xdue; } ( xwhole ? flatLines : perLines ).push( { n: el.dataset.bbXfeeLabel || '', a: xdue } ); } ); var qty = bbQty(); var base = '' !== box.dataset.base ? parseFloat( box.dataset.base ) : bbVarBase; var totalEl = box.querySelector( '.bb-st-total' ); var noteEl = box.querySelector( '.bb-st-note' ); var tpl = function ( sel ) { var el = box.querySelector( sel ); return el ? el.textContent : '%s'; }; // ── The receipt lines, from the walk above. ──────────────────── var linesEl = box.querySelector( '.bb-st-lines' ); var qtyTpl = box.dataset.qtyTpl || '%1$s × %2$s'; function bbQtyLabel( n, thing ) { return qtyTpl.replace( '%1$s', String( n ) ).replace( '%2$s', thing ); } var out = []; function bbLine( labelHtml, amount ) { out.push( '
' + labelHtml + '' + bbMoneyCell( box, amount ) + '
' ); } if ( null !== base && ! isNaN( base ) ) { // The base line, wearing its free picks ("Team shirt, M, Navy"), // its measured or table size ("Sign, 60 × 40 cm"), and absorbing // that size's charge, exactly the band-price story the group // itself tells: the line says what is being bought. var bn = bbEscape( box.dataset.name || '' ); if ( zeroPicks.length ) { bn += ', ' + bbEscape( zeroPicks.join( ', ' ) ); } if ( sizeTexts.length ) { bn += ', ' + bbEscape( sizeTexts.join( ', ' ) ); } bbLine( qty > 1 ? bbQtyLabel( qty, bn ) : bn, ( base + sizeCharge ) * qty ); } else if ( 0 !== sizeCharge && sizeTexts.length ) { bbLine( bbEscape( sizeTexts.join( ', ' ) ), sizeCharge * qty ); } perLines.forEach( function ( l ) { var n = bbEscape( l.n ); bbLine( qty > 1 ? bbQtyLabel( qty, n ) : n, l.a * qty ); } ); repAgg.forEach( function ( r ) { var thing = bbEscape( bbRepThing( r.wrap ) ); var cnt = r.items.length || 1; var n = cnt > 1 ? bbQtyLabel( cnt, thing ) : thing; // A per-item repeat's instances already scale with the // quantity (whole-line money); an add-another repeat is // per-unit and multiplies like any other option. bbLine( n, r.whole ? r.sum : r.sum * qty ); } ); flatLines.forEach( function ( l ) { bbLine( bbEscape( l.n ), l.a ); } ); if ( linesEl ) { linesEl.innerHTML = out.join( '' ); } if ( null !== base && ! isNaN( base ) ) { bbSetMoney( totalEl, box, ( base + opts ) * qty + flat, '' ); // The lines above carry the reconciliation now; the Total row // states the figure plainly. noteEl.textContent = ''; box.hidden = false; } else if ( opts > 0 || flat > 0 ) { bbSetMoney( totalEl, box, opts * qty + flat, tpl( '.bb-st-tpl-opts' ) ); noteEl.textContent = ''; box.hidden = false; } else { box.hidden = true; } // Publish the raw per-unit fee sum for any module that prices // AROUND the options (the quantity block's loaded figures read // it), and say the walk settled. One computation, one number, // every reader: re-walking the controls elsewhere is how two // figures on one page learn to disagree. var bbOptsRoot = document.querySelector( '.bb-standalone-options' ); if ( bbOptsRoot ) { bbOptsRoot.dataset.bbUnitFees = String( bbRawUnit ); bbOptsRoot.dataset.bbPositionFees = String( bbRawPos ); bbOptsRoot.dispatchEvent( new CustomEvent( 'bb-fees-settled', { bubbles: true, detail: { unitFees: bbRawUnit, positionFees: bbRawPos } } ) ); } bbRawUnit = 0; bbRawPos = 0; } // A product field changing (typed text, a tick, an uploaded file ref) // re-figures the live total the same as picking a chip does. document.addEventListener( 'input', function ( e ) { if ( e.target && e.target.dataset && undefined !== e.target.dataset.bbFee ) { bbTotal(); } } ); document.addEventListener( 'change', function ( e ) { if ( e.target && e.target.dataset && undefined !== e.target.dataset.bbFee ) { bbTotal(); } } ); // A companion module (positions) recomputed its personalisation fees. document.addEventListener( 'bb-xfee-changed', bbTotal ); // The theme's quantity field re-figures it too. Deferred a tick, so // when the repeat script grows or shrinks its per-item cards on this // same event, the recount sees the field list as it ends up rather // than as it was. Input as well as change: a shopper who TYPES six // must watch the figure move, not only one who clicks the stepper. function bbQtyPing( e ) { if ( e.target && e.target.matches && e.target.matches( 'input.qty, input[name="quantity"]' ) ) { setTimeout( bbTotal, 0 ); } } document.addEventListener( 'input', bbQtyPing ); document.addEventListener( 'change', bbQtyPing ); // Adding or removing a repeatable card changes which fee inputs // exist at all, and a removed card fires no input event of its own, // so the recount rides the click that did it. document.addEventListener( 'click', function ( e ) { if ( e.target && e.target.closest && e.target.closest( '[data-bb-repeat-add], [data-bb-repeat-remove]' ) ) { setTimeout( bbTotal, 0 ); } } ); // On a variable page the native form announces the chosen variation. // The recount is deferred a tick (the quantity listener's idiom): // this handler binds at ready, BEFORE the deferred variation script // binds its own, so it runs first on the event, while the form's // variation_id input still holds the previous pick. Everything that // reads the input (the variation-inert stand-down, the scope line, // the gate marks) would judge against the stale variation; a tick // later the form is settled and the recount reads what the server // will see. bbVarBase needs no wait, the event payload carries it. if ( window.jQuery ) { window.jQuery( function ( $ ) { $( '.variations_form' ) .on( 'found_variation', function ( e, variation ) { bbVarBase = variation && undefined !== variation.display_price ? parseFloat( variation.display_price ) : null; setTimeout( bbTotal, 0 ); } ) .on( 'reset_data hide_variation', function () { bbVarBase = null; setTimeout( bbTotal, 0 ); } ); } ); } // A module recomputing its own live figure says so; the driver refigures // the one total everything reads. document.addEventListener( 'bb-live-change', function () { bbTotal(); } ); // Picks prefilled from a failed add (the page re-rendered with a chip // already selected) get their state on load; each module boots its own // the same way. document.addEventListener( 'DOMContentLoaded', function () { bbTotal(); } ); // ── The configurator frame: header echoes, the growth discipline, // the peek bar. The echo is the receipt and the required marking in // one; folding compresses answered groups at four-plus groups only. function bbStBox() { return document.querySelector( '.bb-standalone-total' ); } function bbGroups() { return document.querySelectorAll( '.bb-standalone-options .bb-attr-group' ); } // A conditional group is LOCKED (visible, disabled, its reason shown) // rather than hidden, by the module that evaluates such rules; this // driver only ever reads the class. function bbActionable( group ) { return ! group.classList.contains( 'bb-glocked' ); } function bbAnswered( group ) { // A module that owns this group's state says whether it is answered // through the echo attribute it maintains: empty means not yet. var own = group.querySelector( '[data-bb-echo]' ); if ( own ) { return '' !== ( own.dataset.bbEcho || '' ); } // A several-of long set's checkbox rows: any tick is an answer. var checks = group.querySelector( '.bb-checks' ); if ( checks ) { return !! checks.querySelector( 'input:checked' ); } var sel = group.querySelector( 'select.bundle-attr-select' ); if ( sel ) { return !! sel.value; } // A FIELD group. Its fold echo already computes answered-ness (typed // text, a tick, an uploaded file, a chosen option), so the same // reading serves the scope line and the add-to-cart gate: without // this branch a shopper who had finished every field still read an // unfinished count. null means the group asks nothing at all (a // heading or an info block), which is never answered and never asked. var t = bbFieldEcho( group ); return null !== t && '' !== t; } // Does this group ask the shopper anything? Display-only groups (a // heading, an info block) must not count in any denominator. function bbAsks( group ) { if ( group.querySelector( '.bb-gecho' ) ) { return true; } return null !== bbFieldEcho( group ); } // A several-of group's picked labels, whichever control it wears, in // the order the controls stand (the set's own order). Null for any // other group, so callers can branch on the shape once. function bbMultiPicks( group ) { if ( '1' !== ( group.dataset.multi || '' ) ) { return null; } var labels = []; var sel = group.querySelector( 'select.bundle-attr-select[multiple]' ); if ( sel ) { Array.prototype.forEach.call( sel.selectedOptions, function ( o ) { labels.push( o.textContent || '' ); } ); } else { group.querySelectorAll( '.bb-checks input:checked' ).forEach( function ( c ) { labels.push( c.dataset.label || '' ); } ); } return labels; } // Engaged but short of the at-least bound: the server would refuse // this exact submit, so the gate holds it at the control instead. function bbMultiShort( group ) { var picks = bbMultiPicks( group ); if ( null === picks ) { return false; } var min = parseInt( group.dataset.min, 10 ) || 0; return picks.length > 0 && picks.length < min; } function bbEchoGroup( group ) { var echo = group.querySelector( '.bb-gecho' ); if ( ! echo ) { return; } if ( group.classList.contains( 'bb-glocked' ) ) { echo.textContent = ''; echo.classList.remove( 'bb-gecho-todo' ); echo.classList.remove( 'bb-gecho-done' ); return; } var text = ''; var own = group.querySelector( '[data-bb-echo]' ); var multi = bbMultiPicks( group ); if ( null !== multi && multi.length ) { // A several-of group's echo IS its pick list: the labels while // they fit on a breath, the count once they stop ("3 chosen"). echo.textContent = multi.length > 2 ? ( echo.dataset.chosen || '%s chosen' ).replace( '%s', String( multi.length ) ) : multi.map( function ( t ) { return t.trim(); } ).join( ', ' ); echo.classList.remove( 'bb-gecho-todo' ); echo.classList.add( 'bb-gecho-done' ); return; } if ( own ) { // A module writes its own receipt (the size and its money, the band // and its cell) onto the store element it maintains. text = own.dataset.bbEcho || ''; } else if ( null === multi ) { // A long set's list control (D6): the chosen option IS the echo, // price and all (its text already states both, translated). var lsel = group.querySelector( 'select.bb-list-sel' ); if ( lsel ) { if ( lsel.value && lsel.selectedOptions.length ) { echo.textContent = ( lsel.selectedOptions[ 0 ].textContent || '' ).trim(); echo.classList.remove( 'bb-gecho-todo' ); echo.classList.add( 'bb-gecho-done' ); return; } } var chip = group.querySelector( '.bb-chip.is-selected' ); if ( chip ) { // Mirror the chip: its label plus its OWN rendered price, so a // dual-basis price plugin's markup (and its ex/inc toggle) // survives instead of the stripped title mashing the two // figures into "£29.17£35.00". Label rides in as text (safe), // the price as the chip's already-escaped markup. var deltaEl = chip.querySelector( '.bb-chip-delta' ); echo.textContent = chip.dataset.label || ''; if ( deltaEl && deltaEl.innerHTML ) { echo.insertAdjacentHTML( 'beforeend', ' ' + deltaEl.innerHTML ); } echo.classList.remove( 'bb-gecho-todo' ); echo.classList.add( 'bb-gecho-done' ); return; } } // An answered group's echo is DONE (the skin reads it green); the // "None" an optional group falls back to is a state, not an answer. var done = '' !== text; var todo = false; if ( '' === text ) { todo = '1' === group.dataset.req; text = todo ? ( echo.dataset.choose || '' ) : ( echo.dataset.none || '' ); } echo.textContent = text; echo.classList.toggle( 'bb-gecho-todo', todo ); echo.classList.toggle( 'bb-gecho-done', done ); } function bbEchoAll() { bbGroups().forEach( bbEchoGroup ); } // A fold shut after a pick must still show the pick, so each summary // mirrors its children's echoes. Set, measured, table and upload // children already keep a body echo; FIELD children have none, so // their value is read straight off the input. function bbEsc( t ) { var i = document.createElement( 'i' ); i.textContent = t; return i.innerHTML; } // A field group's contribution: null when it asks nothing (a heading // or an info block), '' while unanswered, the value once answered. function bbFieldEcho( group ) { var chk = group.querySelector( '.bb-addon-check input[type="checkbox"]' ); if ( chk ) { if ( ! chk.checked ) { return ''; } var lab = group.querySelector( '.bb-addon-check span' ); return lab ? lab.textContent.trim() : ''; } var fref = group.querySelector( '.bb-addon-file-ref' ); if ( fref ) { if ( ! fref.value ) { return ''; } var fn = group.querySelector( '.bb-file-fn' ); return fn ? fn.textContent.trim() : ( fref.dataset.bbFilename || '' ); } var w = group.querySelector( '.bb-size-w' ), h = group.querySelector( '.bb-size-h' ); if ( w && h ) { return ( w.value && h.value ) ? w.value + ' × ' + h.value : ''; } var inp = group.querySelector( '.bb-addon-input' ); if ( ! inp ) { return null; } if ( 'SELECT' === inp.tagName ) { return inp.value && inp.selectedOptions.length ? inp.selectedOptions[ 0 ].textContent.trim() : ''; } var v = ( inp.value || '' ).trim(); if ( '' === v ) { return ''; } // Typed words read back in quotes, the receipt convention for free // text; a number or a date is already its own value. return ( 'TEXTAREA' === inp.tagName || 'text' === inp.type ) ? '“' + v + '”' : v; } function bbEchoFolds() { document.querySelectorAll( '.bb-standalone-options details.bb-po-fold' ).forEach( function ( sec ) { var out = sec.querySelector( 'summary .bb-po-foldecho' ); if ( ! out ) { return; } var parts = [], todo = false, askable = 0; sec.querySelectorAll( '.bb-attr-group' ).forEach( function ( g ) { if ( ! bbActionable( g ) ) { return; } var e = g.querySelector( '.bb-gecho' ); if ( e ) { askable++; // The body echo's own markup, so a price rendered by a // dual-basis plugin survives into the summary too. if ( bbAnswered( g ) ) { parts.push( e.innerHTML ); } else if ( e.classList.contains( 'bb-gecho-todo' ) ) { todo = true; } return; } var t = bbFieldEcho( g ); if ( null === t ) { return; } askable++; if ( '' !== t ) { parts.push( bbEsc( t ) ); } else if ( '1' === g.dataset.req ) { todo = true; } } ); if ( ! askable ) { out.textContent = ''; out.classList.remove( 'bb-gecho-todo' ); out.classList.remove( 'bb-gecho-done' ); return; } if ( parts.length ) { out.innerHTML = parts.join( ', ' ); out.classList.remove( 'bb-gecho-todo' ); out.classList.add( 'bb-gecho-done' ); return; } out.textContent = todo ? ( out.dataset.choose || '' ) : ( out.dataset.none || '' ); out.classList.toggle( 'bb-gecho-todo', todo ); out.classList.remove( 'bb-gecho-done' ); } ); } // The scope line: how many actionable decisions exist and how many are // answered, so the visible groups never read as the whole story. function bbScope() { var prog = document.querySelector( '.bb-standalone-options .bb-progress' ); if ( ! prog ) { return; } var total = 0, done = 0; bbGroups().forEach( function ( g ) { if ( g.hidden || ! bbActionable( g ) || bbVarInert( g ) || ! bbAsks( g ) ) { return; } total++; if ( bbAnswered( g ) ) { done++; } } ); if ( total < 2 ) { prog.hidden = true; return; } prog.hidden = false; prog.textContent = ( prog.dataset.tpl || '' ).replace( '%1$s', String( total ) ).replace( '%2$s', String( done ) ); // Finished scope reads in the good green, the gallery's language. prog.classList.toggle( 'bb-prog-done', done === total ); } // ── The add-to-cart gate: a courtesy, never the authority. ───────── // The server's validation stays the enforcer, exactly as it is; this // only saves the shopper the round trip that lands the answer in a // theme notice at the top of the page, far from the field. The button // stays enabled (a disabled submit is a dead control with no // explanation); a blocked attempt marks the first unanswered required // group with the same accent the Choose echo already wears, scrolls // to it, and moves focus there. function bbFirstMissing() { var found = null; bbGroups().forEach( function ( g ) { if ( found || g.hidden || ! bbActionable( g ) || bbVarInert( g ) || ! bbAsks( g ) ) { return; } if ( '1' === g.dataset.req && ! bbAnswered( g ) ) { found = g; } // A several-of group short of its at-least bound blocks too, // required or not: the server would refuse this exact submit. if ( ! found && bbMultiShort( g ) ) { found = g; } } ); return found; } // The mark leaves the moment the group is answered (or stops being // asked at all), so nobody finishes a field that stays accused; its // sentence leaves with it. function bbMissSync() { document.querySelectorAll( '.bb-standalone-options .bb-attr-group.bb-gmiss' ).forEach( function ( g ) { if ( ! g.hidden && bbActionable( g ) && ! bbVarInert( g ) && ( ! bbAnswered( g ) || bbMultiShort( g ) ) ) { return; } g.classList.remove( 'bb-gmiss' ); g.querySelectorAll( '[aria-invalid="true"]' ).forEach( function ( el ) { el.removeAttribute( 'aria-invalid' ); } ); var gnote = g.querySelector( '.bb-gmiss-note' ); if ( gnote ) { gnote.textContent = ''; gnote.hidden = true; } } ); } function bbMissMark( g ) { g.classList.add( 'bb-gmiss' ); // Say it, at the offender: the group's own server-composed sentence // lands in its live region, so the refusal is words as well as paint. var note = g.querySelector( '.bb-gmiss-note' ); if ( note && g.dataset.bbMiss ) { note.textContent = g.dataset.bbMiss; note.hidden = false; } g.querySelectorAll( 'select.bundle-attr-select, .bb-checks input, .bb-addon-input:not([type="hidden"]), .bb-tbl-sel, .bb-tbl-typedin' ).forEach( function ( el ) { el.setAttribute( 'aria-invalid', 'true' ); } ); g.scrollIntoView( { behavior: 'smooth', block: 'center' } ); // Focus the first thing a keyboard can drive, without yanking the // smooth scroll off course: chips before hidden machinery. var f = g.querySelector( '.bb-chip, .bb-tbl-opt, .bb-mchip, .bb-checks input, .bb-addon-input:not([type="hidden"]), .bb-tbl-sel, .bb-tbl-typedin, .bb-up-pick, .bb-addon-file-input' ); if ( f && f.focus ) { try { f.focus( { preventScroll: true } ); } catch ( _ ) { f.focus(); } } } // Capture phase, the lightbox override's idiom, so an AJAX-add theme's // own submit handler never fires on an add the server would refuse. document.addEventListener( 'submit', function ( e ) { var form = e.target; if ( ! form || ! form.querySelector || ! form.querySelector( '.bb-standalone-options' ) ) { return; } var miss = bbFirstMissing(); if ( ! miss ) { return; } e.preventDefault(); e.stopPropagation(); bbMissMark( miss ); }, true ); // Every state change funnels through bbTotal; the echoes, the scope // line and the peek bar ride the same wave, so none can go stale. var bbTotalCore = bbTotal; bbTotal = function () { bbTotalCore(); bbEchoAll(); bbEchoFolds(); bbScope(); bbMissSync(); bbStPeekSync(); }; // The peek bar: the bundle bar's shell standing in for the theme's add // button while it is off screen. Appended to so no transformed // ancestor can capture its fixed positioning. var bbPeekBar = null; // Fold the tapped-open lines away: whenever the bar itself hides, // an open peek would otherwise reappear pre-opened later. function bbStPeekFold() { if ( ! bbPeekBar ) { return; } var l = bbPeekBar.querySelector( '.bb-peek-lines' ); var o = bbPeekBar.querySelector( '.bb-peek-open' ); if ( l ) { l.hidden = true; } if ( o ) { o.setAttribute( 'aria-expanded', 'false' ); } bbPeekBar.classList.remove( 'is-open' ); } function bbStPeekSync() { if ( ! bbPeekBar ) { return; } var box = bbStBox(); if ( ! box || box.hidden ) { bbPeekBar.bbHasTotal = false; bbPeekBar.classList.remove( 'is-shown' ); bbStPeekFold(); return; } // innerHTML, not textContent: carry the total's dual-basis markup (when // a VAT toggle is on) to the peek bar so it answers the switch too. bbPeekBar.querySelector( '.bb-peek-info' ).innerHTML = box.querySelector( '.bb-st-total' ).innerHTML; bbPeekBar.bbHasTotal = true; bbPeekBar.classList.toggle( 'is-shown', !! bbPeekBar.bbOff ); if ( ! bbPeekBar.classList.contains( 'is-shown' ) ) { bbStPeekFold(); } else { // An open peek rides the same wave as the total: re-copy the // lines on every change, so it can never show a stale receipt. var bbPl = bbPeekBar.querySelector( '.bb-peek-lines' ); var bbSrc = box.querySelector( '.bb-st-lines' ); if ( bbPl && ! bbPl.hidden && bbSrc ) { bbPl.innerHTML = bbSrc.innerHTML; } } } // The boot prints inside the form BEFORE the add button parses, so the // bar mounts at DOM-ready, when the button it stands in for exists. function bbStPeekMount() { if ( bbPeekBar || ! bbStBox() || ! ( 'IntersectionObserver' in window ) ) { return; } var real = document.querySelector( 'form.cart .single_add_to_cart_button' ); if ( ! real ) { return; } bbPeekBar = document.createElement( 'div' ); bbPeekBar.className = 'bb-tray-peek bb-st-peek'; // Tap-to-peek: the total is a button that lifts the receipt's own // lines up out of the bar, so the shopper can check what is in the // number without scrolling back. The lines are the receipt's very // markup, copied on open and on every later change. var lines = document.createElement( 'div' ); lines.className = 'bb-peek-lines'; lines.id = 'bb-peek-lines'; lines.hidden = true; var row = document.createElement( 'div' ); row.className = 'bb-peek-row'; var info = document.createElement( 'span' ); info.className = 'bb-peek-info'; var open = document.createElement( 'button' ); open.type = 'button'; open.className = 'bb-peek-open'; open.setAttribute( 'aria-expanded', 'false' ); open.setAttribute( 'aria-controls', 'bb-peek-lines' ); var chev = document.createElement( 'span' ); chev.className = 'bb-peek-chev'; chev.setAttribute( 'aria-hidden', 'true' ); chev.textContent = '\u25B2'; // No label: the figure and the chevron say it all (his trim). // The button still announces itself to screen readers. open.setAttribute( 'aria-label', '' ); var figr = document.createElement( 'span' ); figr.className = 'bb-peek-figure'; figr.appendChild( info ); figr.appendChild( chev ); open.appendChild( figr ); var cta = document.createElement( 'button' ); cta.type = 'button'; cta.className = 'bb-peek-cta'; cta.textContent = ( real.textContent || '' ).trim() || 'Add to cart'; row.appendChild( open ); row.appendChild( cta ); bbPeekBar.appendChild( lines ); bbPeekBar.appendChild( row ); document.body.appendChild( bbPeekBar ); open.addEventListener( 'click', function () { var src = bbStBox() && bbStBox().querySelector( '.bb-st-lines' ); lines.hidden = ! lines.hidden; if ( ! lines.hidden && src ) { lines.innerHTML = src.innerHTML; } open.setAttribute( 'aria-expanded', lines.hidden ? 'false' : 'true' ); bbPeekBar.classList.toggle( 'is-open', ! lines.hidden ); } ); // The peek bar owns the pinned total from here: the receipt stands // down to a plain block (bb-has-peek), so the figure is never // pinned to the viewport twice at once. var stbox = bbStBox(); if ( stbox ) { stbox.classList.add( 'bb-has-peek' ); } cta.addEventListener( 'click', function () { real.click(); } ); new IntersectionObserver( function ( entries ) { bbPeekBar.bbOff = ! entries[ 0 ].isIntersecting; bbPeekBar.classList.toggle( 'is-shown', !! ( bbPeekBar.bbOff && bbPeekBar.bbHasTotal ) ); if ( ! bbPeekBar.classList.contains( 'is-shown' ) ) { bbStPeekFold(); } } ).observe( real ); bbStPeekSync(); } if ( 'loading' !== document.readyState ) { bbStPeekMount(); } else { document.addEventListener( 'DOMContentLoaded', bbStPeekMount ); } // First paint: the echoes and the scope line tell the story straight // away, on the fold summaries too, so a shut group never starts blank. ( function () { bbEchoAll(); bbEchoFolds(); bbScope(); bbStPeekSync(); } )(); } )(); id ) { return; } $css = (string) apply_filters( 'addon_boss_engine_admin_css', '' ); if ( '' !== $css ) { wp_enqueue_style( 'addon-boss-options-admin', $css, array(), addon_boss_engine_asset_version( $css ) ); } if ( ! wp_script_is( 'bundle-boss-admin', 'enqueued' ) ) { $js = (string) apply_filters( 'addon_boss_engine_admin_js', '' ); if ( '' !== $js ) { wp_enqueue_script( 'addon-boss-options-admin', $js, array( 'jquery', 'select2', 'jquery-ui-sortable' ), addon_boss_engine_asset_version( $js ), true ); wp_localize_script( 'addon-boss-options-admin', 'addonBossAdmin', (array) apply_filters( 'addon_boss_engine_admin_localize', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'addon_boss_admin' ), 'i18n' => array(), ) ) ); } } } // The saved order of option-set groups (names), kept apart from the sets so an // empty group a merchant just made is still there after a reload. const ADDON_BOSS_OPTION_SET_GROUPS_STORE = 'bundle_boss_option_set_groups'; function addon_boss_option_set_groups(): array { $saved = get_option( ADDON_BOSS_OPTION_SET_GROUPS_STORE, array() ); return is_array( $saved ) ? array_values( array_filter( array_map( 'strval', $saved ) ) ) : array(); } /** * The library arranged the way a merchant sees it: folder name => [set id => * name], in the order the list page put the folders in, with everything in no * folder last under an empty key. * * A folder holding nothing is not listed: it is real on the library page (an * empty folder a merchant just made is still theirs) but there is nothing to * attach from it, and offering "everything in here" over an empty folder is a * control that does nothing. * * @return array> */ function addon_boss_option_sets_by_group(): array { $sets = addon_boss_option_sets(); $out = array(); $loose = array(); $known = addon_boss_option_set_groups(); foreach ( $known as $bb_g ) { $out[ $bb_g ] = array(); } foreach ( $sets as $bb_id => $bb_set ) { $bb_g = (string) ( $bb_set['group'] ?? '' ); if ( '' === $bb_g ) { $loose[ $bb_id ] = (string) ( $bb_set['name'] ?? $bb_id ); continue; } // A set naming a folder the order list never heard of still belongs // somewhere, so its folder joins the end rather than swallowing the set. if ( ! isset( $out[ $bb_g ] ) ) { $out[ $bb_g ] = array(); } $out[ $bb_g ][ $bb_id ] = (string) ( $bb_set['name'] ?? $bb_id ); } $out = array_filter( $out ); // drop the folders with nothing in them if ( $loose ) { $out[''] = $loose; } return $out; } add_action( 'wp_ajax_addon_boss_save_set_groups', 'addon_boss_ajax_save_set_groups' ); /** * Persist the grouping from the list page: which group each set sits in, plus * the ordered list of group names. One full-state write, so a drop, a rename * and a delete all travel the same path (the client sends the whole picture). */ function addon_boss_ajax_save_set_groups(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( 'forbidden', 403 ); } check_ajax_referer( 'addon_boss_set_groups' ); $assignments = ( isset( $_POST['assignments'] ) && is_array( $_POST['assignments'] ) ) ? wp_unslash( $_POST['assignments'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each id + value sanitised in the loop. : array(); $order = ( isset( $_POST['order'] ) && is_array( $_POST['order'] ) ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['order'] ) ) : array(); $sets = addon_boss_option_sets(); // A set MOVING INTO a group is the moment worth noticing: products already // carrying that group do not have it yet, and hunting them down by hand is // the job the group was supposed to save. Recorded here and handed back, so // the page can offer rather than a group silently changing live products. $bb_joined = array(); foreach ( $assignments as $set_id => $group ) { $set_id = sanitize_text_field( (string) $set_id ); if ( ! isset( $sets[ $set_id ] ) ) { continue; } $bb_group = sanitize_text_field( (string) $group ); $bb_was = (string) ( $sets[ $set_id ]['group'] ?? '' ); if ( '' !== $bb_group && $bb_group !== $bb_was ) { $bb_missing = addon_boss_products_missing_set( $bb_group, $set_id ); if ( $bb_missing ) { $bb_joined[] = array( 'set' => $set_id, 'name' => (string) ( $sets[ $set_id ]['name'] ?? $set_id ), 'group' => $bb_group, 'count' => count( $bb_missing ), ); } } $sets[ $set_id ]['group'] = $bb_group; } update_option( ADDON_BOSS_OPTION_SETS_STORE, $sets, false ); // Keep unique, non-empty names in the given order. $clean = array(); foreach ( $order as $name ) { $name = trim( $name ); if ( '' !== $name && ! in_array( $name, $clean, true ) ) { $clean[] = $name; } } update_option( ADDON_BOSS_OPTION_SET_GROUPS_STORE, $clean, false ); wp_send_json_success( array( 'joined' => $bb_joined ) ); } /** * Products carrying a group that do NOT yet have one of its sets. * * Findable because every row attached through the group picker records the * group it arrived with, which is the whole reason that was stored. * * @param string $group The group name. * @param string $set_id The set they may be missing. * @return int[] Product ids. */ function addon_boss_products_missing_set( string $group, string $set_id ): array { if ( '' === $group || '' === $set_id ) { return array(); } $bb_q = new WP_Query( array( 'post_type' => 'product', 'post_status' => array( 'publish', 'draft', 'private', 'pending' ), 'posts_per_page' => 200, 'fields' => 'ids', 'meta_key' => ADDON_BOSS_PRODUCT_OPTIONS_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a bounded admin-only lookup, not a front-end query. ) ); $out = array(); foreach ( $bb_q->posts as $bb_pid ) { $bb_rows = addon_boss_flatten_option_config( (array) get_post_meta( (int) $bb_pid, ADDON_BOSS_PRODUCT_OPTIONS_META, true ) ); $bb_from = false; $bb_hasit = false; foreach ( $bb_rows as $bb_r ) { if ( ( $bb_r['set'] ?? '' ) === $set_id ) { $bb_hasit = true; break; } if ( (string) ( $bb_r['from_group'] ?? '' ) === $group ) { $bb_from = true; } } if ( $bb_from && ! $bb_hasit ) { $out[] = (int) $bb_pid; } } return $out; } add_action( 'wp_ajax_addon_boss_apply_set_to_group', 'addon_boss_ajax_apply_set_to_group' ); /** * Add one set to every product carrying its group and missing it. * * Only ever runs because somebody pressed the offer. Nothing about a group * reaches a live product on its own, which is the line between this and simply * linking the group, where editing the library would change what is on sale. */ function addon_boss_ajax_apply_set_to_group(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( 'forbidden', 403 ); } check_ajax_referer( 'addon_boss_set_groups' ); $bb_set = isset( $_POST['set'] ) ? sanitize_text_field( wp_unslash( $_POST['set'] ) ) : ''; $bb_group = isset( $_POST['group'] ) ? sanitize_text_field( wp_unslash( $_POST['group'] ) ) : ''; if ( '' === $bb_set || '' === $bb_group || ! addon_boss_option_set( $bb_set ) ) { wp_send_json_error( 'unknown set', 400 ); } $bb_done = 0; foreach ( addon_boss_products_missing_set( $bb_group, $bb_set ) as $bb_pid ) { $bb_product = wc_get_product( $bb_pid ); if ( ! $bb_product instanceof WC_Product ) { continue; } // Appended, never inserted mid-list: the merchant's own arrangement is // theirs, and a set arriving in the middle of it would be a second // surprise on top of the one they asked for. $bb_rows = addon_boss_product_option_config( $bb_product ); $bb_rows[] = array( 'set' => $bb_set, 'from_group' => $bb_group ); addon_boss_save_product_option_config( $bb_product, $bb_rows ); $bb_done++; } wp_send_json_success( array( 'applied' => $bb_done ) ); } function addon_boss_render_option_sets_page(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'addon-boss' ) ); } $notice = get_transient( 'bundle_boss_option_sets_notice_' . get_current_user_id() ); if ( $notice ) { delete_transient( 'bundle_boss_option_sets_notice_' . get_current_user_id() ); // Tolerate the old plain-string shape as well as the {m,t} array. $msg = is_array( $notice ) ? (string) ( $notice['m'] ?? '' ) : (string) $notice; $type = is_array( $notice ) ? (string) ( $notice['t'] ?? 'success' ) : 'success'; // A notice may carry its one next action (label + URL): the checklist's // "now attach it" after a first save. Escaped here, where it prints. $act = is_array( $notice ) && is_array( $notice['a'] ?? null ) ? $notice['a'] : array(); $link = ''; if ( '' !== (string) ( $act['l'] ?? '' ) && '' !== (string) ( $act['u'] ?? '' ) ) { $link = ' ' . esc_html( (string) $act['l'] ) . ''; } printf( '

%2$s%3$s

', esc_attr( $type ), esc_html( $msg ), wp_kses( $link, addon_boss_kses_allowed() ) ); } $editing = isset( $_GET['set'] ) ? sanitize_text_field( wp_unslash( $_GET['set'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing. $recipe = isset( $_GET['recipe'] ) ? sanitize_key( wp_unslash( $_GET['recipe'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing. echo '
'; if ( 'new' === $editing && '' === $recipe ) { // Never a blank page: a brand-new set opens on the starting points, the // same on-ramp idiom the price-table gallery established. Every stored // set, and a picked recipe, goes straight to the editor. addon_boss_render_new_set_onramp(); } elseif ( '' !== $editing ) { addon_boss_render_option_set_editor( $editing, 'new' === $editing ? addon_boss_new_set_recipe_seed( $recipe ) : null ); addon_boss_set_preview_panel(); } else { addon_boss_render_option_sets_list(); } echo '
'; } /** * The seed a new-set recipe pre-fills the editor with, or null for a scratch * start (and for anything unrecognised, which must never block the editor). * * Two families of recipe, both fully editable before anything saves: * - a ready-made shape from the starter roster ('starter_*'), arriving with * its rows, charges and pick rule filled in; * - a registered set kind ('kind_'), arriving empty but with the kind * already chosen, so its own panel (a price table's template gallery) takes * over from there. Only kinds this build registers qualify, so a stripped * build offers exactly the kinds it can honour. */ function addon_boss_new_set_recipe_seed( string $recipe ): ?array { $starters = addon_boss_option_starter_sets(); if ( isset( $starters[ $recipe ] ) ) { // Through the set sanitiser, so the seed carries the exact stored shape // the editor expects: generated option keys, resolved name-map swatches, // lawful bounds. The keys ride the form and persist on save. return addon_boss_sanitize_option_set( $starters[ $recipe ] ); } if ( 0 === strpos( $recipe, 'kind_' ) ) { $kind = substr( $recipe, 5 ); if ( 'choices' !== $kind && isset( addon_boss_option_set_kinds()[ $kind ] ) ) { return addon_boss_sanitize_option_set( array( 'name' => '', 'kind' => $kind, 'options' => array(), ) ); } } return null; } /** * The new-set on-ramp: a gallery of starting points in the same tile idiom as * the price-table template picker, with a quiet start-from-scratch beside * them. Server-rendered links, so it works before a line of script arrives. */ function addon_boss_render_new_set_onramp(): void { $base = admin_url( 'admin.php?page=addon-boss-option-sets&set=new' ); echo '
'; echo '

' . esc_html__( 'New option set', 'addon-boss' ) . '

'; addon_boss_optsets_header( __( 'New option set', 'addon-boss' ), '' . esc_html__( 'Back to sets', 'addon-boss' ) . '' ); echo '

' . esc_html__( 'Pick the closest starting point and change anything, or start clean. Nothing saves until you press Save set.', 'addon-boss' ) . '

'; echo '
'; foreach ( addon_boss_option_starter_sets() as $bb_rid => $bb_starter ) { echo ''; echo ''; foreach ( array_slice( (array) $bb_starter['options'], 0, 8 ) as $bb_opt ) { $bb_sw = (string) ( $bb_opt['swatch'] ?? '' ); echo '' !== $bb_sw ? '' : '' . esc_html( (string) ( $bb_opt['label'] ?? '' ) ) . ''; } echo ''; echo '' . esc_html( (string) $bb_starter['name'] ) . ''; if ( ! empty( $bb_starter['purpose'] ) ) { echo '' . esc_html( (string) $bb_starter['purpose'] ) . ''; } echo ''; } // Kinds priced by their own panel: whatever this build registers beyond the // plain list gets a tile, worded by its own module, so a build without a // module never offers a page it cannot render. foreach ( addon_boss_option_set_kinds() as $bb_k => $bb_spec ) { if ( 'choices' === $bb_k ) { continue; } $bb_tt = (string) ( $bb_spec['recipe_label'] ?? '' ); if ( '' === $bb_tt ) { $bb_tt = ucfirst( (string) ( $bb_spec['label'] ?? $bb_k ) ); } echo ''; echo '' . esc_html( $bb_tt ) . ''; if ( ! empty( $bb_spec['desc'] ) ) { echo '' . esc_html( (string) $bb_spec['desc'] ) . ''; } echo ''; } echo ''; echo '' . esc_html__( 'Start from scratch', 'addon-boss' ) . ''; echo '' . esc_html__( 'An empty set, yours to shape.', 'addon-boss' ) . ''; echo ''; echo '
'; echo '
'; } /** How many products a set is attached to (a LIKE over the serialized meta). */ function addon_boss_option_set_usage( string $set_id ): int { static $cache = array(); if ( isset( $cache[ $set_id ] ) ) { return $cache[ $set_id ]; } global $wpdb; $cache[ $set_id ] = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- prepared count over serialized postmeta; no core API for this LIKE, static-cached per request. $wpdb->prepare( "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value LIKE %s", ADDON_BOSS_PRODUCT_OPTIONS_META, '%' . $wpdb->esc_like( '"' . $set_id . '"' ) . '%' ) ); return $cache[ $set_id ]; } /** * A price string WITHOUT the wc_price filters, so a VAT-toggle plugin cannot * wrap it in its dual-basis markup. Mirrors wc_price symbol placement and * separators; for admin previews and anywhere one plain figure is wanted. */ function addon_boss_plain_price( float $amount ): string { $formatted = number_format( $amount, wc_get_price_decimals(), wc_get_price_decimal_separator(), wc_get_price_thousand_separator() ); return sprintf( get_woocommerce_price_format(), get_woocommerce_currency_symbol(), $formatted ); } /** One preview chip (shared by the list cards and the product panel). */ function addon_boss_option_preview_chip( array $opt, string $class = '' ): string { $dot = $opt['swatch'] ? '' : ''; // wc_price runs through a VAT-toggle plugin filter that, in wp-admin, wraps // it in dual-basis markup WITHOUT the CSS that hides the off-side, so the two // figures run together. The admin preview only needs the plain surcharge, so // bypass wc_price there; keep it on the front end where the toggle can switch. $delta = (float) $opt['delta']; if ( 0.0 === $delta ) { $bump = ''; } elseif ( is_admin() ) { // esc_html, not a bare concatenation. addon_boss_plain_price() builds its // string from get_woocommerce_price_format() and // get_woocommerce_currency_symbol(), and both of those are filter values, // so anything hooking them lands here. Nothing between that helper and // this echo escaped it, and "escaped inside" is what the comment on the // two call sites claimed. It was not. $bump = ' ' . esc_html( ( $delta > 0 ? '+' : '-' ) . addon_boss_plain_price( abs( $delta ) ) ) . ''; } else { $bump = ' ' . ( $delta > 0 ? '+' : '' ) . wp_kses_post( wc_price( $opt['delta'] ) ) . ''; } $cls = 'bb-opt-chip' . ( $bump ? ' has-bump' : '' ) . ( '' !== $class ? ' ' . $class : '' ); return '' . $dot . esc_html( $opt['label'] ) . $bump . ''; } /** * A whole preview row: the first $cap chips visible, the rest behind a * clickable "+N" that expands the row in place (and collapses it again). * With $cap or fewer options there is no button and nothing to expand. */ function addon_boss_option_preview_row( array $options, int $cap ): string { $html = ''; foreach ( array_values( $options ) as $i => $opt ) { $html .= addon_boss_option_preview_chip( $opt, $i >= $cap ? 'bb-chip-overflow' : '' ); } $hidden = count( $options ) - $cap; if ( $hidden > 0 ) { $more = sprintf( '+%d', $hidden ); $html .= ''; } return $html; } /** The BundleBoss page header (brand square + crumb + action). */ function addon_boss_optsets_header( string $crumb, string $action_html = '' ): void { echo '
'; // The host's own mark, when it has one. An src only: the seam hands // over a URL rather than markup, so a host can never post HTML into this // header. Empty leaves the name on its own exactly as before, which is what // a host without a mark still gets. $bb_mark = esc_url_raw( (string) apply_filters( 'addon_boss_engine_brand_mark', '' ) ); echo ''; if ( '' !== $bb_mark ) { echo ''; } echo esc_html( (string) apply_filters( 'addon_boss_engine_brand_name', '' ) ) . ''; echo '/ ' . esc_html( $crumb ) . ''; // kses rather than a promise. Every caller builds this from esc_url and // esc_html already, but "the callers escape it" is an assertion nobody can // verify at this line. wp_kses_post allows the links and buttons the header // actually uses and drops anything else. echo '' . wp_kses_post( $action_html ) . '
'; } /** One set card, extracted so the grouped list (and any later view) share it. */ function addon_boss_render_option_set_card( string $id, array $set ): void { $edit = admin_url( 'admin.php?page=addon-boss-option-sets&set=' . rawurlencode( $id ) ); $delete = wp_nonce_url( admin_url( 'admin-post.php?action=addon_boss_delete_option_set_form&set=' . rawurlencode( $id ) ), 'addon_boss_delete_option_set' ); $dupe = wp_nonce_url( admin_url( 'admin-post.php?action=addon_boss_duplicate_option_set_form&set=' . rawurlencode( $id ) ), 'addon_boss_duplicate_option_set' ); $usage = addon_boss_option_set_usage( $id ); // A lowercase haystack for the client-side search: the set name + every label. $hay = strtolower( (string) ( $set['name'] ?? '' ) ); foreach ( (array) ( $set['options'] ?? array() ) as $hopt ) { $hay .= ' ' . strtolower( (string) ( $hopt['label'] ?? '' ) ); } echo '
'; echo '

' . esc_html( $set['name'] ?: $id ) . '

'; echo '
' . esc_html( $usage /* translators: %d: number of products */ ? sprintf( _n( 'Used on %d product', 'Used on %d products', $usage, 'addon-boss' ), $usage ) : __( 'Not attached to a product yet', 'addon-boss' ) ) . '
'; echo '
'; // Stored data is historical fact: an older build wrote table sets with no // options key at all, and one such set took this whole screen down with a // TypeError. Every set must render, malformed or not, or the merchant has // no way to open it and put it right. echo wp_kses( addon_boss_option_preview_row( (array) ( $set['options'] ?? array() ), 8 ), addon_boss_kses_allowed() ); echo '
'; echo '
'; echo ''; echo ''; echo '' . esc_html__( 'Edit', 'addon-boss' ) . ''; echo '' . esc_html__( 'Duplicate', 'addon-boss' ) . ''; echo '' . esc_html__( 'Delete', 'addon-boss' ) . ''; echo ''; echo '
'; echo ''; echo '
'; } /** One collapsible group section (a drop target) on the option-sets list. */ function addon_boss_render_option_set_group( string $slug, string $label, array $sets, bool $is_ungrouped ): void { echo '
'; echo '
'; echo ''; if ( $is_ungrouped ) { echo '' . esc_html( $label ) . ''; } else { // The house badge, never a native title: a title never shows on touch // and cannot be styled, so on a phone this hint did not exist at all. echo '' . esc_html( $label ) . '' . 'i'; } echo '' . (int) count( $sets ) . ''; if ( ! $is_ungrouped ) { echo ''; } echo '
'; echo '
'; foreach ( $sets as $id => $set ) { addon_boss_render_option_set_card( (string) $id, $set ); } echo '

' . esc_html__( 'Drag sets here', 'addon-boss' ) . '

'; echo '
'; echo '
'; } /** The list-page JS: search, drag sets between groups, make/rename/delete groups (auto-saved). */ function addon_boss_option_sets_organise_script(): void { $labels = array( 'newGroup' => __( 'New group', 'addon-boss' ), 'untitled' => __( 'Untitled group', 'addon-boss' ), 'rename' => esc_attr__( 'Double-click to rename', 'addon-boss' ), 'toggle' => esc_attr__( 'Show or hide this group', 'addon-boss' ), 'del' => esc_attr__( 'Delete group', 'addon-boss' ), 'name' => esc_attr__( 'Group name', 'addon-boss' ), 'delTitle' => __( 'Delete this group?', 'addon-boss' ), 'delBody' => __( 'Its sets move to Ungrouped and are kept, never deleted. To delete a set itself, use Delete on its own card.', 'addon-boss' ), 'delOk' => __( 'Delete the group', 'addon-boss' ), 'delCancel' => __( 'Cancel', 'addon-boss' ), /* translators: 1: group name, 2: how many products, 3: the set just added to the group */ 'offer' => __( '%1$s is on %2$s products that do not have %3$s.', 'addon-boss' ), /* translators: %s: how many products */ 'offerYes' => __( 'Add it to all %s', 'addon-boss' ), 'offerNo' => __( 'Not now', 'addon-boss' ), /* translators: %s: how many products it was added to */ 'offerDone' => __( 'Added to %s products.', 'addon-boss' ), ); ?> ( function () { var wrap = document.getElementById( 'bb-optgroups' ); if ( ! wrap ) { return; } var NONCE = ; var L = ; // Save the whole picture (debounced): assignments (set -> group) + the order of groups. var timer = null; function scheduleSave() { if ( timer ) { clearTimeout( timer ); } timer = setTimeout( save, 350 ); } function save() { var body = new URLSearchParams(); body.append( 'action', 'addon_boss_save_set_groups' ); body.append( '_wpnonce', NONCE ); wrap.querySelectorAll( '.bb-optgroup' ).forEach( function ( sec ) { var name = sec.classList.contains( 'is-ungrouped' ) ? '' : ( sec.getAttribute( 'data-group' ) || '' ); if ( name ) { body.append( 'order[]', name ); } sec.querySelectorAll( '.bb-setcard' ).forEach( function ( card ) { body.append( 'assignments[' + card.getAttribute( 'data-set-id' ) + ']', name ); } ); } ); fetch( ajaxurl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() } ) .then( function ( r ) { return r.json(); } ) .then( function ( j ) { // A set that just JOINED a group, on products that already // carry that group. Offered, never done: a group changing // what is on sale by itself is the thing we refused to // build, so somebody presses this or it does not happen. if ( ! j || ! j.success || ! j.data || ! j.data.joined ) { return; } j.data.joined.forEach( offerToApply ); } ) .catch( function () {} ); } function offerToApply( item ) { var bar = document.createElement( 'div' ); bar.className = 'bb-grp-offer'; var msg = document.createElement( 'span' ); msg.textContent = L.offer .replace( '%1$s', item.group ) .replace( '%2$s', String( item.count ) ) .replace( '%3$s', item.name ); var yes = document.createElement( 'button' ); yes.type = 'button'; yes.className = 'button button-primary'; yes.textContent = L.offerYes.replace( '%s', String( item.count ) ); var no = document.createElement( 'button' ); no.type = 'button'; no.className = 'button-link'; no.textContent = L.offerNo; no.addEventListener( 'click', function () { bar.remove(); } ); yes.addEventListener( 'click', function () { yes.disabled = true; var b = new URLSearchParams(); b.append( 'action', 'addon_boss_apply_set_to_group' ); b.append( '_wpnonce', NONCE ); b.append( 'set', item.set ); b.append( 'group', item.group ); fetch( ajaxurl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() } ) .then( function ( r ) { return r.json(); } ) .then( function ( j ) { bar.textContent = L.offerDone.replace( '%s', String( ( j && j.data && j.data.applied ) || 0 ) ); } ) .catch( function () { yes.disabled = false; } ); } ); bar.appendChild( msg ); bar.appendChild( yes ); bar.appendChild( no ); wrap.insertBefore( bar, wrap.firstChild ); } function refresh() { wrap.querySelectorAll( '.bb-optgroup' ).forEach( function ( sec ) { var n = sec.querySelectorAll( '.bb-setcard' ).length; var c = sec.querySelector( '.bb-grp-count' ); if ( c ) { c.textContent = n; } sec.classList.toggle( 'is-empty', n === 0 ); } ); } // Attach-to-products disclosure. wrap.addEventListener( 'click', function ( e ) { var t = e.target.closest ? e.target.closest( '.bb-bulk-toggle' ) : null; if ( ! t ) { return; } var form = t.closest( '.bb-setcard' ).querySelector( '.bb-bulkrow' ); if ( form ) { form.hidden = ! form.hidden; t.setAttribute( 'aria-expanded', form.hidden ? 'false' : 'true' ); } } ); // Collapse a group. wrap.addEventListener( 'click', function ( e ) { var caret = e.target.closest ? e.target.closest( '.bb-grp-caret' ) : null; if ( caret ) { caret.closest( '.bb-optgroup' ).classList.toggle( 'is-collapsed' ); syncFoldAll(); } } ); // Collapse or expand the whole library in one press. The button reads // the room: all folded says Expand all, anything open says Collapse // all, including after single-group carets or a search unfolds things. var foldAll = document.querySelector( '.bb-collapse-all' ); function syncFoldAll() { if ( ! foldAll ) { return; } var groups = wrap.querySelectorAll( '.bb-optgroup' ); var open = 0; groups.forEach( function ( g ) { if ( ! g.classList.contains( 'is-collapsed' ) ) { open++; } } ); foldAll.textContent = open ? foldAll.getAttribute( 'data-say-collapse' ) : foldAll.getAttribute( 'data-say-expand' ); } if ( foldAll ) { foldAll.addEventListener( 'click', function () { var groups = wrap.querySelectorAll( '.bb-optgroup' ); var anyOpen = false; groups.forEach( function ( g ) { if ( ! g.classList.contains( 'is-collapsed' ) ) { anyOpen = true; } } ); groups.forEach( function ( g ) { g.classList.toggle( 'is-collapsed', anyOpen ); } ); syncFoldAll(); } ); syncFoldAll(); } // Delete a group. Its sets fall back to Ungrouped and are kept; a group // with sets asks first so it is never a surprise. wrap.addEventListener( 'click', function ( e ) { var del = e.target.closest ? e.target.closest( '.bb-grp-del' ) : null; if ( ! del ) { return; } var sec = del.closest( '.bb-optgroup' ); function doDelete() { var ung = wrap.querySelector( '.bb-optgroup.is-ungrouped .bb-setgrid' ); if ( ung ) { sec.querySelectorAll( '.bb-setcard' ).forEach( function ( c ) { ung.appendChild( c ); } ); } sec.parentNode.removeChild( sec ); refresh(); scheduleSave(); } if ( ! sec.querySelector( '.bb-setcard' ) ) { doDelete(); return; } var nameEl = sec.querySelector( '.bb-grp-name' ) || sec.querySelector( '.bb-grp-nameedit' ); var name = nameEl ? ( ( nameEl.value || nameEl.textContent || '' ).trim() ) : ''; var ov = document.createElement( 'div' ); ov.className = 'bb-po-modal-ov'; ov.innerHTML = ''; ov.querySelector( '.bb-po-modal-msg' ).textContent = name ? ( '“' + name + '”' ) : L.delTitle; ov.querySelector( '.bb-po-modal-sub' ).textContent = L.delBody; ov.querySelector( '.bb-fd-ok' ).textContent = L.delOk; ov.querySelector( '.bb-fd-cancel' ).textContent = L.delCancel; function done() { if ( ov.parentNode ) { ov.parentNode.removeChild( ov ); } document.removeEventListener( 'keydown', esc ); } function esc( ev ) { if ( ev.key === 'Escape' ) { done(); } } ov.querySelector( '.bb-fd-ok' ).addEventListener( 'click', function () { doDelete(); done(); } ); ov.querySelector( '.bb-fd-cancel' ).addEventListener( 'click', done ); ov.addEventListener( 'click', function ( ev ) { if ( ev.target === ov ) { done(); } } ); document.addEventListener( 'keydown', esc ); document.body.appendChild( ov ); ov.querySelector( '.bb-fd-ok' ).focus(); } ); // Search by set name or an option label inside it. var search = document.getElementById( 'bb-set-search' ); var nomatch = document.getElementById( 'bb-set-nomatch' ); if ( search ) { search.addEventListener( 'input', function () { var q = search.value.trim().toLowerCase(), any = false; wrap.querySelectorAll( '.bb-optgroup' ).forEach( function ( sec ) { var shown = 0; sec.querySelectorAll( '.bb-setcard' ).forEach( function ( card ) { var hit = ! q || ( card.getAttribute( 'data-search' ) || '' ).indexOf( q ) > -1; card.style.display = hit ? '' : 'none'; if ( hit ) { shown++; } } ); sec.style.display = ( q && shown === 0 ) ? 'none' : ''; if ( shown > 0 ) { any = true; } if ( q ) { sec.classList.remove( 'is-collapsed' ); } } ); if ( nomatch ) { nomatch.hidden = any || ! q; } syncFoldAll(); } ); } // Rename in place (double-click the name), and commit on blur or Enter. function commit( inp, sec ) { var committed = false; function done() { if ( committed ) { return; } committed = true; var val = ( inp.value || '' ).trim() || L.untitled; sec.setAttribute( 'data-group', val ); var span = document.createElement( 'span' ); span.className = 'bb-grp-name'; span.title = L.rename; span.textContent = val; if ( inp.parentNode ) { inp.parentNode.replaceChild( span, inp ); } scheduleSave(); } inp.addEventListener( 'blur', done ); inp.addEventListener( 'keydown', function ( e ) { if ( e.key === 'Enter' ) { inp.blur(); } } ); } wrap.addEventListener( 'dblclick', function ( e ) { var name = e.target.closest ? e.target.closest( '.bb-grp-name' ) : null; if ( ! name || name.closest( '.is-ungrouped' ) ) { return; } var sec = name.closest( '.bb-optgroup' ); var inp = document.createElement( 'input' ); inp.type = 'text'; inp.className = 'bb-grp-nameedit'; inp.value = name.textContent; inp.setAttribute( 'aria-label', L.name ); name.parentNode.replaceChild( inp, name ); inp.focus(); inp.select(); commit( inp, sec ); } ); // Make a new group and drop into it. var newBtn = document.querySelector( '.bb-newgroup' ); if ( newBtn ) { newBtn.addEventListener( 'click', function () { var sec = document.createElement( 'section' ); sec.className = 'bb-optgroup is-empty'; sec.setAttribute( 'data-group', '' ); var h = document.createElement( 'div' ); h.className = 'bb-optgroup-h'; var caret = document.createElement( 'button' ); caret.type = 'button'; caret.className = 'bb-grp-caret'; caret.setAttribute( 'aria-label', L.toggle ); caret.innerHTML = '▾'; var inp = document.createElement( 'input' ); inp.type = 'text'; inp.className = 'bb-grp-nameedit'; inp.value = L.newGroup; inp.setAttribute( 'aria-label', L.name ); var count = document.createElement( 'span' ); count.className = 'bb-grp-count'; count.textContent = '0'; var del = document.createElement( 'button' ); del.type = 'button'; del.className = 'bb-grp-del'; del.setAttribute( 'aria-label', L.del ); del.innerHTML = '✕'; h.appendChild( caret ); h.appendChild( inp ); h.appendChild( count ); h.appendChild( del ); var grid = document.createElement( 'div' ); grid.className = 'bb-setgrid'; var ph = document.createElement( 'p' ); ph.className = 'bb-grp-empty'; ph.textContent = ; grid.appendChild( ph ); sec.appendChild( h ); sec.appendChild( grid ); var ung = wrap.querySelector( '.bb-optgroup.is-ungrouped' ); wrap.insertBefore( sec, ung || null ); inp.focus(); inp.select(); commit( inp, sec ); sec.scrollIntoView( { block: 'nearest' } ); } ); } // Drag sets between groups. var dragEl = null; wrap.addEventListener( 'dragstart', function ( e ) { var card = e.target.closest ? e.target.closest( '.bb-setcard' ) : null; if ( ! card ) { return; } dragEl = card; card.classList.add( 'is-dragging' ); e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData( 'text/plain', card.getAttribute( 'data-set-id' ) ); } catch ( _ ) {} } ); wrap.addEventListener( 'dragend', function () { if ( dragEl ) { dragEl.classList.remove( 'is-dragging' ); } dragEl = null; wrap.querySelectorAll( '.bb-drop-hi' ).forEach( function ( s ) { s.classList.remove( 'bb-drop-hi' ); } ); } ); wrap.addEventListener( 'dragover', function ( e ) { if ( ! dragEl ) { return; } var sec = e.target.closest ? e.target.closest( '.bb-optgroup' ) : null; if ( ! sec ) { return; } e.preventDefault(); e.dataTransfer.dropEffect = 'move'; wrap.querySelectorAll( '.bb-drop-hi' ).forEach( function ( s ) { if ( s !== sec ) { s.classList.remove( 'bb-drop-hi' ); } } ); sec.classList.add( 'bb-drop-hi' ); } ); wrap.addEventListener( 'drop', function ( e ) { if ( ! dragEl ) { return; } var sec = e.target.closest ? e.target.closest( '.bb-optgroup' ) : null; if ( ! sec ) { return; } e.preventDefault(); sec.classList.remove( 'bb-drop-hi', 'is-collapsed' ); var grid = sec.querySelector( '.bb-setgrid' ); var ph = grid.querySelector( '.bb-grp-empty' ); if ( ph ) { grid.insertBefore( dragEl, ph ); } else { grid.appendChild( dragEl ); } refresh(); scheduleSave(); } ); refresh(); } )(); '; // A real page heading for screen-reader and skip-link navigation; the // visible brand header renders just below. echo '

' . esc_html__( 'Option sets', 'addon-boss' ) . '

'; addon_boss_optsets_header( __( 'Option sets', 'addon-boss' ), // New set + New group sit together in the toolbar once sets exist; on // first run New set is the header's primary action beside the starter shelf. $sets ? '' : '' . esc_html__( 'New set', 'addon-boss' ) . '' ); echo '

' . esc_html__( 'Build a list of choices once and reuse it everywhere. Attach a set to your products and every bundle that includes them offers it automatically, so you never rebuild the same colours or sizes for each product.', 'addon-boss' ) . '

'; if ( $sets ) { echo '
'; echo ''; echo '' . esc_html__( '+ New set', 'addon-boss' ) . ''; echo ''; // One press folds the whole library to its group headers (David, 23 // Aug: at two hundred built products the list cannot be scanned open). // The label follows the state, and searching still unfolds whatever // matches, exactly as before. echo ''; echo '
'; echo '

' . esc_html__( 'Drag a set onto a group to file it. Make a new group and it is ready to drop into.', 'addon-boss' ) . '

'; $bb_by_group = array(); foreach ( $sets as $bb_id => $bb_set ) { $bb_by_group[ isset( $bb_set['group'] ) ? (string) $bb_set['group'] : '' ][ $bb_id ] = $bb_set; } $bb_names = array(); foreach ( addon_boss_option_set_groups() as $bb_gname ) { $bb_names[ $bb_gname ] = true; } foreach ( array_keys( $bb_by_group ) as $bb_gname ) { if ( '' !== $bb_gname ) { $bb_names[ $bb_gname ] = true; } } echo '
'; foreach ( array_keys( $bb_names ) as $bb_gname ) { addon_boss_render_option_set_group( (string) $bb_gname, (string) $bb_gname, $bb_by_group[ $bb_gname ] ?? array(), false ); } addon_boss_render_option_set_group( '', __( 'Ungrouped', 'addon-boss' ), $bb_by_group[''] ?? array(), true ); echo '
'; echo ''; addon_boss_option_sets_organise_script(); } else { // First run: a real empty state above the shelf, which becomes the hero. echo '
'; echo '

' . esc_html__( 'Add your first option set', 'addon-boss' ) . '

'; echo '

' . esc_html__( 'An option set is a reusable list of choices, like colours or sizes, that you attach to products. Start from a ready-made set below, or build your own.', 'addon-boss' ) . '

'; // The first-product checklist's compact echo (and any other host note) // renders inside the empty state, so the blank library says where this // first set is heading. do_action( 'addon_boss_option_sets_empty_state' ); echo '
'; } // The starter shelf. On first run it leads (open, primary Add buttons); once // the shop has its own sets it steps back into a collapsed disclosure. No // rival ships ready-made sets, so first-run leans into ours. $bb_names = array(); foreach ( $sets as $bb_set ) { if ( isset( $bb_set['name'] ) ) { $bb_names[ (string) $bb_set['name'] ] = true; } } $starters = addon_boss_option_starter_sets(); if ( $sets ) { echo '
'; echo '' . esc_html__( 'Add a ready-made set', 'addon-boss' ) . ''; echo '
'; addon_boss_render_starter_cards( $starters, $bb_names ); echo '
'; } else { echo '
'; addon_boss_render_starter_cards( $starters, $bb_names ); echo '

' . wp_kses_post( sprintf( /* translators: %s: a link labelled "Create a set from scratch" */ __( 'Prefer to start clean? %s.', 'addon-boss' ), // Straight to the blank editor: this line PROMISES scratch, so it // skips the starting-points on-ramp the plain New set button opens. '' . esc_html__( 'Create a set from scratch', 'addon-boss' ) . '' ) ) . '

'; echo '
'; } // Move the whole library between stores (concierge migration). Import is // always here, so a brand-new shop can bring a library in as its very // first action; export appears once there is a library worth sending. $export_url = wp_nonce_url( admin_url( 'admin-post.php?action=addon_boss_export_option_sets' ), 'addon_boss_export_option_sets' ); $io_lead = $sets ? __( 'Moving to another store?', 'addon-boss' ) : __( 'Already have sets from another store?', 'addon-boss' ); echo '
' . esc_html( $io_lead ) . ''; if ( $sets ) { echo '' . esc_html__( 'Export all sets', 'addon-boss' ) . ''; } echo '
'; echo ''; wp_nonce_field( 'addon_boss_import_option_sets' ); echo ' '; echo ''; echo '
'; echo ''; } /** * The starter cards: a wrapping grid of ready-made sets. A starter already * imported (matched by name in $existing_names) reads as "Added" with a link to * the copy the shop owns, so a second click can never make a silent duplicate. */ function addon_boss_render_starter_cards( array $starters, array $existing_names ): void { echo '
'; foreach ( $starters as $starter_id => $starter ) { $added = isset( $existing_names[ (string) $starter['name'] ] ); echo '
'; echo '

' . esc_html( $starter['name'] ) . '

'; if ( ! empty( $starter['purpose'] ) ) { echo '
' . esc_html( $starter['purpose'] ) . '
'; } echo '
'; foreach ( array_slice( $starter['options'], 0, 12 ) as $opt ) { echo ! empty( $opt['swatch'] ) ? '' : '' . esc_html( $opt['label'] ) . ''; } echo '
'; echo '
'; if ( $added ) { $edit_url = admin_url( 'admin.php?page=addon-boss-option-sets&set=' . rawurlencode( addon_boss_option_set_id_by_name( (string) $starter['name'] ) ) ); echo '' . esc_html__( 'Added', 'addon-boss' ) . ''; echo '' . esc_html__( 'Edit', 'addon-boss' ) . ''; } else { $url = wp_nonce_url( admin_url( 'admin-post.php?action=addon_boss_import_starter_set&starter=' . rawurlencode( $starter_id ) ), 'addon_boss_import_starter_set' ); echo '' . esc_html__( 'Add to my sets', 'addon-boss' ) . ''; } echo '
'; } echo '
'; } /** * The editor: name + option rows (drag to reorder; keys ride hidden inputs). * * A new set may arrive with a $seed: a recipe from the on-ramp, pre-filled but * unsaved, every row editable and nothing stored until Save. A stored set * ignores the seed entirely. */ function addon_boss_render_option_set_editor( string $id, ?array $seed = null ): void { $is_new = ( 'new' === $id ); $set = $is_new ? ( $seed ? $seed : array( 'name' => '', 'options' => array() ) ) : addon_boss_option_set( $id ); if ( null === $set ) { echo '

' . esc_html__( 'That set no longer exists.', 'addon-boss' ) . '

'; return; } wp_enqueue_script( 'jquery-ui-sortable' ); echo '
'; echo '

' . esc_html( $is_new ? __( 'New option set', 'addon-boss' ) : $set['name'] ) . '

'; addon_boss_optsets_header( $is_new ? __( 'New option set', 'addon-boss' ) : $set['name'], '' . esc_html__( 'Back to sets', 'addon-boss' ) . '' ); echo '
'; wp_nonce_field( 'addon_boss_save_option_set_form' ); echo ''; echo ''; echo '
'; echo ''; $usage = $is_new ? 0 : addon_boss_option_set_usage( $id ); if ( $usage ) { /* translators: %d: number of products */ echo '' . esc_html( sprintf( _n( 'Used on %d product', 'Used on %d products', $usage, 'addon-boss' ), $usage ) ) . ''; } echo '
'; // The set's kind. The engine's own is a list of choices; each registered // kind adds its own option to this menu and prints its own panel below. A // stored kind this build has no module for stays on the menu, named // plainly, so a save reposts it rather than quietly turning the set into a // plain list; its payload rides through that save untouched. $kind = (string) ( $set['kind'] ?? 'choices' ); $kinds = addon_boss_option_set_kinds(); echo '
'; echo ''; // Say what each kind is FOR, right at the fork, so a merchant who wants // flat per-item prices reaches for choices and one who sells by size // reaches for the kind that does. The first paint is the server's, so the // line is there before a script runs; the editor script keeps it in step // with the menu from the picked option's own words. echo '

' . esc_html( (string) ( $bb_menu[ $kind ]['desc'] ?? '' ) ) . '

'; // Each kind's own panel, printed by the module that owns it. A panel wears // data-bb-kind-panel="" so the picker shows exactly one at a time. foreach ( $kinds as $bb_spec ) { if ( is_callable( $bb_spec['editor'] ?? null ) ) { call_user_func( $bb_spec['editor'], $set, $kind ); } } echo '
'; // D1: the swatch shape, a display-only choice for a list of choices. The // seg shows the shape the set will ACTUALLY use (the resolved default // while the merchant has never chosen); only a real click writes the // hidden explicit field, so the photo-card auto preference survives // every unrelated save instead of dying to a phantom "square". $bb_shape_now = addon_boss_option_set_shape( $set ); $bb_shape_explicit = (string) ( $set['shape'] ?? '' ); echo '
'; echo '

' . esc_html__( 'Swatch shape', 'addon-boss' ) . '

'; echo '

' . esc_html__( 'How the choices draw on the product page and on the quantity grid. Square is the usual; Circle suits plain colours; Photo card shows each choice as a small card with its picture and price. A set whose options carry photos uses the photo card by itself until you choose here.', 'addon-boss' ) . '

'; echo '
'; $bb_shapes = array( 'square' => __( 'Square', 'addon-boss' ), 'circle' => __( 'Circle', 'addon-boss' ), 'card' => __( 'Photo card', 'addon-boss' ), ); foreach ( $bb_shapes as $bb_sv => $bb_sl ) { echo ''; } echo '
'; echo ''; /* * A picture that will never be drawn, said where the picture was added. * * The product panel has warned about this for as long as a photo could be * pinned per product. Pictures now live on the set itself, so the set * editor is where a merchant attaches them and where the silence bites: a * set someone once clicked Square or Circle on keeps every picture and * shows none of them, with nothing on this screen saying so. The same * sentence as the product panel, plus the one click that fixes it, on the * screen the merchant is already looking at. */ $bb_pin_photo = false; foreach ( (array) ( $set['options'] ?? array() ) as $bb_o ) { if ( ! empty( $bb_o['image_id'] ) ) { $bb_pin_photo = true; break; } } $bb_pin_flat = in_array( $bb_shape_explicit, array( 'square', 'circle' ), true ); echo '

'; echo esc_html__( 'These choices have pictures, but this set draws plain swatches, so shoppers never see them.', 'addon-boss' ) . ' '; echo ''; echo '

'; // Several-of: turn the set from pick-one into pick-many, with optional // bounds. The same seg language as the shape control above; the bounds // only appear once "several" is on, and the helper states the exact words // shoppers read so the merchant knows what they are switching on. $bb_is_multi = ! empty( $set['multi'] ); echo '

' . esc_html__( 'Customers choose', 'addon-boss' ) . '

'; echo '

' . esc_html__( 'One means the usual single pick. Several lets shoppers pick more than one choice from this set, like choosing up to three toppings; each pick adds its own charge.', 'addon-boss' ) . '

'; echo '
'; echo ''; echo ''; echo '
'; echo '
'; echo ''; echo ''; echo '' . esc_html__( 'Leave a box empty for no bound. Shoppers read the rule beside the choices in these words: "Choose up to 3." and a pick past the bound is refused with the same sentence, on the page and at the till.', 'addon-boss' ) . ''; echo '
'; // D6: long lists switch themselves to a searchable dropdown on the // product page; this pin keeps the buttons for this set regardless. echo '
'; echo '' . esc_html__( 'Keep the buttons for long lists', 'addon-boss' ) . ''; echo '' . esc_html( sprintf( /* translators: %s: how many choices fit before the switch, e.g. 12 */ __( 'Past %s choices this set becomes a searchable dropdown on the product page, which is easier to scan and much shorter on a phone. Turn this on to keep every choice as a button whatever the length.', 'addon-boss' ), number_format_i18n( ADDON_BOSS_CHIP_LIST_THRESHOLD ) ) ) . ''; echo ''; echo '
'; // The storefront stock display toggle: on shows Sold out / Only N left; off // hides the numbers and simply drops a sold-out choice. The oversell guard is // independent of this and always on. // A build without the plan prints nothing here at all: it cannot offer the // feature, so it does not advertise it either. if ( addon_boss_option_stock_enabled() ) { echo '
'; echo '' . esc_html__( 'Show stock to shoppers', 'addon-boss' ) . ''; echo '' . esc_html__( 'Show "Sold out" and "Only 2 left" on this set\'s choices. Turn it off to hide the numbers, and a sold-out choice is simply not offered. Overselling is prevented either way.', 'addon-boss' ) . ''; echo ''; echo '
'; } // end: per-option stock offered by this build echo '
'; echo '
'; // Measured mode shows the presets as a headed table: the columns echo the // dimensions (Name / Width (m) / Height (m) / Price), so a bare preset number // reads as its dimension, not a mystery. Hidden for the colour list. The // trailing cells mirror the row's controls (kept invisible) purely to hold // the auto columns in step; the measured module's script fills the labels. echo ''; foreach ( $set['options'] as $opt ) { addon_boss_option_editor_row( $opt ); } echo '
'; echo '
'; echo '' . esc_html__( 'Type a colour name and the swatch fills itself on save; click the bubble to fine-tune. Drag a row, or use its up and down arrows, to set the order shoppers see.', 'addon-boss' ) . '
'; echo '
'; echo ''; echo '' . esc_html__( 'The extra charge is a default; each product can set its own. Carts and orders keep their snapshots whatever you edit.', 'addon-boss' ) . ''; echo '
'; // Row template + sortable + a fresh blank row for new sets. ?> , like every other row template on these screens, rather than a script tag holding markup: there is no JavaScript in it. ?> jQuery( function ( $ ) { $( '#bb-option-rows' ).sortable( { handle: '.bb-opt-grip', axis: 'y', items: '> :not(.bb-pshead)' } ); // Keyboard reorder: the grip is mouse-only, so each row also has Move up // and Move down buttons. Delegated, so it works for rows added later. $( '#bb-option-rows' ).on( 'click', '.bb-edmove-up', function () { var row = $( this ).closest( '.bb-edrow' ); var prev = row.prev( '.bb-edrow' ); if ( prev.length ) { row.insertBefore( prev ); this.focus(); } } ); $( '#bb-option-rows' ).on( 'click', '.bb-edmove-down', function () { var row = $( this ).closest( '.bb-edrow' ); var next = row.next( '.bb-edrow' ); if ( next.length ) { row.insertAfter( next ); this.focus(); } } ); // The kind switcher, generic on purpose: each option on the menu carries // its own description and whether the option rows apply, and each kind's // panel wears data-bb-kind-panel, so a kind registered tomorrow behaves // without this script learning its name. The form wears bb-kind- // for the panel's own styles. function bbKindSync() { var sel = document.getElementById( 'bb-set-kind' ); if ( ! sel ) { return; } var kind = sel.value; var opt = sel.options[ sel.selectedIndex ]; var form = $( '#bb-option-set-form' ); ( form.attr( 'class' ) || '' ).split( /\s+/ ).forEach( function ( c ) { if ( 0 === c.indexOf( 'bb-kind-' ) ) { form.removeClass( c ); } } ); if ( 'choices' !== kind ) { form.addClass( 'bb-kind-' + kind ); } $( '[data-bb-kind-panel]' ).each( function () { this.hidden = this.getAttribute( 'data-bb-kind-panel' ) !== kind; } ); $( '#bb-kind-desc' ).text( opt ? ( opt.getAttribute( 'data-desc' ) || '' ) : '' ); // A kind priced off its own panel has no use for the option rows. // .toggle() sets inline display, which beats the list's own display // rule (prop hidden alone loses to it). var uses = ! opt || '0' !== opt.getAttribute( 'data-uses-options' ); $( '#bb-option-rows, .bb-edadd' ).toggle( uses ); } $( '#bb-set-kind' ).on( 'change', bbKindSync ); bbKindSync(); // The swatch shape seg: a click is what makes the choice explicit // (the hidden field is the one the save reads), and the highlight // follows the checked radio exactly as the other segs do. function bbShapeShow( value ) { $( '#bb-shape-seg input[type=radio]' ).each( function () { var on = ( this.value === value ); this.checked = on; $( this ).closest( '.bb-seg' ).toggleClass( 'is-on', on ); } ); } $( '#bb-shape-seg' ).on( 'change', 'input[type=radio]', function () { $( '#bb-set-shape' ).val( this.value ); bbShapeShow( this.value ); bbShapeSync(); } ); // The seg shows the shape the set will ACTUALLY use, so attaching the // first picture has to move it: the resolved shape just became the // photo card, and a seg still reading "Square" is the screen telling // the merchant their pictures will not be drawn when they will. The // hidden pin is never written here, only the highlight, so the auto // preference survives. Where the pin IS square or circle the pictures // really are dead, and the warning says so with the one click that // fixes it. Photos are a Pro feature; with no picker on the page // nothing ever changes and this simply agrees with the server. function bbShapeSync() { var photos = 0; $( '#bb-option-rows .bb-ovr-imgid' ).each( function () { if ( this.value && '0' !== this.value ) { photos++; } } ); var pin = $( '#bb-set-shape' ).val(); if ( ! pin ) { bbShapeShow( photos ? 'card' : 'square' ); } document.getElementById( 'bb-shape-warn' ).hidden = ! ( photos && ( 'square' === pin || 'circle' === pin ) ); } $( '#bb-option-rows' ).on( 'bb:photos', bbShapeSync ); $( '#bb-shape-warn-fix' ).on( 'click', function () { $( '#bb-set-shape' ).val( 'card' ); bbShapeShow( 'card' ); bbShapeSync(); } ); // The several-of seg: the same highlight discipline, and the bounds // row only exists while "several" is the answer. $( '#bb-multi-seg' ).on( 'change', 'input[type=radio]', function () { $( '#bb-multi-seg .bb-seg' ).each( function () { $( this ).toggleClass( 'is-on', $( this ).find( 'input' ).prop( 'checked' ) ); } ); document.getElementById( 'bb-multi-bounds' ).hidden = ( '1' !== this.value ); } ); $( '#bb-add-option' ).on( 'click', function () { $( '#bb-option-rows' ).append( $( '#bb-option-row-template' ).html() ); } ); $( document ).on( 'click', '.bb-opt-remove', function () { $( this ).closest( '.bb-edrow' ).remove(); } ); // The colour bubble: the native picker writes through to the hidden // swatch field the form actually submits. $( document ).on( 'input change', '.bb-swpick', function () { var bub = $( this ).closest( '.bb-swbub' ); bub.removeClass( 'is-none' ).css( 'background', this.value ); bub.find( 'input[name="opt_swatch[]"]' ).val( this.value ); } ); // The optional second colour (two-tone): its own picker writes through, // and the small clear control drops back to a single-colour swatch. $( document ).on( 'input change', '.bb-swpick2', function () { var bub = $( this ).closest( '.bb-swbub2' ); bub.removeClass( 'is-none' ).css( 'background', this.value ); bub.find( 'input[name="opt_swatch2[]"]' ).val( this.value ); } ); $( document ).on( 'click', '.bb-sw2-clear', function ( e ) { e.preventDefault(); var bub = $( this ).closest( '.bb-swbub2' ); bub.addClass( 'is-none' ).css( 'background', '' ); bub.find( 'input[name="opt_swatch2[]"]' ).val( '' ); } ); // A fresh option list gets one blank row; a kind priced off its own // panel never needs one. var bbKindOpt = $( '#bb-set-kind option:selected' ); if ( ( ! bbKindOpt.length || '0' !== bbKindOpt.attr( 'data-uses-options' ) ) && ! $( '#bb-option-rows .bb-edrow' ).length ) { $( '#bb-add-option' ).trigger( 'click' ); } } ); ' . $photo . ''; $dcells = ''; for ( $d = 0; $d < 3; $d++ ) { $dval = isset( $dims[ $d ] ) && 0.0 !== (float) $dims[ $d ] ? (string) (float) $dims[ $d ] : ''; /* translators: %d: dimension number, 1 to 3 */ $dlabel = sprintf( __( 'Preset size, dimension %d', 'addon-boss' ), $d + 1 ); $dcells .= ''; } // Every field carries an aria-label (the placeholder alone vanishes on type // and is not reliably announced). The grip is decorative — keyboard users // reorder with the Move up/down buttons. Numbered args: the option-name label // is reused for aria-label and placeholder. printf( '
' . '' . '' . '%28$s' . '' . '' . '%9$s' . '' . // The caption is part of the control, not a header row above it: the // choices rows are flex, so a separate header cannot be trusted to // stay over this column, and a placeholder alone vanishes the moment // a number is typed. An unlabelled box here is how a merchant once // typed a number without knowing what it did. ( addon_boss_option_stock_enabled() ? '' : '' ) . '' . '' . '' . '
', $swatch ? '' : ' is-none', $swatch ? 'background:' . esc_attr( $swatch ) : '', esc_attr__( 'Swatch colour', 'addon-boss' ), esc_attr( $swatch ? $swatch : '#dddddd' ), esc_attr( $swatch ), esc_attr( $opt['key'] ?? '' ), esc_attr__( 'Option name', 'addon-boss' ), esc_attr( $opt['label'] ?? '' ), wp_kses( $dcells, addon_boss_kses_allowed() ), esc_attr__( 'Extra charge', 'addon-boss' ), esc_attr( 0.0 === (float) ( $opt['delta'] ?? 0 ) ? '' : (string) $opt['delta'] ), esc_attr__( 'No extra charge', 'addon-boss' ), esc_attr__( 'Move up', 'addon-boss' ), esc_attr__( 'Move down', 'addon-boss' ), esc_html__( 'Remove', 'addon-boss' ), $swatch2 ? '' : ' is-none', $swatch2 ? 'background:' . esc_attr( $swatch2 ) : '', esc_attr__( 'Second colour (two-tone)', 'addon-boss' ), esc_attr( $swatch2 ? $swatch2 : '#dddddd' ), esc_attr( $swatch2 ), esc_attr__( 'Clear second colour', 'addon-boss' ), esc_attr__( 'Note shown when this option is picked (optional)', 'addon-boss' ), esc_attr( $desc ), esc_attr__( 'Stock (leave blank for unlimited)', 'addon-boss' ), esc_attr( isset( $opt['stock'] ) && is_numeric( $opt['stock'] ) ? (string) (int) $opt['stock'] : '' ), esc_attr__( 'No limit', 'addon-boss' ), esc_html__( 'Stock', 'addon-boss' ), // Built above from the photo module's own controls, already escaped by // the module that prints them, plus the engine's hidden id. wp_kses( $photo, addon_boss_kses_allowed() ) ); } // ---- admin-post handlers ---------------------------------------- add_action( 'admin_post_addon_boss_save_option_set_form', 'addon_boss_handle_save_option_set_form' ); add_action( 'admin_post_addon_boss_delete_option_set_form', 'addon_boss_handle_delete_option_set_form' ); add_action( 'admin_post_addon_boss_import_starter_set', 'addon_boss_handle_import_starter_set' ); add_action( 'admin_post_addon_boss_duplicate_option_set_form', 'addon_boss_handle_duplicate_option_set_form' ); add_action( 'admin_post_addon_boss_bulk_attach_set', 'addon_boss_handle_bulk_attach_set' ); /** * Attach one set to every product in a category. Products already offering * it are left EXACTLY as they are, overrides, photos and rules included; * the attachment stays removable per product. A starting point, never a * lock: 303 optioned products cannot be configured one at a time. * * One run is bounded. Loading and re-saving every product happens inside a * single request, and past a point that request times out halfway with the * category half-attached, which is worse than not starting. The ids query is * cheap, so the size is counted first; a category past the cap is refused * whole, with both numbers named, and nothing is touched. The refusal rides * the result as 'refused' so the handler can show it as it stands. */ function addon_boss_bulk_attach_set( string $set_id, int $term_id ): array { $attached = 0; $skipped = 0; if ( ! addon_boss_option_set( $set_id ) || $term_id <= 0 ) { return array( 'attached' => 0, 'skipped' => 0 ); } $ids = get_posts( array( 'post_type' => 'product', 'post_status' => array( 'publish', 'draft', 'private' ), 'posts_per_page' => -1, 'fields' => 'ids', 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- an explicit admin action, not a storefront query. array( 'taxonomy' => 'product_cat', 'terms' => $term_id, ), ), ) ); /** * The most products one bulk-attach run will take on. * * 200 finishes comfortably inside an ordinary request. A host with more * headroom can raise it, and zero or less removes the bound entirely. * * @param int $cap The bound. Default 200. */ $cap = (int) apply_filters( 'addon_boss_bulk_attach_cap', 200 ); if ( $cap > 0 && count( $ids ) > $cap ) { return array( 'attached' => 0, 'skipped' => 0, 'refused' => sprintf( /* translators: 1: how many products the category holds, 2: the most one run will attach to */ __( 'That category holds %1$s products. To finish reliably in one go, this tool attaches to at most %2$s at a time, so nothing was attached. Please try a smaller category or a subcategory.', 'addon-boss' ), number_format_i18n( count( $ids ) ), number_format_i18n( $cap ) ), ); } $first_id = 0; foreach ( $ids as $pid ) { $product = wc_get_product( $pid ); if ( ! $product || ! ( $product->is_type( 'simple' ) || $product->is_type( 'variable' ) ) ) { continue; } $config = addon_boss_product_option_config( $product ); if ( in_array( $set_id, array_column( $config, 'set' ), true ) ) { ++$skipped; if ( 0 === $first_id ) { $first_id = (int) $pid; } continue; } $config[] = array( 'set' => $set_id, 'required' => false ); addon_boss_save_product_option_config( $product, $config ); ++$attached; if ( 0 === $first_id ) { $first_id = (int) $pid; } } return array( 'attached' => $attached, 'skipped' => $skipped, 'first_id' => $first_id ); } function addon_boss_handle_bulk_attach_set(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'addon-boss' ) ); } check_admin_referer( 'addon_boss_bulk_attach_set' ); $result = addon_boss_bulk_attach_set( sanitize_text_field( wp_unslash( $_POST['set'] ?? '' ) ), absint( $_POST['bulk_category'] ?? 0 ) ); if ( isset( $result['refused'] ) ) { // The category is past the bound: the worker refused it whole and its // message names the count and the cap. A warning, not an error, because // nothing broke and nothing was half-done. addon_boss_option_sets_redirect( (string) $result['refused'], 'warning' ); } if ( 0 === (int) $result['attached'] && 0 === (int) $result['skipped'] ) { // Nothing happened: no category chosen, or the category held no eligible // products. A green "Attached to 0 products" read as a silent success. addon_boss_option_sets_redirect( __( 'Pick a category first, or that one has no eligible products. Nothing was attached.', 'addon-boss' ), 'warning' ); } addon_boss_option_sets_redirect( $result['skipped'] ? sprintf( /* translators: 1: products attached, 2: products that already offered the set */ __( 'Attached to %1$d products. %2$d already offered it and were left untouched.', 'addon-boss' ), $result['attached'], $result['skipped'] ) /* translators: %d: products attached */ : sprintf( __( 'Attached to %d products.', 'addon-boss' ), $result['attached'] ), 'success', // Until a product page has been seen, the first attach offers the view. function_exists( 'addon_boss_onboarding_next_action' ) ? addon_boss_onboarding_next_action( 'attached', (int) ( $result['first_id'] ?? 0 ) ) : array() ); } function addon_boss_handle_duplicate_option_set_form(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'addon-boss' ) ); } check_admin_referer( 'addon_boss_duplicate_option_set' ); $src = addon_boss_option_set( sanitize_text_field( wp_unslash( $_GET['set'] ?? '' ) ) ); if ( $src ) { /* translators: %s: the copied set's name */ $src['name'] = sprintf( __( '%s copy', 'addon-boss' ), $src['name'] ); addon_boss_save_option_set( $src ); } addon_boss_option_sets_redirect( $src ? __( 'Set duplicated.', 'addon-boss' ) : __( 'That set no longer exists.', 'addon-boss' ), $src ? 'success' : 'error' ); } function addon_boss_option_sets_redirect( string $notice, string $type = 'success', array $action = array() ): void { // The message carries its own type so a failure never wears the green // success tick. "That set no longer exists" and a bad file import used to // render exactly like "Option set saved". An optional action ({l,u}) rides // along so a notice can carry its one next step; the printer escapes it. $payload = array( 'm' => $notice, 't' => in_array( $type, array( 'success', 'error', 'warning' ), true ) ? $type : 'success', ); if ( '' !== (string) ( $action['l'] ?? '' ) && '' !== (string) ( $action['u'] ?? '' ) ) { $payload['a'] = array( 'l' => (string) $action['l'], 'u' => (string) $action['u'], ); } set_transient( 'bundle_boss_option_sets_notice_' . get_current_user_id(), $payload, MINUTE_IN_SECONDS ); wp_safe_redirect( admin_url( 'admin.php?page=addon-boss-option-sets' ) ); exit; } function addon_boss_handle_save_option_set_form(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'addon-boss' ) ); } check_admin_referer( 'addon_boss_save_option_set_form' ); // The whole form-to-set assembly lives in addon_boss_option_set_from_form() // (set-preview.php), shared with the preview endpoint so a preview can // never drift from what saving stores. $bb_raw = addon_boss_option_set_from_form(); $id = addon_boss_save_option_set( $bb_raw, sanitize_text_field( wp_unslash( $_POST['set_id'] ?? '' ) ) ); // A kind may have something worth saying about the set just saved (a price // table warns about overlapping bands). Never blocks the save. $saved_set = addon_boss_option_set( $id ); if ( $saved_set ) { $bb_spec = addon_boss_option_set_kinds()[ (string) ( $saved_set['kind'] ?? 'choices' ) ] ?? null; if ( $bb_spec && is_callable( $bb_spec['save_notice'] ?? null ) ) { $bb_notice = call_user_func( $bb_spec['save_notice'], $saved_set ); if ( is_array( $bb_notice ) && '' !== (string) ( $bb_notice['message'] ?? '' ) ) { addon_boss_option_sets_redirect( (string) $bb_notice['message'], (string) ( $bb_notice['type'] ?? 'warning' ) ); } } } // While nothing is attached yet, the saved-set notice carries the next // step, so the first success points straight at the second. addon_boss_option_sets_redirect( __( 'Option set saved.', 'addon-boss' ), 'success', function_exists( 'addon_boss_onboarding_next_action' ) ? addon_boss_onboarding_next_action( 'set_saved' ) : array() ); } function addon_boss_handle_delete_option_set_form(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'addon-boss' ) ); } check_admin_referer( 'addon_boss_delete_option_set' ); addon_boss_delete_option_set( sanitize_text_field( wp_unslash( $_GET['set'] ?? '' ) ) ); addon_boss_option_sets_redirect( __( 'Option set deleted.', 'addon-boss' ) ); } function addon_boss_handle_import_starter_set(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'addon-boss' ) ); } check_admin_referer( 'addon_boss_import_starter_set' ); $starter_id = sanitize_text_field( wp_unslash( $_GET['starter'] ?? '' ) ); $starters = addon_boss_option_starter_sets(); if ( ! isset( $starters[ $starter_id ] ) ) { addon_boss_option_sets_redirect( __( 'Unknown ready-made set.', 'addon-boss' ), 'error' ); } // Already imported: don't make a silent duplicate. Say so plainly and leave // the copy the shop already owns untouched. if ( '' !== addon_boss_option_set_id_by_name( $starters[ $starter_id ]['name'] ) ) { addon_boss_option_sets_redirect( __( 'You already have that set, so it was not added again.', 'addon-boss' ), 'warning' ); } addon_boss_import_starter_set( $starter_id ); // An import is a saved set by another route, so it earns the same next step. addon_boss_option_sets_redirect( __( 'Ready-made set imported. It is your own copy now, edit it however you like.', 'addon-boss' ), 'success', function_exists( 'addon_boss_onboarding_next_action' ) ? addon_boss_onboarding_next_action( 'set_saved' ) : array() ); } // ----------------------------------------------------------------- // Export / import the whole option-sets library as one JSON file, so a // store's colours and sizes move to another store (the concierge // migration story). Sets keep their stable ids, so a product's saved // attachment resolves on the other side without any re-keying. // ----------------------------------------------------------------- const ADDON_BOSS_OPTION_SETS_EXPORT_FORMAT = 'bundleboss-option-sets'; add_action( 'admin_post_addon_boss_export_option_sets', 'addon_boss_handle_export_option_sets' ); add_action( 'admin_post_addon_boss_import_option_sets', 'addon_boss_handle_import_option_sets' ); function addon_boss_handle_export_option_sets(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'addon-boss' ) ); } check_admin_referer( 'addon_boss_export_option_sets' ); $doc = array( 'format' => ADDON_BOSS_OPTION_SETS_EXPORT_FORMAT, 'version' => 1, 'site' => home_url(), 'sets' => addon_boss_option_sets(), // id => { name, options[] } ); nocache_headers(); header( 'Content-Type: application/json; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="bundleboss-option-sets.json"' ); echo wp_json_encode( $doc, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); exit; } function addon_boss_handle_import_option_sets(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'addon-boss' ) ); } check_admin_referer( 'addon_boss_import_option_sets' ); if ( empty( $_FILES['bb_sets_file']['tmp_name'] ) || ! is_uploaded_file( $_FILES['bb_sets_file']['tmp_name'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- path checked by is_uploaded_file; contents validated below. addon_boss_option_sets_redirect( __( 'No file was uploaded. Choose a JSON file and try again.', 'addon-boss' ), 'error' ); } $raw = (string) file_get_contents( $_FILES['bb_sets_file']['tmp_name'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.Security -- just-uploaded temp file; path validated by is_uploaded_file above. $doc = json_decode( $raw, true ); if ( ! is_array( $doc ) || ( $doc['format'] ?? '' ) !== ADDON_BOSS_OPTION_SETS_EXPORT_FORMAT || empty( $doc['sets'] ) || ! is_array( $doc['sets'] ) ) { addon_boss_option_sets_redirect( __( 'That is not an option-sets file.', 'addon-boss' ), 'error' ); } $imported = 0; foreach ( $doc['sets'] as $id => $set ) { if ( ! is_array( $set ) ) { continue; } // Keep the file's id so product attachments resolve; the sanitiser // regenerates any missing option keys and drops junk. An existing set // with the same id is overwritten (a re-import updates in place). addon_boss_save_option_set( $set, is_string( $id ) ? $id : '' ); ++$imported; } addon_boss_option_sets_redirect( $imported /* translators: %d: number of option sets imported */ ? sprintf( _n( '%d option set imported.', '%d option sets imported.', $imported, 'addon-boss' ), $imported ) : __( 'No option sets were found in that file.', 'addon-boss' ), $imported ? 'success' : 'warning' ); } // ================================================================= // Admin: the product panel ("Options this product comes in") // ================================================================= add_filter( 'woocommerce_product_data_tabs', 'addon_boss_product_options_tab' ); add_action( 'woocommerce_product_data_panels', 'addon_boss_product_options_panel' ); add_action( 'woocommerce_admin_process_product_object', 'addon_boss_product_options_save' ); function addon_boss_product_options_tab( array $tabs ): array { // No tab, no panel, no save handler reached. The stored config is left // exactly as it is, so switching the feature back on restores every product // untouched rather than to an empty one. if ( ! addon_boss_options_available() ) { return $tabs; } $tabs['bundle_boss_options'] = array( // "Product options": the feature's public name (docs, website), and // unambiguous next to WooCommerce's own Attributes tab. His call. 'label' => __( 'Product options', 'addon-boss' ), 'target' => 'bundle_boss_options_panel', 'class' => array( 'show_if_simple', 'show_if_variable' ), // sets ride on variables as extras; variations keep stock and photos. 'priority' => 65, ); return $tabs; } // ----------------------------------------------------------------- // Suggest photos: deterministic option-to-image matching. The engine // proposes, a review sheet shows every proposal WITH its reason, and // nothing persists until the merchant applies and saves. Trust order: // the product's own photos first, then the media library by file name // and title. Options that already carry a photo are never touched. // ----------------------------------------------------------------- /** * Hidden inputs reposting a stored value tree exactly as it stands, so a save * from a build that cannot edit the thing carries it whole rather than * quietly dropping it. Data only: names are escaped path segments, leaves are * scalars; nothing here reads or interprets what it carries. * * @param string $name The input name prefix, e.g. bb_pf[3]. * @param mixed $value The stored subtree. */ function addon_boss_hidden_carriers( string $name, $value ): void { if ( is_array( $value ) ) { foreach ( $value as $k => $v ) { addon_boss_hidden_carriers( $name . '[' . $k . ']', $v ); } return; } if ( is_bool( $value ) ) { $value = $value ? '1' : ''; } echo ''; } /** * Hidden inputs reposting a field row's stored reveal rule in the exact shape * the rule editor posts, so a build with no editor for it still carries the * rule through a save. Both stored shapes serialise: the legacy single rule * and the canonical multi-condition one. Data traversal only; nothing here * evaluates anything. * * @param string $ix The row index. * @param array $when The stored rule. */ function addon_boss_field_when_carriers( string $ix, array $when ): void { if ( ! $when ) { return; } $conds = array(); $match = 'all'; if ( isset( $when['conditions'] ) && is_array( $when['conditions'] ) ) { $match = ( 'any' === ( $when['match'] ?? 'all' ) ) ? 'any' : 'all'; foreach ( $when['conditions'] as $c ) { if ( is_array( $c ) && '' !== (string) ( $c['set'] ?? '' ) ) { $conds[] = array( 'set' => (string) $c['set'], 'op' => (string) ( $c['op'] ?? 'is' ), 'value' => (string) ( $c['value'] ?? '' ), ); } } } elseif ( '' !== (string) ( $when['set'] ?? '' ) ) { $conds[] = array( 'set' => (string) $when['set'], 'op' => 'is', 'value' => (string) ( $when['is'] ?? '' ), ); } if ( ! $conds ) { return; } $first = array_shift( $conds ); echo ''; echo ''; echo ''; echo ''; foreach ( $conds as $j => $c ) { foreach ( array( 'set', 'op', 'value' ) as $bb_ck ) { echo ''; } } } /** * A field whose TYPE this build has no module for: a plain card naming it, a * remove control, and hidden carriers reposting the stored def and rule * whole, so pressing Update rewrites nothing and the field returns intact the * moment its module is installed. Nothing here is editable, because the build * has no controls that could honestly edit it. * * @param mixed $i The row index. * @param array $field The stored def. * @param array $when The stored reveal rule. */ function addon_boss_render_inert_field_row( $i, array $field, array $when = array() ): void { $ix = esc_attr( (string) $i ); $type = (string) ( $field['type'] ?? '' ); $label = (string) ( $field['label'] ?? '' ); ?>
, and a cast turns it // into 0, so every row the button adds posts under bb_pf[0] and the save // clones one row's data over the others. Proven as real admin data loss // (ProductFieldRowTemplateTest pins the placeholder's survival). $ix = esc_attr( (string) $i ); $mode_lbls = addon_boss_fee_modes(); $bits = array( $heading ? __( 'Heading', 'addon-boss' ) : ( 'always' === $reqmode ? __( 'Required', 'addon-boss' ) : __( 'Optional', 'addon-boss' ) ) ); // A block whose label is only for this list says so on its closed line, or a // merchant hunting a heading that will not appear has nothing to go on. if ( 'html' === $type && ! empty( $field['hide_title'] ) ) { $bits[] = __( 'Label hidden', 'addon-boss' ); } if ( $fee > 0 && ! $heading ) { $num = rtrim( rtrim( number_format( $fee, 2 ), '0' ), '.' ); // A type or mode whose charge reads in its own words (per square metre, // a share of the price) says so through the module that owns it; the // engine's own modes read from the menu wording, and a mode this build // has no module for is named plainly. $bb_bit = (string) apply_filters( 'addon_boss_field_fee_bit', '', $field, $num, $sym ); $bits[] = '' !== $bb_bit ? $bb_bit : '+' . $sym . $num . ' ' . ( $mode_lbls[ $mode ] ?? $mode ); } // A module with something to say about this row on its closed line (a // placement announcing itself) says it through this seam. $bits = (array) apply_filters( 'addon_boss_field_row_bits', $bits, $field, $type ); ?>
>
>
>
1, 'repeat_max' => 1, 'repeat_label' => 1 ) ) ); } ?>
ID ); if ( $bb_vprod instanceof WC_Product && $bb_vprod->is_type( 'variable' ) ) { foreach ( $bb_vprod->get_children() as $bb_vid ) { $bb_v = wc_get_product( $bb_vid ); if ( $bb_v ) { $bb_vlab = wc_get_formatted_variation( $bb_v, true, false ); $bb_vars[ (int) $bb_vid ] = '' !== $bb_vlab ? $bb_vlab : ( '#' . (int) $bb_vid ); } } } } $bb_only = array_map( 'absint', (array) ( $field['only_variations'] ?? array() ) ); ?>
> $bb_vlabel ) : ?>
>
) so the save stores it in the * arranged sequence. */ function addon_boss_render_product_group_head( $i, array $group ): void { // $i may be the '__PGIDX__' template placeholder; see the field row's note. $ix = esc_attr( (string) $i ); $label = (string) ( $group['label'] ?? '' ); ?>
'; if ( ! empty( $group['repeat_max'] ) ) { echo ''; } if ( '' !== (string) ( $group['repeat_label'] ?? '' ) ) { echo ''; } } } /** * One SET row in the admin product-options list. Extracted so a group can * render it as a child exactly like a top-level one. $attached is the * [id => name] map of sets on this product, for the reveal-rule siblings. */ function addon_boss_render_product_set_row( $i, array $row, array $sets, array $attached ): void { $set = $sets[ $row['set'] ?? '' ] ?? null; if ( ! $set ) { return; // deleted set: degrade silently. } $ix = (int) $i; ?>
[label, type]; gains this row's field. */ function addon_boss_note_earlier_field( $row, array &$earlier ): void { $row = is_array( $row ) ? $row : array(); $key = (string) ( $row['field']['key'] ?? '' ); if ( '' === $key ) { return; } $label = (string) ( $row['field']['label'] ?? '' ); $type = (string) ( $row['field']['type'] ?? '' ); // A display-only row (a section label, an info block) has no answer to // test, so it is never offered as a trigger. $types = addon_boss_personalisation_field_types(); if ( ! empty( $types[ $type ]['display_only'] ) ) { return; } $earlier[ $key ] = array( 'label' => '' !== $label ? $label : $key, 'type' => $type, ); } /** * One sibling-field entry read whichever way it was handed over: the map is * [label, type] now, and a bare string is still read as a name. * * @param mixed $entry The map entry. * @param string $key Its key, as the last-resort name. * @return array{label:string,type:string} */ function addon_boss_sibling_field( $entry, string $key ): array { if ( is_array( $entry ) ) { return array( 'label' => (string) ( $entry['label'] ?? $key ), 'type' => (string) ( $entry['type'] ?? '' ), ); } return array( 'label' => '' !== (string) $entry ? (string) $entry : $key, 'type' => '' ); } /** A set-or-field CHILD row (used both top-level and inside a group). */ function addon_boss_render_product_child_row( $i, array $row, array $sets, array $attached, array $sibling_fields = array() ): void { if ( ( $row['row_type'] ?? '' ) === 'field' ) { addon_boss_render_product_field_row( $i, (array) ( $row['field'] ?? array() ), (array) ( $row['when'] ?? array() ), $attached, $sibling_fields ); return; } addon_boss_render_product_set_row( $i, $row, $sets, $attached ); } /** The product-editor JS for field rows: add, type-change, live summary. */ function addon_boss_product_fields_admin_script(): void { if ( ! function_exists( 'addon_boss_personalisation_field_types' ) ) { return; } $sym = html_entity_decode( get_woocommerce_currency_symbol() ); ?> jQuery( function ( $ ) { var panel = $( '#bundle_boss_options_panel' ); if ( ! panel.length ) { return; } // "Not on this product" answers as it is ticked. The is-off class was // only ever printed by PHP, so ticking the box struck nothing through // and dimmed nothing until the product was saved and the page came // back: the only feedback was the focus ring sitting on the box until // it moved to the next one, which reads as a stuck highlight rather // than a choice being refused. panel.on( 'change', '.bb-ovr-offcb', function () { $( this ).closest( '.bb-ovr-cell' ).toggleClass( 'is-off', this.checked ); } ); var pfNext = 100000; // field rows take a high, non-colliding index base var L = { newField: , optional: , required: , sym: , modes: }; function summarise( row ) { var lbl = $.trim( row.find( '.bb-pf-label' ).val() || '' ); row.find( '.bb-pf-headlabel' ).text( lbl || L.newField ); var reqmode = row.find( 'select[name$="[require]"]' ).val(); var bits = [ reqmode === 'always' ? L.required : L.optional ]; var fee = parseFloat( row.find( '.bb-pf-fee' ).val() ); var fmode = row.find( '.bb-pf-feemode' ).val(); var ftype = row.find( '.bb-pf-type' ).val(); if ( fee > 0 ) { // A type or mode whose charge reads in its own words says so // through the module that registered it; the fallback reads from // the mode menu's own wording. var ownBit = null; ( window.addonBossFeeBits || [] ).some( function ( f ) { var b = f( ftype, fmode, fee, row, L ); if ( null !== b && undefined !== b ) { ownBit = b; return true; } return false; } ); bits.push( null !== ownBit ? ownBit : '+' + L.sym + fee + ' ' + ( L.modes[ fmode ] || fmode || '' ) ); } // A module's own summary bits (an upload field's image-handling note) // join here, after the engine's. ( window.addonBossSummarise || [] ).forEach( function ( f ) { bits = f( bits, ftype, row ) || bits; } ); row.find( '.bb-pf-sum' ).text( bits.join( ' · ' ) ); } // The money box wears the currency by default; a mode priced as a share // of something (a percentage) swaps it through its module's own sync, // registered on addonBossTypeSync below. function feeSymSync( row ) { row.find( '.bb-pf-feesym' ).prop( 'hidden', false ); row.find( '.bb-pf-feepct' ).prop( 'hidden', true ); ( window.addonBossFeeSyms || [] ).forEach( function ( f ) { f( row ); } ); } /** * A group is only shown when at least one control inside it is relevant to * this field type, and a LONE remaining group drops its heading so a simple * field still reads as one flat row rather than a lonely titled section. * Type-conditional display is the cheapest disclosure there is: automatic, * predictable, and with no discoverability cost, which is why it does the * heavy lifting here instead of a fold. */ function grpSync( row ) { var shown = []; row.find( '.bb-pf-grp' ).each( function () { var g = $( this ); var kids = g.find( '.bb-pf-ctl, .bb-pf-imgrow' ); var any = false; kids.each( function () { if ( ! $( this ).prop( 'hidden' ) ) { any = true; } } ); g.prop( 'hidden', ! any ); if ( any ) { shown.push( g ); } } ); // Heading only earns its space once there is more than one group to tell // apart. var solo = shown.length < 2; shown.forEach( function ( g ) { g.find( '> .bb-pf-grph' ).prop( 'hidden', solo ); } ); } function typeSync( row ) { var t = row.find( '.bb-pf-type' ).val(); row.attr( 'data-type', t ); row.find( '.bb-pf-settings' ).prop( 'hidden', t === 'heading' || t === 'html' ); row.find( '.bb-pf-htmlbox' ).prop( 'hidden', t !== 'html' ); row.find( '.bb-pf-max' ).prop( 'hidden', ! ( t === 'text' || t === 'textarea' ) ); // Each module's own type behaviours (a measured size's unit picker, a // mode that only suits certain types) run here, after the engine's // own decisions and before the groups settle. ( window.addonBossTypeSync || [] ).forEach( function ( f ) { f( row, t ); } ); feeSymSync( row ); // Groups settle AFTER every per-control decision above, since a group's // visibility is derived from its children's. grpSync( row ); // The charge reads differently per type ("per square m" vs "for // each"), so the summary follows the type, not just the fee. summarise( row ); } panel.on( 'change', '.bb-pf-add', function () { var t = $( this ).val(); if ( ! t ) { return; } var tmpl = document.getElementById( 'bb-pf-row-template' ); if ( ! tmpl ) { return; } var row = $( tmpl.innerHTML.replace( /__PFIDX__/g, String( pfNext ) ) ); row.find( '.bb-pf-type' ).val( t ); // A new field lands in the ungrouped area, before the first group, so // it never auto-joins a group; the merchant drags it in deliberately. var $pfRows = $( '#bb-product-option-rows' ), $pfG = $pfRows.children( '.bb-po-grouprow' ).first(); if ( $pfG.length ) { $pfG.before( row ); } else { $pfRows.append( row ); } row.find( '.bb-po-body' ).prop( 'hidden', false ); typeSync( row ); feeSymSync( row ); var bbSegs = window.addonBossBuildSegs || window.bundleBossBuildSegs; // theirs, when BundleBoss's admin.js was the one loaded if ( bbSegs ) { bbSegs( row ); } row.find( '.bb-pf-label' ).trigger( 'focus' ); $( this ).val( '' ).trigger( 'change.select2' ); pfNext++; panel.trigger( 'bb-pf-changed' ); } ); panel.on( 'change', '.bb-pf-type', function () { typeSync( $( this ).closest( '.bb-po-fieldrow' ) ); } ); panel.on( 'change', '.bb-pf-feemode', function () { feeSymSync( $( this ).closest( '.bb-po-fieldrow' ) ); } ); // A module whose controls appear or leave (the repeat panel following // its mode menu) says so through this event, and the grouping is // resettled the same way the summary is refreshed. panel.on( 'bb-pf-regroup', '.bb-po-fieldrow', function () { grpSync( $( this ) ); } ); // ---- which variations a field is for -------------------------------- // "All of them" is the plain-English default. Unticking it reveals the // list; ticking it again clears every pick, so the stored rule and what // the merchant sees can never disagree. panel.on( 'change', '.bb-pf-varall-cb', function () { var wrap = $( this ).closest( '.bb-pf-vars' ); var list = wrap.find( '.bb-pf-varlist' ); if ( this.checked ) { list.prop( 'hidden', true ).find( 'input[type=checkbox]' ).prop( 'checked', false ); } else { list.prop( 'hidden', false ); } } ); // Unticking every variation means "all of them" again, rather than a rule // that matches nothing and silently hides the field everywhere. panel.on( 'change', '.bb-pf-varlist input[type=checkbox]', function () { var wrap = $( this ).closest( '.bb-pf-vars' ); if ( ! wrap.find( '.bb-pf-varlist input[type=checkbox]:checked' ).length ) { wrap.find( '.bb-pf-varall-cb' ).prop( 'checked', true ); wrap.find( '.bb-pf-varlist' ).prop( 'hidden', true ); } } ); panel.on( 'input change', '.bb-po-fieldrow .bb-pf-label, .bb-po-fieldrow .bb-pf-fee, .bb-po-fieldrow .bb-pf-feemode, .bb-po-fieldrow .bb-pf-unit, .bb-po-fieldrow select[name$="[require]"]', function () { summarise( $( this ).closest( '.bb-po-fieldrow' ) ); } ); // A module that changed something the closed row summarises asks for a // rewrite through this event rather than reaching into this scope. panel.on( 'bb-pf-resummarise', '.bb-po-fieldrow', function () { summarise( $( this ) ); } ); } ); i', esc_attr( $text ) ); } function addon_boss_product_options_panel(): void { global $post; $product = $post ? wc_get_product( $post->ID ) : null; $config = $product ? addon_boss_product_option_config( $product ) : array(); $sets = addon_boss_option_sets(); // The sets attached to THIS product: the reveal-rule triggers a field may // point at (a field shown "only when" one of these is picked a certain way). $bb_attached = array(); foreach ( addon_boss_flatten_option_config( $config ) as $bb_crow ) { $bb_csid = (string) ( $bb_crow['set'] ?? '' ); if ( '' !== $bb_csid && isset( $sets[ $bb_csid ] ) ) { $bb_attached[ $bb_csid ] = $sets[ $bb_csid ]; } } ?>
> 1 && count( addon_boss_flatten_option_config( $config ) ) > 1 ) : ?>

is_type( 'variable' ) ) : ?> →

is_type( 'variable' ) ) : $bb_has_price = '' !== (string) $product->get_price( 'edit' ); ?>

>

jQuery( function ( $ ) { var $n = $( '.bb-po-noprice' ), $rp = $( '#_regular_price' ); if ( ! $n.length || ! $rp.length ) { return; } function bbNoPriceSync() { $n.prop( 'hidden', '' !== $.trim( $rp.val() || '' ) ); } $rp.on( 'input change', bbNoPriceSync ); bbNoPriceSync(); } );

' . esc_html__( 'the Option sets library', 'addon-boss' ) . '' ) ); ?>

label, of every field ALREADY drawn. A rule // may only point at one of these, which is what makes a // cycle impossible and reads as "you cannot depend on an // answer that has not been asked yet". $bb_earlier = array(); foreach ( $config as $bb_lrow ) { if ( ( $bb_lrow['row_type'] ?? '' ) === 'group' ) { echo '
'; addon_boss_render_product_group_head( $bb_ri, (array) $bb_lrow ); echo '
'; $bb_ri++; foreach ( (array) ( $bb_lrow['children'] ?? array() ) as $bb_child ) { addon_boss_render_product_child_row( $bb_ri, (array) $bb_child, $sets, $bb_attached, $bb_earlier ); addon_boss_note_earlier_field( $bb_child, $bb_earlier ); $bb_ri++; } echo '
'; } else { addon_boss_render_product_child_row( $bb_ri, (array) $bb_lrow, $sets, $bb_attached, $bb_earlier ); addon_boss_note_earlier_field( $bb_lrow, $bb_earlier ); $bb_ri++; } } ?>

jQuery( function ( $ ) { var next = ; // Set id => name, so a whole folder can build its rows with real // names on them without a round trip to the server. window.addonBossSetNames = ; the two words that type uses for "there is an // answer" and "there is not", so a rule builder can re-word // itself the moment its trigger changes. $bb_op_words = array(); foreach ( addon_boss_standalone_product_fields( $product ) as $bb_fd ) { $bb_fkey = (string) ( $bb_fd['key'] ?? '' ); if ( '' === $bb_fkey ) { continue; } $bb_w = addon_boss_field_op_words( (string) ( $bb_fd['type'] ?? '' ) ); $bb_op_words[ $bb_fkey ] = array( $bb_w['filled'], $bb_w['empty'] ); } ?> window.addonBossFieldOps = ; // The rows fold like every other family row; controls do not toggle. $( '#bundle_boss_options_panel' ).on( 'click', '.bb-po-line', function ( e ) { if ( $( e.target ).closest( 'select, button, input, label, .bb-seg' ).length ) { return; } var body = $( this ).siblings( '.bb-po-body' )[ 0 ]; if ( body ) { body.hidden = ! body.hidden; $( this ).find( '.bb-po-caret' ).html( body.hidden ? '▸' : '▾' ); } } ); // Nested drag: the top-level list and every group's list are connected, so // a row drags between them. A group header stays a top-level item and never // nests inside another group. The container IS the boundary, so moving a // group carries its members and can never sweep up neighbours. var bbSortOpts = { handle: '.bb-po-line', items: '> .bb-po-row', distance: 6, cancel: 'select, button, input, textarea, a, label, .bb-seg', placeholder: 'bb-po-placeholder', forcePlaceholderSize: true, connectWith: '.bb-po-list, .bb-po-grouplist', over: function () { $( this ).closest( '.bb-po-grouprow' ).addClass( 'bb-po-groupactive' ); }, out: function () { $( '.bb-po-grouprow' ).removeClass( 'bb-po-groupactive' ); }, stop: function () { $( '.bb-po-grouprow' ).removeClass( 'bb-po-groupactive' ); }, receive: function ( e, ui ) { if ( ui.item.hasClass( 'bb-po-grouprow' ) && $( this ).hasClass( 'bb-po-grouplist' ) ) { $( ui.sender ).sortable( 'cancel' ); } } }; function bbMakeSortable( el ) { if ( el && $.fn.sortable && ! $( el ).hasClass( 'ui-sortable' ) ) { $( el ).sortable( bbSortOpts ); } } function bbInitSortables() { bbMakeSortable( document.getElementById( 'bb-product-option-rows' ) ); $( '.bb-po-grouplist' ).each( function () { bbMakeSortable( this ); } ); } bbInitSortables(); // On save, serialise the DOM tree into bb_po_order[] with group / gend // markers so the exact nesting is stored. function bbRowKey( $el ) { return $el.children( '.bb-po-line' ).find( '.bb-po-orderkey' ).val() || ''; } $( '#bundle_boss_options_panel' ).closest( 'form' ).on( 'submit', function () { var $list = $( '#bb-product-option-rows' ); $list.find( 'input[name="bb_po_order[]"]' ).remove(); var seq = []; $list.children( '.bb-po-row' ).each( function () { var $el = $( this ); seq.push( bbRowKey( $el ) ); if ( $el.hasClass( 'bb-po-grouprow' ) ) { $el.children( '.bb-po-grouplist' ).children( '.bb-po-row' ).each( function () { seq.push( bbRowKey( $( this ) ) ); } ); seq.push( 'gend-' + bbRowKey( $el ).replace( 'group-', '' ) ); } } ); var frag = document.createDocumentFragment(); seq.forEach( function ( k ) { var inp = document.createElement( 'input' ); inp.type = 'hidden'; inp.name = 'bb_po_order[]'; inp.value = k; frag.appendChild( inp ); } ); $list[0].appendChild( frag ); } ); // Add a group: append a fresh section header to name, then drag // sets and fields beneath it. var pgNext = 200000; $( '#bb-po-add-group' ).on( 'click', function () { var tmpl = document.getElementById( 'bb-po-group-template' ); if ( ! tmpl ) { return; } var row = $( tmpl.innerHTML.replace( /__PGIDX__/g, String( pgNext ) ) ); $( '#bb-product-option-rows' ).append( row ); row.find( '.bb-po-grouplabel' ).trigger( 'focus' ); bbMakeSortable( row.find( '.bb-po-grouplist' )[0] ); // connect the new group's drop zone pgNext++; } ); // Searchable, consistent add controls: hundreds of option sets stay // usable. select2 (loaded on the product screen) powers the search; // the field picker keeps its short list with no search box. if ( $.fn.select2 ) { $( '#bb-po-attach' ).select2( { width: '205px', placeholder: $( '#bb-po-attach option:first' ).text(), dropdownCssClass: 'bb-po-dropdown' } ); if ( $( '#bb-pf-add' ).length ) { $( '#bb-pf-add' ).select2( { width: '168px', placeholder: $( '#bb-pf-add option:first' ).text(), minimumResultsForSearch: Infinity, dropdownCssClass: 'bb-po-dropdown' } ); } } // One row per set, whether it arrived alone or with its folder. function bbAttachSet( setId, label, folder ) { if ( ! setId ) { return false; } // Already here: two rows for one set share a field name, so the // second silently eats the first. Say so on the row that exists // rather than making a broken one. var $have = $( '#bb-product-option-rows' ).find( 'input[name^="bb_po_set"]' ).filter( function () { return $( this ).val() === setId; } ).first(); if ( $have.length ) { var $seen = $have.closest( '.bb-po-row' ); $seen.addClass( 'bb-po-already' ); window.setTimeout( function () { $seen.removeClass( 'bb-po-already' ); }, 1400 ); return false; } var row = $( '
' + '' + '' + '' + '' + '' + '
' ); row.find( 'b' ).text( label ); // Set ONLY the set-id input; the .bb-po-orderkey carrier must keep // its "set-" value or the save drops the row (it fails the // "set-" prefix check). Blanket-setting every hidden input here was // the 4.16.0 regression that stopped newly attached sets saving. row.find( 'input[name^="bb_po_set"]' ).val( setId ); row.find( '.bb-po-fromgroup' ).val( folder || '' ); // A new set lands in the ungrouped area too, before any group. var $poRows = $( '#bb-product-option-rows' ), $poG = $poRows.children( '.bb-po-grouprow' ).first(); if ( $poG.length ) { $poG.before( row ); } else { $poRows.append( row ); } var bbSegs2 = window.addonBossBuildSegs || window.bundleBossBuildSegs; // theirs, when BundleBoss's admin.js was the one loaded if ( bbSegs2 ) { bbSegs2( row ); } next++; return true; } $( '#bb-po-attach' ).on( 'change', function () { var sel = $( this ), val = sel.val(); if ( ! val ) { return; } var $opt = sel.find( 'option:selected' ); if ( val.indexOf( 'bbfolder:' ) === 0 ) { // The whole folder. Its members ride on the option, so the // order they were arranged in is the order they arrive in, // and anything already on the product is left alone. var folder = val.slice( 9 ); var ids = ( $opt.attr( 'data-bb-folder' ) || '' ).split( ',' ); var names = window.addonBossSetNames || {}; $.each( ids, function ( i, id ) { id = $.trim( id ); if ( id ) { bbAttachSet( id, names[ id ] || id, folder ); } } ); } else { bbAttachSet( val, $opt.text(), $opt.closest( 'optgroup' ).attr( 'label' ) || '' ); } sel.val( '' ).trigger( 'change.select2' ); } ); $( document ).on( 'click', '.bb-po-remove', function () { var $row = $( this ).closest( '.bb-po-row' ); if ( ! $row.hasClass( 'bb-po-grouprow' ) ) { $row.remove(); return; } var $items = $row.children( '.bb-po-grouplist' ).children( '.bb-po-row' ); if ( ! $items.length ) { $row.remove(); return; } // an empty group just goes. bbGroupDelete( $row, $items.length ); // non-empty: ask before losing work. } ); var bbGL = { sub: , keep: , del: , cancel: , group: }; function bbGroupDelete( $group, n ) { var name = $.trim( $group.find( '.bb-po-grouplabel' ).val() || '' ) || bbGL.group; var $ov = $( '
' ); var $m = $( '' ).appendTo( $ov ); $( '

' ).text( '“' + name + '”' ).appendTo( $m ); $( '

' ).text( bbGL.sub ).appendTo( $m ); var $a = $( '
' ).appendTo( $m ); var $keep = $( '' ).text( bbGL.keep ).appendTo( $a ); $( '' ).text( bbGL.del ).appendTo( $a ); $( '' ).text( bbGL.cancel ).appendTo( $a ); function close() { $ov.remove(); $( document ).off( 'keydown.bbgd' ); } $a.on( 'click', '.bb-po-modal-keep', function () { $group.after( $group.children( '.bb-po-grouplist' ).children( '.bb-po-row' ) ); // extract to top level $group.remove(); if ( $.fn.sortable ) { $( '#bb-product-option-rows' ).sortable( 'refresh' ); } close(); } ); $a.on( 'click', '.bb-po-modal-del', function () { $group.remove(); close(); } ); $a.on( 'click', '.bb-po-modal-cancel', close ); $ov.on( 'click', function ( e ) { if ( e.target === $ov[0] ) { close(); } } ); $( document ).on( 'keydown.bbgd', function ( e ) { if ( e.key === 'Escape' ) { close(); } } ); $( 'body' ).append( $ov ); $keep.trigger( 'focus' ); } } );
get_meta( ADDON_BOSS_STANDALONE_UPLOAD_META ) ) ) { return; } // Every panel input is read ONCE, unslashed and sanitised here at the // boundary, into plain locals the builders below close over. Flat scalar // arrays (ids, labels, keys, rule values) take sanitize_text_field per // leaf; attachment ids take absint; the field definitions take // wp_kses_post per leaf, because an info block's content is legitimate // markup that a text sanitiser would destroy. The structured sanitisers // further down then enforce shape, types and money on the result, exactly // as before; nothing below this block touches the request again. $raw = array(); $set_rows = isset( $_POST['bb_po_set'] ) ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['bb_po_set'] ) ) : array(); $field_rows = isset( $_POST['bb_pf'] ) ? map_deep( wp_unslash( (array) $_POST['bb_pf'] ), 'wp_kses_post' ) : array(); $group_lbls = isset( $_POST['bb_po_group_label'] ) ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['bb_po_group_label'] ) ) : array(); $group_rpt = isset( $_POST['bb_po_group_repeat'] ) ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['bb_po_group_repeat'] ) ) : array(); $group_rmax = isset( $_POST['bb_po_group_repeat_max'] ) ? array_map( 'absint', wp_unslash( (array) $_POST['bb_po_group_repeat_max'] ) ) : array(); $group_rlbl = isset( $_POST['bb_po_group_repeat_label'] ) ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['bb_po_group_repeat_label'] ) ) : array(); $set_required = isset( $_POST['bb_po_required'] ) ? array_map( 'absint', wp_unslash( (array) $_POST['bb_po_required'] ) ) : array(); $set_deltas = isset( $_POST['bb_po_delta'] ) ? map_deep( wp_unslash( (array) $_POST['bb_po_delta'] ), 'sanitize_text_field' ) : array(); $set_images = isset( $_POST['bb_po_image'] ) ? map_deep( wp_unslash( (array) $_POST['bb_po_image'] ), 'absint' ) : array(); // Which choices this product refuses: set index => option key => 1. Absent // means nothing refused, which is what a form from any older build (or one // saved before this control existed) posts, so a save can never invent a // restriction the merchant did not ask for. $set_hide = isset( $_POST['bb_po_hide'] ) ? map_deep( wp_unslash( (array) $_POST['bb_po_hide'] ), 'absint' ) : array(); $set_fromgrp = isset( $_POST['bb_po_fromgroup'] ) ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['bb_po_fromgroup'] ) ) : array(); $set_when_set = isset( $_POST['bb_po_when_set'] ) ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['bb_po_when_set'] ) ) : array(); $set_when_is = isset( $_POST['bb_po_when_is'] ) ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['bb_po_when_is'] ) ) : array(); $pf_when_set = isset( $_POST['bb_pf_when_set'] ) ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['bb_pf_when_set'] ) ) : array(); $pf_when_is = isset( $_POST['bb_pf_when_is'] ) ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['bb_pf_when_is'] ) ) : array(); $pf_when_op = isset( $_POST['bb_pf_when_op'] ) ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['bb_pf_when_op'] ) ) : array(); $pf_when_more = isset( $_POST['bb_pf_when_more'] ) ? map_deep( wp_unslash( (array) $_POST['bb_pf_when_more'] ), 'sanitize_text_field' ) : array(); $pf_when_match = isset( $_POST['bb_pf_when_match'] ) ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['bb_pf_when_match'] ) ) : array(); // Show or hide. Absent is show, which is what every form built before this // posts and what every rule already stored means. $pf_when_act = isset( $_POST['bb_pf_when_act'] ) ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['bb_pf_when_act'] ) ) : array(); // The merchant's arrangement rides bb_po_order[]: one key per row in DOM // order, with group- / gend- markers bounding each group's children, // so the exact nesting is stored (a group is a real container). An older // form with no order keys falls back to the flat numeric index. $order = isset( $_POST['bb_po_order'] ) ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['bb_po_order'] ) ) : array(); $cur_group = null; // index into $raw of the currently open group, or null. $build_set = static function ( $i ) use ( $set_rows, $set_required, $set_deltas, $set_images, $set_hide, $set_fromgrp, $set_when_set, $set_when_is ) { return array( 'set' => (string) $set_rows[ $i ], 'required' => ! empty( $set_required[ $i ] ), 'deltas' => (array) ( $set_deltas[ $i ] ?? array() ), 'images' => (array) ( $set_images[ $i ] ?? array() ), 'hide' => (array) ( $set_hide[ $i ] ?? array() ), 'from_group' => (string) ( $set_fromgrp[ $i ] ?? '' ), 'when' => array( 'set' => (string) ( $set_when_set[ $i ] ?? '' ), 'is' => (string) ( $set_when_is[ $i ] ?? '' ), ), ); }; $build_field = static function ( $i ) use ( $field_rows, $pf_when_set, $pf_when_is, $pf_when_op, $pf_when_more, $pf_when_match, $pf_when_act ) { // The first condition still posts in the original two fields, so an older // form (or a browser with the extras stripped) saves exactly as it did. // Any further conditions ride bb_pf_when_more, and the whole lot is handed // to the canonical normaliser. $first_set = (string) ( $pf_when_set[ $i ] ?? '' ); $first_is = (string) ( $pf_when_is[ $i ] ?? '' ); $first_op = in_array( (string) ( $pf_when_op[ $i ] ?? '' ), array( 'is', 'is_not', 'filled', 'empty' ), true ) ? (string) $pf_when_op[ $i ] : 'is'; // The trigger dropdown carries sets and fields in one list; a field's // entry wears the prefix, so one control posts either kind. $bb_cond = static function ( string $trigger, string $op, string $value ): array { $bb_field = 0 === strpos( $trigger, ADDON_BOSS_FIELD_RULE_KEY ) ? substr( $trigger, strlen( ADDON_BOSS_FIELD_RULE_KEY ) ) : ''; return array( 'set' => '' === $bb_field ? $trigger : '', 'field' => $bb_field, 'op' => in_array( $op, array( 'is', 'is_not', 'filled', 'empty' ), true ) ? $op : 'is', 'value' => $value, ); }; $conds = array(); if ( '' !== $first_set ) { $conds[] = $bb_cond( $first_set, $first_op, $first_is ); } foreach ( (array) ( $pf_when_more[ $i ] ?? array() ) as $extra ) { if ( ! is_array( $extra ) ) { continue; } $set = (string) ( $extra['set'] ?? '' ); if ( '' === $set ) { continue; // a half-finished row is dropped rather than saved broken } $conds[] = $bb_cond( $set, (string) ( $extra['op'] ?? '' ), (string) ( $extra['value'] ?? '' ) ); } $bb_act = ( 'hide' === (string) ( $pf_when_act[ $i ] ?? '' ) ) ? 'hide' : 'show'; // One plain SHOW condition on a set stores in the legacy shape, so // nothing about an ordinary single-rule field changes on disk. if ( 1 === count( $conds ) && 'is' === $conds[0]['op'] && '' === $conds[0]['field'] && 'show' === $bb_act ) { $when = array( 'set' => $conds[0]['set'], 'is' => $conds[0]['value'] ); } elseif ( $conds ) { $when = array( 'match' => ( 'any' === (string) ( $pf_when_match[ $i ] ?? 'all' ) ) ? 'any' : 'all', 'conditions' => $conds, ); if ( 'hide' === $bb_act ) { $when = array( 'act' => 'hide' ) + $when; } } else { $when = array(); } return array( 'row_type' => 'field', 'field' => (array) $field_rows[ $i ], 'when' => $when, ); }; if ( $order ) { foreach ( $order as $okey ) { $okey = sanitize_text_field( (string) $okey ); if ( 0 === strpos( $okey, 'gend-' ) ) { $cur_group = null; // close the current group container. continue; } if ( 0 === strpos( $okey, 'group-' ) && isset( $group_lbls[ substr( $okey, 6 ) ] ) ) { $gi = substr( $okey, 6 ); $raw[] = array( 'row_type' => 'group', 'label' => sanitize_text_field( (string) ( $group_lbls[ $gi ] ?? '' ) ), 'children' => array(), // The group's repeat panel (or its hidden carriers); the // structured sanitiser whitelists and bounds these below. 'repeat' => (string) ( $group_rpt[ $gi ] ?? '' ), 'repeat_max' => (int) ( $group_rmax[ $gi ] ?? 0 ), 'repeat_label' => (string) ( $group_rlbl[ $gi ] ?? '' ), ); $cur_group = count( $raw ) - 1; continue; } $rowdata = null; if ( 0 === strpos( $okey, 'set-' ) && isset( $set_rows[ substr( $okey, 4 ) ] ) ) { $rowdata = $build_set( substr( $okey, 4 ) ); } elseif ( 0 === strpos( $okey, 'field-' ) && isset( $field_rows[ substr( $okey, 6 ) ] ) ) { $rowdata = $build_field( substr( $okey, 6 ) ); } if ( null === $rowdata ) { continue; } if ( null !== $cur_group ) { $raw[ $cur_group ]['children'][] = $rowdata; // a child of the open group. } else { $raw[] = $rowdata; // top-level, ungrouped. } } } else { // No order keys (an old cached form): flat numeric index, no groups. $indices = array_keys( $set_rows + $field_rows ); sort( $indices, SORT_NUMERIC ); foreach ( $indices as $i ) { $raw[] = isset( $set_rows[ $i ] ) ? $build_set( $i ) : $build_field( $i ); } } // Fold a retired artwork box into a file field, once. The dedicated box is // gone from the admin; a product that still carries the old upload config // keeps working via back-compat read until it is next saved, when the // config converts to a file-field row and the old meta is dropped. This is // non-destructive: no bulk rewrite runs, only this per-product pass, and // the file field carries the same label, required-ness, template link and // reveal rule the box had. $bb_old_upload = $product->get_meta( ADDON_BOSS_STANDALONE_UPLOAD_META ); if ( is_array( $bb_old_upload ) && '' !== (string) ( $bb_old_upload['mode'] ?? '' ) ) { $raw[] = array( 'row_type' => 'field', 'field' => array( 'type' => 'file', 'label' => '' !== (string) ( $bb_old_upload['label'] ?? '' ) ? (string) $bb_old_upload['label'] : __( 'Your artwork', 'addon-boss' ), 'require' => 'required' === ( $bb_old_upload['mode'] ?? '' ) ? 'always' : '', 'template' => (string) ( $bb_old_upload['template'] ?? '' ), 'filetypes' => (string) ( $bb_old_upload['filetypes'] ?? '' ), ), 'when' => is_array( $bb_old_upload['when'] ?? null ) ? $bb_old_upload['when'] : array(), ); $product->delete_meta_data( ADDON_BOSS_STANDALONE_UPLOAD_META ); } // How the groups are arranged on the page. Read only when the control was // actually on the screen that posted, so a surface without the picker can // never blank a choice made somewhere that has it. if ( isset( $_POST['bb_options_layout'] ) ) { addon_boss_save_options_layout( $product, sanitize_key( wp_unslash( $_POST['bb_options_layout'] ) ) ); } $clean = addon_boss_sanitize_product_option_config( $raw ); if ( $clean ) { $product->update_meta_data( ADDON_BOSS_PRODUCT_OPTIONS_META, $clean ); } else { $product->delete_meta_data( ADDON_BOSS_PRODUCT_OPTIONS_META ); } } // The product-tab icon rule used to live here. It was dead: it was guarded on // function_exists( 'addon_boss_menu_icon' ), and no such function exists in this // plugin or in BundleBoss, so the whole thing returned at the first line and had // never once run. What it left behind was a phpcs:ignore sitting on an echo into // a CSS url(), a context where neither esc_attr nor esc_url is the right // escaper, waiting for someone to define that name and make it live. // // Deleted rather than repointed. The coral square in admin.css was always doing // this job; nothing on screen changes. add_action( 'admin_footer', 'addon_boss_option_chip_expander_js' ); function addon_boss_option_chip_expander_js(): void { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen ) { return; } $is_ours = 'product' === $screen->id || false !== strpos( (string) $screen->id, 'addon-boss-option-sets' ); if ( ! $is_ours ) { return; } ?> jQuery( function ( $ ) { $( document ).on( 'click', 'button.bb-opt-more', function () { var b = $( this ), row = b.closest( '.bb-chiprow' ); var open = row.toggleClass( 'is-expanded' ).hasClass( 'is-expanded' ); b.attr( 'aria-expanded', open ? 'true' : 'false' ) .text( open ? b.data( 'lessLabel' ) : b.data( 'moreLabel' ) ); } ); } ); __( 'Product options and personalisation', 'addon-boss' ), 'callback' => 'addon_boss_standalone_exporter', ); return $exporters; } function addon_boss_standalone_exporter( $email, $page = 1 ) { $orders = wc_get_orders( array( 'billing_email' => $email, 'limit' => 20, 'paged' => max( 1, (int) $page ), 'type' => 'shop_order', 'orderby' => 'ID', 'order' => 'ASC', ) ); $export = array(); foreach ( (array) $orders as $order ) { foreach ( $order->get_items() as $item ) { $rows = addon_boss_standalone_privacy_rows( $item ); if ( $rows ) { $export[] = array( 'group_id' => 'addon-boss-standalone', 'group_label' => __( 'Product options and personalisation', 'addon-boss' ), 'item_id' => 'order-' . $order->get_id(), 'data' => $rows, ); } } } return array( 'data' => $export, 'done' => count( (array) $orders ) < 20, ); } /** * Flatten one order line's personal data (entered fields, option picks, and on * a build with the upload module, the upload and chosen areas) into WordPress exporter rows. */ function addon_boss_standalone_privacy_rows( $item ): array { $rows = array(); $up = $item->get_meta( ADDON_BOSS_STANDALONE_UPLOAD_META ); if ( is_array( $up ) && '' !== (string) ( $up['name'] ?? '' ) ) { $value = (string) $up['name']; if ( ! empty( $up['url'] ) ) { $value .= ' (' . $up['url'] . ')'; } $rows[] = array( 'name' => '' !== (string) ( $up['label'] ?? '' ) ? (string) $up['label'] : __( 'Your file', 'addon-boss' ), 'value' => $value, ); } foreach ( (array) $item->get_meta( ADDON_BOSS_PRODUCT_FIELDS_META ) as $f ) { $disp = (string) ( $f['display'] ?? '' ); if ( '' === $disp ) { continue; } if ( ! empty( $f['url'] ) ) { $disp .= ' (' . $f['url'] . ')'; } $rows[] = array( 'name' => '' !== (string) ( $f['label'] ?? '' ) ? (string) $f['label'] : __( 'Detail', 'addon-boss' ), 'value' => $disp, ); } foreach ( (array) $item->get_meta( '_bundle_boss_standalone_options' ) as $s ) { $label = (string) ( $s['label'] ?? '' ); if ( '' === $label ) { continue; } $rows[] = array( 'name' => '' !== (string) ( $s['set_name'] ?? '' ) ? (string) $s['set_name'] : __( 'Option', 'addon-boss' ), 'value' => $label, ); } // Chosen areas. A shopper's own logo and the words they asked to have // printed are as personal as anything above, and were being left out of // both the export and the erasure. foreach ( (array) $item->get_meta( '_bundle_boss_positions' ) as $pos ) { $parts = array(); if ( '' !== (string) ( $pos['text'] ?? '' ) ) { $parts[] = (string) $pos['text']; } if ( '' !== (string) ( $pos['file_name'] ?? '' ) ) { $file = (string) $pos['file_name']; if ( ! empty( $pos['file_url'] ) ) { $file .= ' (' . $pos['file_url'] . ')'; } $parts[] = $file; } if ( ! $parts ) { continue; } $rows[] = array( 'name' => '' !== (string) ( $pos['name'] ?? '' ) ? (string) $pos['name'] : __( 'Position', 'addon-boss' ), 'value' => implode( ', ', $parts ), ); } return $rows; } add_filter( 'wp_privacy_personal_data_erasers', 'addon_boss_standalone_register_eraser' ); function addon_boss_standalone_register_eraser( $erasers ) { $erasers['addon-boss-standalone'] = array( 'eraser_friendly_name' => __( 'Product options and personalisation', 'addon-boss' ), 'callback' => 'addon_boss_standalone_eraser', ); return $erasers; } function addon_boss_standalone_eraser( $email, $page = 1 ) { $orders = wc_get_orders( array( 'billing_email' => $email, 'limit' => 20, 'paged' => max( 1, (int) $page ), 'type' => 'shop_order', 'orderby' => 'ID', 'order' => 'ASC', ) ); $removed = false; foreach ( (array) $orders as $order ) { foreach ( $order->get_items() as $item ) { $changed = false; // The standalone artwork upload: delete the file, drop the meta and // its readable row. $up = $item->get_meta( ADDON_BOSS_STANDALONE_UPLOAD_META ); if ( is_array( $up ) ) { addon_boss_privacy_forget_upload( (string) ( $up['ref'] ?? '' ) ); $item->delete_meta_data( ADDON_BOSS_STANDALONE_UPLOAD_META ); if ( ! empty( $up['label'] ) ) { $item->delete_meta_data( (string) $up['label'] ); } $changed = true; } // Product fields: entered text and any uploaded files are personal. // A file field stores its reference as the field value, so try both // keys; resolving returns nothing for a plain text value, so text // fields are simply skipped by the deleter and dropped below. $fsnaps = $item->get_meta( ADDON_BOSS_PRODUCT_FIELDS_META ); if ( is_array( $fsnaps ) && $fsnaps ) { foreach ( $fsnaps as $f ) { foreach ( array( $f['value'] ?? '', $f['ref'] ?? '' ) as $maybe_ref ) { if ( '' !== (string) $maybe_ref ) { addon_boss_privacy_forget_upload( (string) $maybe_ref ); } } if ( ! empty( $f['label'] ) ) { $item->delete_meta_data( (string) $f['label'] ); } } $item->delete_meta_data( ADDON_BOSS_PRODUCT_FIELDS_META ); $changed = true; } // Chosen areas: the artwork they uploaded and the words they asked to // have printed. Both the structured snapshot and the readable row the // order screen shows have to go, or an erasure leaves the personal // data sitting in plain sight on the order. $psnaps = $item->get_meta( '_bundle_boss_positions' ); if ( is_array( $psnaps ) && $psnaps ) { foreach ( $psnaps as $pos ) { if ( '' !== (string) ( $pos['file'] ?? '' ) ) { addon_boss_privacy_forget_upload( (string) $pos['file'] ); } if ( ! empty( $pos['name'] ) ) { $item->delete_meta_data( (string) $pos['name'] ); } } $item->delete_meta_data( '_bundle_boss_positions' ); $changed = true; } if ( $changed ) { $item->save(); $removed = true; } } // The per-order copy folder. Deleting the originals above does not touch // it, because it holds COPIES, and the orphan sweep skips it on purpose. // Without this an honoured erasure left a complete second copy of the // customer's artwork on disk. Only a build with the uploads module has // such a folder, so the call is guarded like the file deleter above. if ( function_exists( 'addon_boss_standalone_forget_order_folder' ) ) { addon_boss_standalone_forget_order_folder( (int) $order->get_id() ); } } return array( 'items_removed' => $removed, 'items_retained' => false, 'messages' => array(), 'done' => count( (array) $orders ) < 20, ); } // ================================================================= // Apply this setup to other products: a COPY, never a live link. // The merchant perfects one product, picks the parts and a category, // reads a review naming what happens to every product, then applies. // Receivers end up with their own editable setup; house precedent // (the starter shelf, the price-table gallery) is copy-not-link, so // nothing a merchant owns ever changes behind their back. // ================================================================= /** * The parts a copy can carry. Photo overrides never travel with the * option rows: the blue photo of this polo is not the blue photo of * that hoodie, so each row's images stay behind by design. * * @return array part key => merchant-facing name. */ function addon_boss_copy_parts(): array { return array( 'options' => __( 'Option sets and fields', 'addon-boss' ), 'positions' => __( 'Positions', 'addon-boss' ), 'breaks' => __( 'Quantity breaks', 'addon-boss' ), 'layout' => __( 'Layout', 'addon-boss' ), ); } /** * The products a category offers as receivers: anything a merchant is * still working on counts (published, draft, pending, private), the * source itself never does. * * @return int[] Product ids. */ function addon_boss_copy_targets( int $source_id, int $term_id ): array { $ids = get_posts( array( 'post_type' => 'product', 'post_status' => array( 'publish', 'draft', 'pending', 'private' ), 'posts_per_page' => 200, 'fields' => 'ids', 'orderby' => 'title', 'order' => 'ASC', 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the one honest way to list a category. array( 'taxonomy' => 'product_cat', 'terms' => $term_id, ), ), ) ); return array_values( array_filter( array_map( 'intval', $ids ), static fn( $id ) => $id !== $source_id ) ); } /** * What the apply WOULD do, product by product, before anything does: * the review the bulk-editing pattern libraries call for and the same * look-it-over-first habit the StockBoss bulk build keeps. * * @return array */ function addon_boss_copy_outcomes( int $source_id, int $term_id, bool $replace ): array { $out = array(); foreach ( addon_boss_copy_targets( $source_id, $term_id ) as $id ) { $target = wc_get_product( $id ); if ( ! $target instanceof WC_Product ) { continue; } $has_own = (bool) addon_boss_product_option_config( $target ); $out[] = array( 'id' => $id, 'name' => $target->get_name(), 'has_own' => $has_own, 'will' => $replace || ! $has_own, ); } return $out; } /** * The copy itself. Each ticked part lands on every willing receiver as * the source has it - a part the source carries copies over, a part the * source does not carry clears, which is what "apply THIS setup" means. * Receivers that already have an options setup are skipped unless the * merchant ticked replace, and the source is never touched. * * @param int $source_id The product being copied from. * @param int $term_id The receiving category. * @param array $parts Part keys from addon_boss_copy_parts(). * @param bool $replace Overwrite receivers that have their own setup. * @return array{applied:int,skipped:int} */ function addon_boss_copy_apply( int $source_id, int $term_id, array $parts, bool $replace ): array { $source = wc_get_product( $source_id ); if ( ! $source instanceof WC_Product ) { return array( 'applied' => 0, 'skipped' => 0 ); } $parts = array_values( array_intersect( array_keys( addon_boss_copy_parts() ), array_map( 'strval', $parts ) ) ); // The source's options config with every per-row photo override // stripped, prepared once for all receivers. $config = array(); foreach ( addon_boss_product_option_config( $source ) as $row ) { if ( is_array( $row ) ) { unset( $row['images'] ); $config[] = $row; } } // Storage names, not module constants: a free build carries no positions // or breaks module, and copying their stored rows as dormant data is // exactly how the engine treats every setup a build cannot speak for. $raw_metas = addon_boss_copy_raw_metas(); $applied = 0; $skipped = 0; foreach ( addon_boss_copy_outcomes( $source_id, $term_id, $replace ) as $row ) { if ( ! $row['will'] ) { ++$skipped; continue; } $target = wc_get_product( $row['id'] ); if ( ! $target instanceof WC_Product ) { continue; } if ( in_array( 'options', $parts, true ) ) { addon_boss_save_product_option_config( $target, $config ); $target = wc_get_product( $row['id'] ); // the save persisted; reread so raw metas stack cleanly. } foreach ( $raw_metas as $part => $keys ) { if ( ! in_array( $part, $parts, true ) ) { continue; } foreach ( $keys as $key ) { $value = get_post_meta( $source_id, $key, true ); if ( '' === $value || null === $value || array() === $value ) { delete_post_meta( $row['id'], $key ); } else { update_post_meta( $row['id'], $key, $value ); } } } ++$applied; } return array( 'applied' => $applied, 'skipped' => $skipped ); } /** * "Take the setup from another product" - the pull surface, Stefanos's * Design A (23 Aug 2026). It stands on the product BEING SET UP and * takes a finished product's setup; nothing here ever edits another * product, per the house rule. The engine above (parts, outcomes, * apply) is unchanged; a future Products-list bulk action would reuse * it from the list screen, never from inside a product. */ /** The stored meta keys each raw part travels by. */ function addon_boss_copy_raw_metas(): array { return array( 'positions' => array( '_bundle_boss_position_sets', '_bundle_boss_position_overrides', '_bundle_boss_positions_label', '_bundle_boss_positions_entry', ), 'breaks' => array( '_addon_boss_qty_discount' ), 'layout' => array( ADDON_BOSS_OPTIONS_LAYOUT_META ), ); } /** * Land the source's ticked parts on ONE receiver, as the source has * them: photo overrides stripped, a ticked part the source lacks * CLEARS (taking THIS setup includes its absences), unticked parts are * never touched, and the source is never written. A take is explicit, * so it always replaces what the receiver had in the ticked parts. */ function addon_boss_copy_apply_one( int $source_id, int $receiver_id, array $parts ): bool { $source = wc_get_product( $source_id ); $receiver = wc_get_product( $receiver_id ); if ( ! $source instanceof WC_Product || ! $receiver instanceof WC_Product || $source_id === $receiver_id ) { return false; } $parts = array_values( array_intersect( array_keys( addon_boss_copy_parts() ), array_map( 'strval', $parts ) ) ); if ( ! $parts ) { return false; } if ( in_array( 'options', $parts, true ) ) { $config = array(); foreach ( addon_boss_product_option_config( $source ) as $row ) { if ( is_array( $row ) ) { unset( $row['images'] ); $config[] = $row; } } addon_boss_save_product_option_config( $receiver, $config ); } foreach ( addon_boss_copy_raw_metas() as $part => $keys ) { if ( ! in_array( $part, $parts, true ) ) { continue; } foreach ( $keys as $key ) { $value = get_post_meta( $source_id, $key, true ); if ( '' === $value || null === $value || array() === $value ) { delete_post_meta( $receiver_id, $key ); } else { update_post_meta( $receiver_id, $key, $value ); } } } return true; } /** * What a product's setup carries, part by part, in shop words: the * ticks' captions and the search rows' chips both read from this, so * the merchant can tell they have the right product BEFORE taking. * * @return array */ function addon_boss_take_summary( int $product_id ): array { $product = wc_get_product( $product_id ); if ( ! $product instanceof WC_Product ) { return array(); } $out = array(); foreach ( addon_boss_copy_parts() as $key => $label ) { $have = false; $bits = ''; if ( 'options' === $key ) { $names = array(); foreach ( addon_boss_product_option_config( $product ) as $row ) { $set = is_array( $row ) && ! empty( $row['set'] ) ? addon_boss_option_set( (string) $row['set'] ) : null; if ( $set ) { $names[] = (string) $set['name']; } } $have = (bool) $names; $keep = array_slice( $names, 0, 3 ); if ( count( $names ) > 3 ) { /* translators: %s: how many more option groups the product carries. */ $keep[] = sprintf( __( '+%s more', 'addon-boss' ), number_format_i18n( count( $names ) - 3 ) ); } $bits = implode( ', ', $keep ); } elseif ( 'positions' === $key ) { $sets = get_post_meta( $product_id, '_bundle_boss_position_sets', true ); $n = is_array( $sets ) ? count( $sets ) : 0; $have = $n > 0; /* translators: %s: how many print/embroidery positions the product carries. */ $bits = $have ? sprintf( _n( '%s position', '%s positions', $n, 'addon-boss' ), number_format_i18n( $n ) ) : ''; } elseif ( 'breaks' === $key ) { $meta = get_post_meta( $product_id, '_addon_boss_qty_discount', true ); $have = is_array( $meta ) && ! empty( $meta['enabled'] ); if ( $have ) { $list = ! empty( $meta['source'] ) && function_exists( 'addon_boss_break_list' ) ? addon_boss_break_list( (string) $meta['source'] ) : null; $bands = count( (array) ( $meta['bands'] ?? array() ) ); if ( $list ) { /* translators: %s: the saved break list's name. */ $bits = sprintf( __( 'list: %s', 'addon-boss' ), (string) $list['name'] ); } elseif ( $bands > 0 ) { /* translators: %s: how many quantity bands the product carries. */ $bits = sprintf( _n( '%s band', '%s bands', $bands, 'addon-boss' ), number_format_i18n( $bands ) ); } // Enabled with no bands and no list says nothing beyond the label. } } else { $have = '' !== (string) get_post_meta( $product_id, ADDON_BOSS_OPTIONS_LAYOUT_META, true ); } $out[] = array( 'key' => (string) $key, 'label' => (string) $label, 'have' => $have, 'bits' => $bits, ); } return $out; } /** * Products worth taking from: they HAVE a setup, they are not the * product being edited, and they match by name OR by SKU (a shop that * lives in SKUs finds by SKU). An empty search suggests the edited * product's own categories first, so the likely source is one tap * away even in a big catalogue. * * @return int[] Product ids, best first, capped. */ function addon_boss_take_candidates( string $q, int $receiver_id, int $cap = 20 ): array { $base = array( 'type' => array( 'simple', 'variable' ), 'status' => 'publish', 'limit' => $cap * 3, 'return' => 'ids', ); $ids = array(); if ( '' !== $q ) { $byname = wc_get_products( array_merge( $base, array( 's' => $q ) ) ); $bysku = wc_get_products( array_merge( $base, array( 'sku' => $q ) ) ); $ids = array_merge( (array) $byname, (array) $bysku ); } else { $cats = wc_get_product_term_ids( $receiver_id, 'product_cat' ); if ( $cats ) { $ids = (array) wc_get_products( array_merge( $base, array( 'category' => array_map( static fn( $t ) => get_term( $t, 'product_cat' )->slug ?? '', $cats ) ) ) ); } $ids = array_merge( $ids, (array) wc_get_products( array_merge( $base, array( 'orderby' => 'date', 'order' => 'DESC' ) ) ) ); } $out = array(); foreach ( array_unique( array_map( 'intval', $ids ) ) as $id ) { if ( $id === $receiver_id ) { continue; } $candidate = wc_get_product( $id ); if ( ! $candidate instanceof WC_Product || ! addon_boss_product_option_config( $candidate ) ) { continue; } $out[] = $id; if ( count( $out ) >= $cap ) { break; } } return $out; } /** Search, for the picker: rows with name, SKU and what-it-carries chips. */ function addon_boss_take_search_ajax(): void { check_ajax_referer( 'bb_take' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do that.', 'addon-boss' ) ), 403 ); } $q = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : ''; $receiver = isset( $_GET['product'] ) ? (int) $_GET['product'] : 0; $rows = array(); foreach ( addon_boss_take_candidates( $q, $receiver ) as $id ) { $candidate = wc_get_product( $id ); $chips = array(); foreach ( addon_boss_take_summary( $id ) as $part ) { if ( ! $part['have'] ) { continue; } if ( 'options' === $part['key'] ) { $sets = count( addon_boss_product_option_config( $candidate ) ); /* translators: %s: how many option groups the product carries. */ $chips[] = sprintf( _n( '%s set', '%s sets', $sets, 'addon-boss' ), number_format_i18n( $sets ) ); } elseif ( 'layout' !== $part['key'] ) { $chips[] = '' !== $part['bits'] ? $part['bits'] : $part['label']; } } $rows[] = array( 'id' => $id, 'text' => $candidate->get_name(), 'sku' => (string) $candidate->get_sku(), 'bits' => $chips, ); } wp_send_json( array( 'results' => $rows ) ); } add_action( 'wp_ajax_addon_boss_take_search', 'addon_boss_take_search_ajax' ); /** Peek, for the ticks: the picked source's parts with captions. */ function addon_boss_take_peek_ajax(): void { check_ajax_referer( 'bb_take' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do that.', 'addon-boss' ) ), 403 ); } $source = isset( $_POST['source'] ) ? (int) $_POST['source'] : 0; wp_send_json_success( array( 'parts' => addon_boss_take_summary( $source ) ) ); } add_action( 'wp_ajax_addon_boss_take_peek', 'addon_boss_take_peek_ajax' ); /** The take itself: the ticked parts land on the product being edited. */ function addon_boss_take_apply_ajax(): void { check_ajax_referer( 'bb_take' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do that.', 'addon-boss' ) ), 403 ); } $source = isset( $_POST['source'] ) ? (int) $_POST['source'] : 0; $receiver = isset( $_POST['product'] ) ? (int) $_POST['product'] : 0; $parts = isset( $_POST['parts'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['parts'] ) ) : array(); if ( ! addon_boss_copy_apply_one( $source, $receiver, $parts ) ) { wp_send_json_error( array( 'message' => __( 'Nothing was taken. Pick a product and at least one part.', 'addon-boss' ) ), 400 ); } wp_send_json_success(); } add_action( 'wp_ajax_addon_boss_take_apply', 'addon_boss_take_apply_ajax' ); /** * The card on the Options tab. A product with no setup gets it open, * ready; a product that already has one keeps it folded behind a quiet * "Start from another product" line, because then it is the rarer path. */ function addon_boss_take_panel( $product ): void { if ( ! $product instanceof WC_Product || ! current_user_can( 'manage_woocommerce' ) ) { return; } $has_own = (bool) addon_boss_product_option_config( $product ); $card = static function () use ( $product, $has_own ): void { ?>
data-say-warn="">

' . '' . '' . esc_html__( 'Start from another product', 'addon-boss' ) . '' . '' . esc_html__( 'Copy the sets, positions and breaks of a finished product into this one.', 'addon-boss' ) . '' . ''; $card(); echo ''; } else { $card(); } addon_boss_take_panel_js(); } add_action( 'addon_boss_options_panel_extras', 'addon_boss_take_panel', 5 ); /** The card's behaviour: search, peek, take, then reload to show it. */ function addon_boss_take_panel_js(): void { ob_start(); ?> ( function () { var root = document.getElementById( 'bb-take' ); if ( ! root || root.dataset.bbTakeBooted || ! window.jQuery || ! jQuery.fn.select2 ) { return; } root.dataset.bbTakeBooted = '1'; var $src = jQuery( '#bb-take-src' ); var parts = document.getElementById( 'bb-take-parts' ); var warn = document.getElementById( 'bb-take-warn' ); var go = document.getElementById( 'bb-take-go' ); var stop = document.getElementById( 'bb-take-cancel' ); var dirty = document.getElementById( 'bb-take-dirty' ); var force = document.getElementById( 'bb-take-force' ); var keep = document.getElementById( 'bb-take-keep' ); var acts = root.querySelector( '.bb-take-acts' ); // Taking a setup reloads the page, and a reload throws away whatever // the merchant typed elsewhere on it: the page's own form is printed // once it has settled and compared again at the click. Unchanged // reloads freely; changed asks first. Nonces refresh on their own // heartbeat and this card is part of the flow, so neither counts as // the merchant's work. function formPrint() { var form = document.getElementById( 'post' ); if ( ! form ) { return ''; } if ( window.tinyMCE && window.tinyMCE.triggerSave ) { window.tinyMCE.triggerSave(); } var out = []; Array.prototype.forEach.call( form.querySelectorAll( 'input, select, textarea' ), function ( el ) { if ( ! el.name || /nonce|_wp_http_referer/i.test( el.name ) ) { return; } if ( root.contains( el ) ) { return; } if ( ( 'checkbox' === el.type || 'radio' === el.type ) && ! el.checked ) { return; } out.push( el.name + '=' + el.value ); } ); return out.join( '&' ); } // The snapshot waits for ready: this script runs mid-form, before the // fields below the panel have even parsed. var cleanPrint = null; jQuery( function () { cleanPrint = formPrint(); } ); function calm() { dirty.hidden = true; acts.hidden = false; } $src.select2( { placeholder: root.dataset.saySearch, allowClear: true, // Its own dropdown class only: bb-po-dropdown carries a blue // highlighted-row rule that erases the carries-line. dropdownCssClass: 'bb-take-drop', ajax: { url: window.ajaxurl, delay: 250, data: function ( q ) { return { action: 'addon_boss_take_search', _wpnonce: root.dataset.nonce, product: root.dataset.product, q: q.term || '' }; } }, templateResult: function ( row ) { if ( ! row.id ) { return row.text; } var el = document.createElement( 'div' ); el.className = 'bb-take-row'; var top = document.createElement( 'span' ); top.className = 'bb-take-row-top'; var nm = document.createElement( 'b' ); nm.textContent = row.text; top.appendChild( nm ); if ( row.sku ) { var sk = document.createElement( 'small' ); sk.textContent = row.sku; top.appendChild( sk ); } el.appendChild( top ); var line = document.createElement( 'span' ); line.className = 'bb-take-row-sub'; line.textContent = ( row.bits || [] ).join( ' \u00b7 ' ); el.appendChild( line ); return jQuery( el ); } } ); function reset() { parts.hidden = true; parts.textContent = ''; warn.hidden = true; go.disabled = true; stop.hidden = true; calm(); } $src.on( 'select2:clear', reset ); $src.on( 'select2:select', function ( e ) { calm(); var data = new FormData(); data.append( 'action', 'addon_boss_take_peek' ); data.append( '_wpnonce', root.dataset.nonce ); data.append( 'source', String( e.params.data.id ) ); fetch( window.ajaxurl, { method: 'POST', body: data, credentials: 'same-origin' } ) .then( function ( r ) { return r.json(); } ) .then( function ( res ) { if ( ! res || ! res.success ) { return; } parts.textContent = ''; res.data.parts.forEach( function ( p ) { var row = document.createElement( 'label' ); row.className = 'bb-take-part' + ( p.have ? '' : ' is-empty' ); var cb = document.createElement( 'input' ); cb.type = 'checkbox'; cb.value = p.key; cb.checked = p.have && 'layout' !== p.key; cb.disabled = ! p.have; row.appendChild( cb ); var txt = document.createElement( 'span' ); var nm = document.createElement( 'b' ); nm.textContent = p.label; txt.appendChild( nm ); var bits = document.createElement( 'i' ); bits.textContent = ' · ' + ( p.have ? ( p.bits || '' ) : root.dataset.sayNone ); if ( p.have && ! p.bits ) { bits.textContent = ''; } txt.appendChild( bits ); row.appendChild( txt ); parts.appendChild( row ); } ); parts.hidden = false; stop.hidden = false; go.disabled = false; if ( '1' === root.dataset.hasOwn ) { warn.textContent = root.dataset.sayWarn; warn.hidden = false; } } ); } ); stop.addEventListener( 'click', function () { $src.val( null ).trigger( 'change' ); reset(); } ); function apply() { var picked = $src.select2( 'data' ); if ( ! picked || ! picked.length ) { return; } go.disabled = true; force.disabled = true; var data = new FormData(); data.append( 'action', 'addon_boss_take_apply' ); data.append( '_wpnonce', root.dataset.nonce ); data.append( 'source', String( picked[ 0 ].id ) ); data.append( 'product', root.dataset.product ); root.querySelectorAll( '.bb-take-part input:checked' ).forEach( function ( c ) { data.append( 'parts[]', c.value ); } ); fetch( window.ajaxurl, { method: 'POST', body: data, credentials: 'same-origin' } ) .then( function ( r ) { return r.json(); } ) .then( function ( res ) { if ( res && res.success ) { // The taken setup is best shown by the editor itself. window.location.reload(); } else { go.disabled = false; force.disabled = false; } } ); } go.addEventListener( 'click', function () { if ( null !== cleanPrint && formPrint() !== cleanPrint ) { acts.hidden = true; dirty.hidden = false; return; } apply(); } ); force.addEventListener( 'click', apply ); keep.addEventListener( 'click', calm ); } )();