*/ 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' => __( 'Message', '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', 'addon-boss' ) ), 'color' => array( 'label' => __( 'Colour', 'addon-boss' ), 'menu' => __( 'Colour', 'addon-boss' ), 'color_options' => true, 'rule_answer' => 'addon_boss_colour_rule_answer' ), // "Name your price" is what this is CALLED, everywhere it is sold. It was // "Their price", which is our shorthand and nobody else's: it names the // figure rather than the thing, so a merchant scanning the field list has // to open it to find out what it does. The trade already has a name for a // box the shopper types an amount into, and a merchant who wants one // searches for that name, not for ours. (His find, 7 Sept.) 'price' => array( 'label' => __( 'Name your price', 'addon-boss' ), 'menu' => __( 'Name your price', 'addon-boss' ), 'own_amount' => true ), 'email' => array( 'label' => __( 'Email', 'addon-boss' ), 'menu' => __( 'Email address', 'addon-boss' ) ), 'url' => array( 'label' => __( 'Web address', 'addon-boss' ), 'menu' => __( 'Web address', 'addon-boss' ) ), 'hidden' => array( 'label' => __( 'Hidden', 'addon-boss' ), 'menu' => __( 'Hidden value', 'addon-boss' ), 'admin_only' => true ), 'heading' => array( 'label' => __( 'Heading', 'addon-boss' ), 'display_only' => true, 'menu' => __( 'Heading', 'addon-boss' ) ), 'html' => array( 'label' => __( 'Info block', 'addon-boss' ), 'display_only' => true, 'menu' => __( 'Info 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( 'once' => __( 'Flat fee', 'addon-boss' ), 'unit' => __( 'Quantity based', 'addon-boss' ), ); return (array) apply_filters( 'addon_boss_fee_modes', $own ); } /** * The same modes as short phrases, for the closed row's summary line, where * the mode reads AFTER an amount: "+£2.50 for each" is a sentence, while * "+£2.50 Quantity based" is a label read aloud. The menu takes the * category's names (Flat fee, Quantity based); this keeps the reading. * * @return array mode key to phrase. */ function addon_boss_fee_mode_shorts(): array { $own = array( 'once' => __( 'once', 'addon-boss' ), 'unit' => __( 'for each', 'addon-boss' ), ); return (array) apply_filters( 'addon_boss_fee_mode_shorts', $own ); } /** * What ONE charge mode does, in a sentence a merchant can act on. * * The Charge control used to carry a single sentence explaining "for each" and * "once" together, sitting full-width above the controls. It was a wall of text * AND incomplete: the menu offers five modes, and the three it never mentioned * are exactly the ones nobody guesses. A merchant only ever needs the one they * have picked, which is the rule the repeatable panel already follows. * * Filterable for the same reason the menu is: a module that registers a mode * says what it does here, so the note can never fall behind the menu. * * @param string $mode The stored fee mode. * @return string Empty when nothing has claimed the mode. */ function addon_boss_fee_mode_note( string $mode ): string { $own = array( 'unit' => __( 'Charged per item: two shirts, twice.', 'addon-boss' ), 'once' => __( 'Charged once, however many they buy.', 'addon-boss' ), ); $notes = (array) apply_filters( 'addon_boss_fee_mode_notes', $own ); return isset( $notes[ $mode ] ) ? (string) $notes[ $mode ] : ''; } /** * 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; } // The merchant's own sentence under the field, and the category's own name // for it: Gravity Forms, WPForms, Product Add-Ons, PPOM, Ninja Forms and // Meta Box all call this control Description, it is WordPress's own admin // idiom (`

`) and it is already the argument name in // WooCommerce's own wp_*_input helpers. ACF's "Instructions" is that one // plugin's dialect; even the field builder that copies the label stores it // under `description` underneath. // // Stored only when written, like every optional string in these defs, so a // cleared box removes the key and the storefront falls silent again. Plain // text on purpose: this is guidance, not a place to build markup, and it // shares a slot with lines the engine WORKS OUT (a number's fence, a date's // earliest day) which are escaped as text too. $bb_desc = sanitize_text_field( (string) ( $f['description'] ?? '' ) ); if ( '' !== $bb_desc ) { $def['description'] = $bb_desc; } $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 ) ); } if ( 'select' === $type ) { // A dropdown prices by the chosen option. A field-level fee here was // promised on three surfaces and charged on none; it never survives. $def['fee'] = 0.0; } if ( 'number' === $type ) { // Bounds are optional; the zero floor at resolve is not. Stored // only when set, like every optional fence in these defs. foreach ( array( 'num_min', 'num_max' ) as $bb_bk ) { if ( '' !== (string) ( $f[ $bb_bk ] ?? '' ) && is_numeric( $f[ $bb_bk ] ) ) { $def[ $bb_bk ] = (float) $f[ $bb_bk ]; } } // Counting things, not measuring them. Stored only when ticked, so // every field built before this keeps taking decimals. if ( ! empty( $f['num_whole'] ) ) { $def['num_whole'] = true; } if ( isset( $def['num_min'], $def['num_max'] ) && $def['num_max'] < $def['num_min'] ) { // A ceiling below the floor is nonsense; the floor wins. unset( $def['num_max'] ); } } if ( 'color' === $type ) { $def['options'] = addon_boss_personalisation_parse_color_options( $f['options'] ?? '' ); // The custom pick is an extra swatch the merchant switches ON; its own // charge is separate because a colour match costs work the presets do not. $def['allow_custom'] = ! empty( $f['allow_custom'] ); $def['custom_fee'] = max( 0.0, (float) wc_format_decimal( (string) ( $f['custom_fee'] ?? 0 ) ) ); $def['fee'] = 0.0; // the swatches carry the money; a field-level fee never applies. } if ( 'price' === $type ) { // The shopper's amount IS the charge, so the field's own fee input never // applies; min/max/suggested bound it and the chips are typed as one // comma list, capped at five so the row stays a row. $def['fee'] = 0.0; foreach ( array( 'price_min', 'price_max', 'price_suggested' ) as $bb_pk ) { $bb_pv = trim( (string) ( $f[ $bb_pk ] ?? '' ) ); if ( '' !== $bb_pv && is_numeric( wc_format_decimal( $bb_pv ) ) ) { $def[ $bb_pk ] = max( 0.0, (float) wc_format_decimal( $bb_pv ) ); } } if ( isset( $def['price_min'], $def['price_max'] ) && $def['price_max'] < $def['price_min'] ) { unset( $def['price_max'] ); // a ceiling under the floor is a typo, not a rule. } $bb_amounts = array(); foreach ( explode( ',', (string) ( $f['price_amounts'] ?? '' ) ) as $bb_a ) { $bb_a = trim( $bb_a ); if ( '' !== $bb_a && is_numeric( wc_format_decimal( $bb_a ) ) && (float) wc_format_decimal( $bb_a ) > 0 ) { $bb_amounts[] = (float) wc_format_decimal( $bb_a ); } } if ( $bb_amounts ) { $def['price_amounts'] = array_slice( array_values( array_unique( $bb_amounts ) ), 0, 5 ); } } if ( 'hidden' === $type ) { // Invisible things carry no requirement and no charge, ever: a shopper // cannot complete what they cannot see, and invisible money is exactly // the pattern this plugin exists to refuse. $def['required'] = false; unset( $def['require_when'] ); $def['fee'] = 0.0; $def['value'] = sanitize_text_field( (string) ( $f['value'] ?? '' ) ); $def['url_fill'] = ! empty( $f['url_fill'] ); } // 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 ); } elseif ( isset( ADDON_BOSS_SCHEDULING_KEYS[ $type ] ) ) { // A scheduling field's own rules (a date's lead time, cut-off, closed // days and rush ladder; a time's hours, slot step and priced windows) // belong to a module this build may not have. They ride through a save // untouched rather than being wiped, on exactly the reasoning the // repeat setting above is stored on: a licence that lapses and a field // that is re-saved must not cost a shop its whole calendar or its // opening hours. The editor reposts them on hidden carriers; nothing // here reads or honours them. // // The list is keyed by type rather than written out per field because // the date field carried this protection from the day it shipped and // the time field, written days later, did not: a merchant on a lapsed // licence lost every window on the first save. One table, and the next // scheduling type cannot repeat it. foreach ( ADDON_BOSS_SCHEDULING_KEYS[ $type ] as $bb_dk ) { if ( isset( $f[ $bb_dk ] ) ) { $def[ $bb_dk ] = map_deep( $f[ $bb_dk ], 'sanitize_text_field' ); } } } 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, 'description' => 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 the structured colour rows from the admin repeater. Each row is * [ label, color, fee ]; the slug derives from the label like every option. * A row without a real hex colour is dropped rather than stored blank, so a * stored swatch can always be painted. * * @return array */ function addon_boss_personalisation_parse_color_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'] ?? '' ); $hex = addon_boss_normalise_hex( (string) ( $row['color'] ?? '' ) ); if ( '' === $label || '' === $hex ) { continue; } $value = sanitize_title( $row['value'] ?? '' ) ?: sanitize_title( $label ); if ( '' === $value ) { $value = 'colour'; } $base = $value; $n = 2; while ( isset( $used[ $value ] ) ) { $value = $base . '-' . $n++; } $used[ $value ] = true; $out[] = array( 'value' => $value, 'label' => $label, 'color' => $hex, 'fee' => max( 0.0, (float) wc_format_decimal( (string) ( $row['fee'] ?? 0 ) ) ), ); } return $out; } /** * One hex shape everywhere: #RRGGBB lower-case, or '' for anything else. * Accepts a missing #, and the #RGB short form browsers never send but * people paste. */ function addon_boss_normalise_hex( string $raw ): string { $raw = strtolower( trim( $raw ) ); if ( '' !== $raw && '#' !== $raw[0] ) { $raw = '#' . $raw; } if ( preg_match( '/^#([0-9a-f]{3})$/', $raw, $m ) ) { return '#' . $m[1][0] . $m[1][0] . $m[1][1] . $m[1][1] . $m[1][2] . $m[1][2]; } return preg_match( '/^#[0-9a-f]{6}$/', $raw ) ? $raw : ''; } /** * Sanitise a raw submitted value against a field definition. * * A WP_Error is the third answer, for a value the shopper DID type that the * field cannot accept (an amount under the minimum, an address that is not * an address). null stays "left empty": dropping typed-but-wrong input * silently would either lose their answer or, on a priced field, their money. * * @return array{value:string,display:string,opt_fee?:float}|WP_Error|null null = empty. */ function addon_boss_personalisation_resolve_value( array $def, $raw, $product = null ) { $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 ) ); if ( '' === $val ) { return null; } $bb_over = addon_boss_personalisation_too_long( $val, $def ); if ( $bb_over instanceof WP_Error ) { return $bb_over; } return array( 'value' => $val, 'display' => $val ); case 'text': $val = trim( sanitize_text_field( (string) $raw ) ); if ( '' === $val ) { return null; } $bb_over = addon_boss_personalisation_too_long( $val, $def ); if ( $bb_over instanceof WP_Error ) { return $bb_over; } return array( 'value' => $val, 'display' => $val ); case 'number': if ( null === $raw || '' === $raw ) { return null; } if ( ! is_numeric( $raw ) ) { // Typed but not a number. This used to resolve to null, so somebody // who wrote "two" got silence while somebody who wrote 1e9 got a // sentence. One wrong answer, one behaviour. return new WP_Error( 'addon_boss', sprintf( /* translators: %s: the field's label */ __( '%s: enter a number.', 'addon-boss' ), (string) ( $def['label'] ?? '' ) ) ); } $bb_num = (float) $raw; // The floor is zero even with no bounds set: a typed -3 on a // per-value charge priced NEGATIVE money before this. Typed but // out of the fence speaks, never silently drops. $bb_lo = isset( $def['num_min'] ) ? max( 0.0, (float) $def['num_min'] ) : 0.0; $bb_hi = isset( $def['num_max'] ) ? (float) $def['num_max'] : null; if ( $bb_num < $bb_lo || ( null !== $bb_hi && $bb_num > $bb_hi ) ) { $bb_words = null !== $bb_hi /* translators: 1: the field's label, 2: the lowest allowed number, 3: the highest allowed number */ ? sprintf( __( '%1$s: enter a number between %2$s and %3$s.', 'addon-boss' ), (string) ( $def['label'] ?? '' ), (string) ( $bb_lo + 0 ), (string) ( $bb_hi + 0 ) ) /* translators: 1: the field's label, 2: the lowest allowed number */ : sprintf( __( '%1$s: enter a number of at least %2$s.', 'addon-boss' ), (string) ( $def['label'] ?? '' ), (string) ( $bb_lo + 0 ) ); return new WP_Error( 'bundle_boss', $bb_words ); } if ( ! empty( $def['num_whole'] ) && floor( $bb_num ) !== $bb_num ) { // "7.5 stems" is orderable without this. A shop that COUNTS things // ticks the box; one that measures in metres leaves it clear. return new WP_Error( 'addon_boss', sprintf( /* translators: %s: the field's label */ __( '%s: enter a whole number.', 'addon-boss' ), (string) ( $def['label'] ?? '' ) ) ); } $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': $bb_typed = trim( (string) $raw ); $slug = sanitize_title( $bb_typed ); if ( '' === $slug ) { return null; } foreach ( (array) ( $def['options'] ?? array() ) as $opt ) { // The sanitised form OR the value exactly as stored, for the same // reason as the colour swatches above: a value that arrived by a // road other than the editor can carry a character sanitize_title // rewrites, and no answer would ever match it again. $stored = (string) ( $opt['value'] ?? '' ); if ( '' !== $stored && ( $stored === $slug || $stored === $bb_typed ) ) { return array( 'value' => $stored, 'display' => $opt['label'] ?? $stored, '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' ) ); case 'email': $val = trim( sanitize_text_field( (string) $raw ) ); $bb_at = strrpos( $val, '@' ); if ( false !== $bb_at ) { // The domain is case-blind; the part before the @ is theirs to spell. $val = substr( $val, 0, $bb_at ) . strtolower( substr( $val, $bb_at ) ); } if ( '' === $val ) { return null; } if ( ! is_email( $val ) ) { return new WP_Error( 'addon_boss', sprintf( /* translators: %s: the field's label */ __( '%s: enter an email address like name@example.com.', 'addon-boss' ), (string) ( $def['label'] ?? __( 'Email', 'addon-boss' ) ) ) ); } return array( 'value' => $val, 'display' => $val ); case 'url': $val = trim( sanitize_text_field( (string) $raw ) ); if ( '' === $val ) { return null; } // People type "mysite.com". The strict native check rejects exactly the // commonest correct input, so the scheme is supplied rather than demanded. if ( ! preg_match( '#^https?://#i', $val ) ) { $val = 'https://' . $val; } $bb_host = (string) wp_parse_url( $val, PHP_URL_HOST ); if ( false === strpos( $bb_host, '.' ) || ! filter_var( $val, FILTER_VALIDATE_URL ) ) { return new WP_Error( 'addon_boss', sprintf( /* translators: %s: the field's label */ __( '%s: enter a web address like example.com.', 'addon-boss' ), (string) ( $def['label'] ?? __( 'Web address', 'addon-boss' ) ) ) ); } $val = esc_url_raw( $val ); return array( 'value' => $val, 'display' => $val ); case 'color': $val = trim( sanitize_text_field( (string) $raw ) ); if ( '' === $val ) { return null; } if ( 0 === strpos( $val, 'custom:' ) ) { if ( empty( $def['allow_custom'] ) ) { return null; // the escape hatch is off: not a defined choice. } $hex = addon_boss_normalise_hex( substr( $val, 7 ) ); if ( '' === $hex ) { return new WP_Error( 'addon_boss', sprintf( /* translators: %s: the field's label */ __( '%s: pick a colour or enter a code like #1A2B3C.', 'addon-boss' ), (string) ( $def['label'] ?? __( 'Colour', 'addon-boss' ) ) ) ); } return array( 'value' => 'custom:' . $hex, // The hex is the truth a printer can work from; "Custom" is the word. 'display' => sprintf( /* translators: %s: a colour code like #1A2B3C */ __( 'Custom (%s)', 'addon-boss' ), $hex ), 'opt_fee' => (float) ( $def['custom_fee'] ?? 0 ), 'hex' => $hex, ); } $slug = sanitize_title( $val ); foreach ( (array) ( $def['options'] ?? array() ) as $opt ) { // The sanitised form OR the value exactly as stored. The editor // slugs every value it saves, so the two are normally the same // string and this changes nothing. They come apart only when a // value arrived by some other road (an import, an older format, // a hand-written fixture) carrying a character sanitize_title // rewrites: a swatch stored as "#c9a227" can then never be // matched by the "#c9a227" the page posts back, and the field // becomes permanently unanswerable. Required, that is a dead // Add to basket refusing a colour the shopper can see selected, // with nothing in the message to explain it. Same family as the // hidden required choice that blocked the grid. $stored = (string) ( $opt['value'] ?? '' ); if ( '' !== $stored && ( $stored === $slug || $stored === $val ) ) { $bb_label = (string) ( $opt['label'] ?? $stored ); $bb_hex = (string) ( $opt['color'] ?? '' ); return array( 'value' => $stored, // The code in brackets is for the person making the thing, // and only earns its place when there is one. A swatch that // reached us without a colour printed "Gold ()" on the cart // line and on the order, an empty bracket nobody can read. 'display' => '' !== $bb_hex ? sprintf( '%1$s (%2$s)', $bb_label, $bb_hex ) : $bb_label, 'opt_fee' => (float) ( $opt['fee'] ?? 0 ), 'hex' => $bb_hex, ); } } return null; // not a defined swatch case 'price': $val = trim( (string) ( is_scalar( $raw ) ? $raw : '' ) ); if ( '' === $val ) { return null; } $bb_amount = wc_format_decimal( $val ); if ( '' === (string) $bb_amount || ! is_numeric( $bb_amount ) ) { return new WP_Error( 'addon_boss', sprintf( /* translators: 1: the field's label, 2: a money amount */ __( '%1$s: enter an amount like %2$s.', 'addon-boss' ), (string) ( $def['label'] ?? __( 'Name your price', 'addon-boss' ) ), // aob-money-raw: an example of the FORMAT to type in, not a charge. addon_boss_plain_price( 10 ) ) ); } $bb_amount = (float) $bb_amount; if ( $bb_amount <= 0 ) { // Zero or less is no charge at all, and no charge is an empty answer, // never a negative line. The zero-floor rule, same as everywhere. return null; } // THE SHOPPER TYPED THE FIGURE THEY WERE READING. // // The box speaks the shop's display basis, like every other price on // the page, so the typed figure arrives in that basis. The bounds the // merchant set are entry-basis like every Woo price, so they convert // to the shopper's basis for the comparison and for the sentence: the // refusal must quote figures the shopper can see on their screen. The // charge converts the other way and rides the fee pipeline as the // entry-basis figure every configured fee already is. On a shop whose // two bases coincide, none of this moves anything. (His call, 7 Sept.) $bb_pp = $product instanceof WC_Product ? $product : null; // The basis the page declared, or the shop's display setting where no // declaration arrived (an API add, an order-again): the same default // the page renders with. $bb_basis = isset( $_POST['bb_own_basis'] ) ? sanitize_key( wp_unslash( $_POST['bb_own_basis'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only basis declaration; the add-to-cart nonce guards the request. if ( ! in_array( $bb_basis, array( 'incl', 'excl' ), true ) ) { $bb_basis = 'incl' === get_option( 'woocommerce_tax_display_shop', 'excl' ) ? 'incl' : 'excl'; } if ( isset( $def['price_min'] ) ) { $bb_min_shown = $bb_pp ? addon_boss_entry_to_basis( (float) $def['price_min'], $bb_pp, $bb_basis ) : (float) $def['price_min']; if ( round( $bb_amount, 4 ) < round( $bb_min_shown, 4 ) ) { return new WP_Error( 'addon_boss', sprintf( /* translators: 1: the field's label, 2: the smallest amount accepted */ __( '%1$s: enter at least %2$s.', 'addon-boss' ), (string) ( $def['label'] ?? '' ), // aob-money-raw: the bound in the basis the shopper reads. addon_boss_plain_price( $bb_min_shown ) ) ); } } if ( isset( $def['price_max'] ) ) { $bb_max_shown = $bb_pp ? addon_boss_entry_to_basis( (float) $def['price_max'], $bb_pp, $bb_basis ) : (float) $def['price_max']; if ( round( $bb_amount, 4 ) > round( $bb_max_shown, 4 ) ) { return new WP_Error( 'addon_boss', sprintf( /* translators: 1: the field's label, 2: the largest amount accepted */ __( '%1$s: enter at most %2$s.', 'addon-boss' ), (string) ( $def['label'] ?? '' ), // aob-money-raw: the upper twin of price_min, same reason. addon_boss_plain_price( $bb_max_shown ) ) ); } } return array( 'value' => (string) wc_format_decimal( $bb_amount, wc_get_price_decimals() ), // aob-money-raw: the figure they typed, echoed back to them, in the // basis they typed it in. 'display' => addon_boss_plain_price( $bb_amount ), // Their amount IS the charge, converted to the entry basis so it // rides the same pipeline every configured fee does. Downstream tax // puts it back to exactly the figure they typed. 'opt_fee' => $bb_pp ? addon_boss_basis_to_entry( $bb_amount, $bb_pp, $bb_basis ) : $bb_amount, ); case 'hidden': // The stored value is the truth; the page never asked anything. Posted // input counts only when fill-from-link is on, and even then it is // plain text with a hard cap, never markup, never unbounded. $bb_fixed = (string) ( $def['value'] ?? '' ); $val = $bb_fixed; if ( ! empty( $def['url_fill'] ) ) { $bb_posted = trim( sanitize_text_field( (string) ( is_scalar( $raw ) ? $raw : '' ) ) ); if ( '' !== $bb_posted ) { $val = mb_substr( $bb_posted, 0, 200 ); } } if ( '' === $val ) { return null; } return array( 'value' => $val, 'display' => $val, 'admin_only' => true ); } 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; // Amber as the cap approaches, red once it is reached. The last // stretch is proportional, so a 30 box and a 500 box both warn // with room to act; a flat two characters was no notice at all on // a long one. Red matters because maxlength stops the keys // silently: without it, nothing on screen explains why typing died. var tail = !! max ? Math.min( 20, Math.max( 2, Math.round( max * 0.1 ) ) ) : 0; c.classList.toggle( 'is-near', !! max && n >= max - tail && n < max ); c.classList.toggle( 'is-at', !! max && n >= max ); } 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' ) ); } ); } )(); __( 'Did you mean %s?', 'addon-boss' ), 'unavail' => __( 'Unavailable', 'addon-boss' ), /* translators: %s: how many choices the list holds */ 'nomatch' => __( 'No matching options. Clear your text to see all %s options.', 'addon-boss' ), /* translators: 1: results shown, 2: of them unavailable */ 'results' => __( '%1$s results (%2$s unavailable). Use up and down arrows.', 'addon-boss' ), ); ?> ( function () { var root = document.querySelector( '.bb-standalone-options' ); if ( ! root ) { return; } var I18N = ; // ── Colour: the custom swatch opens only once this script exists. ── root.querySelectorAll( '.bb-addon-colour .bb-colour-custom' ).forEach( function ( cw ) { cw.hidden = false; var radio = cw.querySelector( 'input[type=radio]' ); var pickw = cw.querySelector( '.bb-colour-pick' ); var picker = cw.querySelector( '.bb-colour-picker' ); var hexin = cw.querySelector( '.bb-colour-hex' ); var group = cw.closest( '.bb-addon-colour' ); function norm( v ) { v = ( v || '' ).trim().toLowerCase(); if ( v && '#' !== v[ 0 ] ) { v = '#' + v; } if ( /^#[0-9a-f]{3}$/.test( v ) ) { v = '#' + v[ 1 ] + v[ 1 ] + v[ 2 ] + v[ 2 ] + v[ 3 ] + v[ 3 ]; } return /^#[0-9a-f]{6}$/.test( v ) ? v : ''; } function sync( from ) { var hex = norm( 'picker' === from ? picker.value : hexin.value ); if ( ! hex ) { return; } if ( 'picker' === from ) { hexin.value = hex; } else { picker.value = hex; } // The radio's value IS the answer the form posts; painting it here is // what lets a no-surprises native POST carry the exact colour. radio.value = 'custom:' + hex; radio.checked = true; radio.dispatchEvent( new Event( 'change', { bubbles: true } ) ); } picker && picker.addEventListener( 'input', function () { sync( 'picker' ); } ); // Live on input too: the chip takes the colour the moment the sixth // character lands, not when the box loses focus. Partial codes fall // out of norm() and change nothing. hexin && hexin.addEventListener( 'input', function () { sync( 'hex' ); } ); hexin && hexin.addEventListener( 'change', function () { sync( 'hex' ); } ); group && group.addEventListener( 'change', function ( e ) { if ( e.target && 'radio' === e.target.type ) { pickw.hidden = ( e.target !== radio ); } } ); } ); // ── Name your price: chips fill the box; the box stays the truth. ── // // A chip shows the display-basis figure and pressing it writes exactly // that figure. On a shop with a tax toggle the figure a chip SHOWS moves // with the switch, so what it writes has to move too: the -ex/-in pair // carries both, and the toggle plugin's own active class says which side // the shopper is reading. No tax maths happens here. root.querySelectorAll( '.bb-addon-ownprice' ).forEach( function ( w ) { var chips = w.querySelector( '.bb-price-chips' ); var input = w.querySelector( '.bb-price-input' ); if ( ! chips || ! input ) { return; } chips.hidden = false; function curAmount( c ) { var on = document.querySelector( '.wts-price-incl.wts-active' ); var off = document.querySelector( '.wts-price-excl.wts-active' ); if ( on ) { return c.dataset.bbAmountIn || c.dataset.bbAmount; } if ( off ) { return c.dataset.bbAmountEx || c.dataset.bbAmount; } return c.dataset.bbAmount; } function mark() { var now = parseFloat( ( input.value || '' ).replace( ',', '.' ) ); chips.querySelectorAll( '.bb-price-chip' ).forEach( function ( c ) { // A chip is "on" when the box holds its figure in ANY basis, so // the tick survives a toggle flip that rewrites both of them. c.classList.toggle( 'is-on', parseFloat( c.dataset.bbAmount ) === now || parseFloat( c.dataset.bbAmountEx ) === now || parseFloat( c.dataset.bbAmountIn ) === now ); } ); } chips.addEventListener( 'click', function ( e ) { var b = e.target.closest( '.bb-price-chip' ); if ( ! b ) { return; } input.value = curAmount( b ); input.dispatchEvent( new Event( 'input', { bubbles: true } ) ); input.dispatchEvent( new Event( 'change', { bubbles: true } ) ); mark(); } ); input.addEventListener( 'input', mark ); mark(); } ); // ── E-mail: the did-you-mean nudge. Never blocks, one tap accepts. ── var DOMAINS = [ 'gmail.com', 'googlemail.com', 'hotmail.com', 'hotmail.co.uk', 'outlook.com', 'yahoo.com', 'yahoo.co.uk', 'icloud.com', 'live.com', 'aol.com', 'protonmail.com', 'msn.com' ]; function nearDomain( d ) { if ( -1 !== DOMAINS.indexOf( d ) ) { return ''; } for ( var i = 0; i < DOMAINS.length; i++ ) { var t = DOMAINS[ i ]; if ( Math.abs( t.length - d.length ) > 1 ) { continue; } var edits = 0, a = 0, b = 0; while ( a < d.length && b < t.length ) { if ( d[ a ] === t[ b ] ) { a++; b++; continue; } // gmial -> gmail: the classic typo is two adjacent letters // swapped, and it is ONE mistake, not two. if ( a + 1 < d.length && b + 1 < t.length && d[ a ] === t[ b + 1 ] && d[ a + 1 ] === t[ b ] ) { edits++; a += 2; b += 2; if ( edits > 1 ) { break; } continue; } edits++; if ( edits > 1 ) { break; } if ( d.length > t.length ) { a++; } else if ( d.length < t.length ) { b++; } else { a++; b++; } } edits += ( d.length - a ) + ( t.length - b ); if ( edits === 1 ) { return t; } } return ''; } root.querySelectorAll( 'input[type=email].bb-addon-input' ).forEach( function ( inp ) { var hint = root.querySelector( '.bb-addon-mailhint[data-bb-for="' + inp.id + '"]' ); if ( ! hint ) { return; } inp.addEventListener( 'blur', function () { hint.hidden = true; hint.textContent = ''; var v = ( inp.value || '' ).trim(); var at = v.lastIndexOf( '@' ); if ( at < 1 ) { return; } var fix = nearDomain( v.slice( at + 1 ).toLowerCase() ); if ( ! fix ) { return; } var whole = v.slice( 0, at + 1 ) + fix; hint.textContent = I18N.mean.replace( '%s', whole ); hint.hidden = false; hint.onclick = function () { inp.value = whole; hint.hidden = true; inp.dispatchEvent( new Event( 'change', { bubbles: true } ) ); }; } ); } ); // ── Long lists search themselves. ────────────────────────────── // The native select stays in the form and stays the single truth: the // combobox only reads its options (fresh on every open, because pruning // disables options live) and writes its value back. No script, or a // script failure, leaves the working select exactly as it renders today. var THRESHOLD = 12; function fold( s ) { // Case- and accent-insensitive matching: "Metallic" finds "métallic". s = ( s || '' ).toLowerCase(); try { s = s.normalize( 'NFD' ).replace( /[̀-ͯ]/g, '' ); } catch ( e ) {} return s; } function enhance( sel ) { if ( sel.dataset.bbCombo || 'off' === sel.dataset.bbSearch || sel.multiple ) { return; } var real = 0; for ( var i = 0; i < sel.options.length; i++ ) { if ( '' !== sel.options[ i ].value ) { real++; } } if ( real <= THRESHOLD ) { return; } sel.dataset.bbCombo = '1'; var wrap = document.createElement( 'span' ); wrap.className = 'bb-combo'; var input = document.createElement( 'input' ); // BOTH classes. It only ever carried bb-combo-input, so the one control // we build in script wore raw browser chrome while every field beside // it wore the panel's. Next to a dressed field it read as something // from another decade, which is exactly what it was. (His find.) input.type = 'text'; input.className = 'bb-addon-input bb-combo-input'; input.setAttribute( 'role', 'combobox' ); input.setAttribute( 'aria-expanded', 'false' ); input.setAttribute( 'aria-autocomplete', 'list' ); input.setAttribute( 'autocomplete', 'off' ); input.setAttribute( 'spellcheck', 'false' ); var lbid = ( sel.id || sel.name || 'bb' ).replace( /[^a-z0-9_-]/gi, '' ) + '-listbox'; input.setAttribute( 'aria-controls', lbid ); // The select's accessible name carries over, so the combobox answers // to the same label the select did. var owner = sel.closest( 'label' ) || document.querySelector( 'label[for="' + sel.id + '"]' ); if ( owner && owner.id ) { input.setAttribute( 'aria-labelledby', owner.id ); } var chev = document.createElement( 'span' ); chev.className = 'bb-combo-chev'; chev.setAttribute( 'aria-hidden', 'true' ); var list = document.createElement( 'ul' ); list.className = 'bb-combo-list'; list.id = lbid; list.setAttribute( 'role', 'listbox' ); list.hidden = true; var live = document.createElement( 'span' ); live.className = 'screen-reader-text'; live.setAttribute( 'aria-live', 'polite' ); sel.parentNode.insertBefore( wrap, sel ); wrap.appendChild( input ); wrap.appendChild( chev ); wrap.appendChild( list ); wrap.appendChild( live ); sel.style.display = 'none'; sel.setAttribute( 'tabindex', '-1' ); sel.setAttribute( 'aria-hidden', 'true' ); var committed = sel.selectedOptions && sel.selectedOptions[ 0 ] && sel.value ? sel.selectedOptions[ 0 ].text : ''; input.value = committed; var items = [], hl = -1, liveTimer = 0; function snapshot() { var out = []; for ( var i = 0; i < sel.options.length; i++ ) { var o = sel.options[ i ]; if ( '' === o.value ) { continue; } out.push( { value: o.value, label: o.text, dis: o.disabled } ); } return out; } function render( q ) { var all = snapshot(); var f = fold( q ); var starts = [], has = []; all.forEach( function ( o ) { var l = fold( o.label ); if ( '' === f || 0 === l.indexOf( f ) ) { starts.push( o ); } else if ( -1 !== l.indexOf( f ) ) { has.push( o ); } // A shopper types what they know, and that is not always the // words on the row. A collection time is stored 09:30 and // written "9:30 am", so typing the time found nothing; a size // or a finish is often known by its code. The value is the // option's other half, so it answers too, ranked last so a // real label match always leads. else if ( -1 !== fold( o.value ).indexOf( f ) ) { has.push( o ); } } ); items = starts.concat( has ); list.textContent = ''; hl = -1; if ( ! items.length ) { var none = document.createElement( 'li' ); none.className = 'bb-combo-none'; none.textContent = I18N.nomatch.replace( '%s', String( all.length ) ); list.appendChild( none ); } var un = 0; items.forEach( function ( o, ix ) { if ( o.dis ) { un++; } var li = document.createElement( 'li' ); // is-on marks what is chosen NOW. Opening on the whole list is // only half the job: a shopper also has to see which row they // are already on without reading the box above. var bbOn = sel.value && o.value === sel.value; li.className = 'bb-combo-opt' + ( o.dis ? ' is-dis' : '' ) + ( bbOn ? ' is-on' : '' ); li.id = lbid + '-' + ix; li.setAttribute( 'role', 'option' ); li.setAttribute( 'aria-selected', bbOn ? 'true' : 'false' ); if ( o.dis ) { li.setAttribute( 'aria-disabled', 'true' ); } // Highlight the matched letters without trusting the label as HTML. var l = fold( o.label ), at = '' === f ? -1 : l.indexOf( f ); if ( at >= 0 ) { li.appendChild( document.createTextNode( o.label.slice( 0, at ) ) ); var m = document.createElement( 'mark' ); m.textContent = o.label.slice( at, at + q.length ); li.appendChild( m ); li.appendChild( document.createTextNode( o.label.slice( at + q.length ) ) ); } else { li.textContent = o.label; } if ( o.dis ) { var u = document.createElement( 'span' ); u.className = 'bb-combo-unav'; u.textContent = I18N.unavail; li.appendChild( u ); } li.dataset.bbValue = o.value; list.appendChild( li ); } ); clearTimeout( liveTimer ); liveTimer = setTimeout( function () { live.textContent = I18N.results.replace( '%1$s', String( items.length ) ).replace( '%2$s', String( un ) ); }, 900 ); } function open( q ) { render( q ); list.hidden = false; input.setAttribute( 'aria-expanded', 'true' ); } function close() { list.hidden = true; input.setAttribute( 'aria-expanded', 'false' ); input.removeAttribute( 'aria-activedescendant' ); hl = -1; } function commit( ix ) { var o = items[ ix ]; if ( ! o || o.dis ) { return; } sel.value = o.value; committed = o.label; input.value = o.label; sel.dispatchEvent( new Event( 'change', { bubbles: true } ) ); close(); } function move( dir ) { if ( list.hidden ) { open( input.value ); } var n = hl; for ( var g = 0; g < items.length; g++ ) { n += dir; if ( n < 0 ) { n = items.length - 1; } if ( n >= items.length ) { n = 0; } if ( ! items[ n ].dis ) { break; } } if ( n < 0 || ! items.length || items[ n ].dis ) { return; } var prev = list.querySelector( '.is-hl' ); prev && prev.classList.remove( 'is-hl' ); hl = n; var el = document.getElementById( lbid + '-' + n ); if ( el ) { el.classList.add( 'is-hl' ); input.setAttribute( 'aria-activedescendant', el.id ); el.scrollIntoView( { block: 'nearest' } ); } } input.addEventListener( 'input', function () { open( input.value ); } ); input.addEventListener( 'keydown', function ( e ) { // YOUR ANSWER IS REPLACEABLE, NOT EDITABLE. // // While the box shows the committed pick it is a statement of what // you chose, not text to amend. Backspacing into it left // "9:30 am (+GBP 4.0" on screen while the field underneath still // held 09:30: the box saying something the form did not mean. // Nothing unsafe could be submitted, because blur reverts and free // text never posts, but a control that can be made to lie about // your own answer is not one to trust. (His find, 7 Sept.) // // So the first key on a committed box clears it and starts a fresh // search. Escape and blur still put the answer back. if ( committed && input.value === committed && ! e.ctrlKey && ! e.metaKey && ! e.altKey && ( 1 === String( e.key ).length || 'Backspace' === e.key || 'Delete' === e.key ) ) { input.value = ''; if ( 'Backspace' === e.key || 'Delete' === e.key ) { e.preventDefault(); open( '' ); } } if ( 'ArrowDown' === e.key ) { e.preventDefault(); move( 1 ); } else if ( 'ArrowUp' === e.key ) { e.preventDefault(); move( -1 ); } else if ( 'Enter' === e.key ) { if ( ! list.hidden && hl >= 0 ) { e.preventDefault(); commit( hl ); } } else if ( 'Escape' === e.key ) { input.value = committed; close(); } } ); input.addEventListener( 'blur', function () { // Unconfirmed text reverts: free text can never submit, the exact // contract that keeps this a select underneath. setTimeout( function () { if ( document.activeElement && wrap.contains( document.activeElement ) ) { return; } input.value = committed; close(); }, 120 ); } ); chev.addEventListener( 'mousedown', function ( e ) { e.preventDefault(); if ( list.hidden ) { input.focus(); open( '' ); } else { close(); } } ); // PRESSING THE BOX OPENS THE WHOLE LIST, and highlights what is in it. // // It used to open filtered by input.value, which after a pick IS the // chosen label: the list came back holding one row, the row already // chosen. So the only ways to see the other choices were to press the // little arrow, or to delete your own answer first. It read as a search // box that had forgotten it was a dropdown. (His find, 7 Sept.) // // Selecting the text is the other half: the next keystroke replaces the // old answer instead of being appended to it, which is what every // combobox a shopper has ever used does. input.addEventListener( 'click', function () { if ( ! list.hidden ) { return; } open( '' ); input.select(); } ); list.addEventListener( 'mousedown', function ( e ) { var li = e.target.closest( '.bb-combo-opt' ); if ( ! li || li.classList.contains( 'is-dis' ) ) { e.preventDefault(); return; } e.preventDefault(); commit( items.findIndex( function ( o ) { return o.value === li.dataset.bbValue; } ) ); } ); // The select's value can move without us (prefill, another driver): // mirror it so the box never lies about the committed pick. sel.addEventListener( 'change', function () { var o = sel.selectedOptions && sel.selectedOptions[ 0 ]; committed = sel.value && o ? o.text : ''; if ( document.activeElement !== input ) { input.value = committed; } } ); // THE BOX IS A COPY, AND A COPY GOES STALE. // // This input holds the chosen option's TEXT, taken once at the moment // it was chosen. The tax toggle rewrites every

' . esc_html( $label ) . '
'; } return; } // A hidden field is the merchant talking to their own order screen. The page // prints an input only when fill-from-link is on (so the parameter has // somewhere to land in the post), and never a label, never chrome. if ( 'hidden' === $type ) { if ( ! empty( $def['url_fill'] ) ) { $bb_seed = (string) ( $value ?? '' ); if ( '' === $bb_seed && isset( $_GET[ (string) ( $def['key'] ?? '' ) ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $bb_seed = mb_substr( trim( sanitize_text_field( wp_unslash( (string) $_GET[ (string) ( $def['key'] ?? '' ) ] ) ) ), 0, 200 ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } printf( '', esc_attr( $name ), esc_attr( $bb_seed ) ); } 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'; } // The merchant's own sentence, wired to the control with aria-describedby so // a screen reader hears it AS PART OF the field rather than as loose text // somewhere after it. Of every comparable plugin read for this, Gravity Forms // is the only one that bothers, and it is the whole difference between help // text and decoration. A table cell has no label and no room, so it says // nothing there: the column header is doing that job. $bb_desc = $cell ? '' : trim( (string) ( $def['description'] ?? '' ) ); $bb_desc_id = $id . '-desc'; if ( '' !== $bb_desc ) { $attr_req['aria-describedby'] = $bb_desc_id; } 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': $bb_nlo = isset( $def['num_min'] ) ? max( 0.0, (float) $def['num_min'] ) : 0.0; $bb_nhi = isset( $def['num_max'] ) ? (float) $def['num_max'] : null; printf( ''; // The fence is said up front, never discovered by refusal. if ( $bb_nlo > 0 || null !== $bb_nhi ) { $bb_nhint = null !== $bb_nhi ? ( $bb_nlo > 0 /* translators: 1: the lowest allowed number, 2: the highest allowed number */ ? sprintf( __( 'Between %1$s and %2$s', 'addon-boss' ), (string) ( $bb_nlo + 0 ), (string) ( $bb_nhi + 0 ) ) /* translators: %s: the highest allowed number */ : sprintf( __( 'Up to %s', 'addon-boss' ), (string) ( $bb_nhi + 0 ) ) ) /* translators: %s: the lowest allowed number */ : sprintf( __( 'At least %s', 'addon-boss' ), (string) ( $bb_nlo + 0 ) ); echo '' . esc_html( $bb_nhint ) . ''; } break; case 'date': case 'time': // A module may take over a type this switch already draws: the paid // plan turns the plain date box into a priced one. Its registered // markup wins here, exactly as it does in the default arm below. // Without this the case would draw the plain box and the module's // input would never reach a storefront, while every direct call of // it in a test passed. if ( is_callable( $types[ $type ]['render'] ?? null ) ) { call_user_func( $types[ $type ]['render'], $def, $name, $value, $id, $attr_req ); break; } printf( ''; break; case 'select': printf( ''; break; case 'email': printf( ''; // The nudge lands here: one quiet line, filled by the typo check, never // blocking anything. Empty = absent to every reader. printf( '', esc_attr( $id ) ); break; case 'url': // type="text" on purpose: the strict native url check rejects // "mysite.com", which is how people actually type their site. printf( ''; break; case 'color': $bb_val = (string) ( $value ?? '' ); $bb_is_cus = 0 === strpos( $bb_val, 'custom:' ); // data-pmode names the voice these swatches speak, so a shopper // flipping the tax toggle repaints them in it. Without the host the // repaint reads whichever priced control rendered first on the page. printf( '
', esc_attr( $id ), esc_attr( $label ) ); foreach ( (array) ( $def['options'] ?? array() ) as $opt ) { $ofee = (float) ( $opt['fee'] ?? 0 ); printf( '', esc_attr( $name ), esc_attr( $opt['value'] ), checked( $bb_val, $opt['value'], false ), esc_attr( (string) $ofee ), $req ? ' required' : '', esc_attr( $opt['color'] ?? '#cccccc' ), esc_html( $opt['label'] ?? $opt['value'] ), // The figure sits in its own span so the repaint can rewrite it // without eating the brackets around it. $ofee > 0 ? ' (' . esc_html( addon_boss_money( $ofee, $product, array( 'plain' => true, 'sign' => true ) ) ) . ')' : '', esc_attr( (string) addon_boss_fee_shown( $ofee, $product ) ) ); } if ( ! empty( $def['allow_custom'] ) ) { $bb_cfee = (float) ( $def['custom_fee'] ?? 0 ); $bb_hex = $bb_is_cus ? addon_boss_normalise_hex( substr( $bb_val, 7 ) ) : ''; // The whole custom row waits for the script that makes it work: // without it the presets stand alone, complete, rather than a // dead swatch beside a picker nothing reads. printf( '', esc_attr( (string) $bb_cfee ), esc_attr( $name ), esc_attr( $bb_is_cus ? $bb_val : 'custom:' ), checked( $bb_is_cus, true, false ), esc_html__( 'Custom', 'addon-boss' ), $bb_cfee > 0 ? ' (' . esc_html( addon_boss_money( $bb_cfee, $product, array( 'plain' => true, 'sign' => true ) ) ) . ')' : '', $bb_is_cus ? '' : ' hidden', esc_attr( '' !== $bb_hex ? $bb_hex : '#1a2b3c' ), esc_attr__( 'Pick a colour', 'addon-boss' ), esc_attr( $bb_hex ), esc_attr__( 'Colour code', 'addon-boss' ), esc_attr( (string) addon_boss_fee_shown( $bb_cfee, $product ) ) ); } echo '
'; break; case 'price': $bb_cur = function_exists( 'get_woocommerce_currency_symbol' ) ? get_woocommerce_currency_symbol() : ''; // THE BOX TAKES THE FIGURE THE SHOPPER IS READING. // // Every other price on the page converts to the shop's display basis, // and for a while this field alone did not: it took the ENTRY basis and // a line under it apologised ("Type the amount without VAT"). On a shop // showing inc-VAT prices, typing 5 charged 6. His call, 7 Sept: the box // speaks the shopper's basis, the apology goes, and the server converts // what was typed back to the entry basis for the charge. // // So every figure on this field is DISPLAY basis: the chips, the // bounds, the refusal sentences, and the number the box takes. // data-pmode="whole" hands the lot to the tax toggle's repaint, and the // -ex/-in attribute pairs carry both bases so the client can follow a // live flip without re-deriving any tax maths of its own. $bb_ep = $product instanceof WC_Product ? $product : null; $bb_dec = function_exists( 'wc_get_price_decimals' ) ? wc_get_price_decimals() : 2; $bb_show = static function ( float $bb_v ) use ( $bb_ep ): float { return $bb_ep ? addon_boss_fee_shown( $bb_v, $bb_ep ) : $bb_v; }; $bb_ex = static function ( float $bb_v ) use ( $bb_ep ): float { return $bb_ep && function_exists( 'wc_get_price_excluding_tax' ) ? (float) wc_get_price_excluding_tax( $bb_ep, array( 'price' => $bb_v, 'qty' => 1 ) ) : $bb_v; }; $bb_in = static function ( float $bb_v ) use ( $bb_ep ): float { return $bb_ep && function_exists( 'wc_get_price_including_tax' ) ? (float) wc_get_price_including_tax( $bb_ep, array( 'price' => $bb_v, 'qty' => 1 ) ) : $bb_v; }; echo '
'; $bb_chips = (array) ( $def['price_amounts'] ?? array() ); if ( $bb_chips ) { // Buttons that only work by script stay hidden until the script is // there; the typed box below is the whole feature without it. // A chip shows the display-basis figure, and PRESSING it writes that // same figure into the box: one number for one amount, in the basis // the shopper is reading. Both bases ride along so a live toggle // flip can keep the promise without any client-side tax maths. echo ''; } // A BOUND ONLY THE TILL KNOWS ABOUT IS NOT A BOUND. // // Minimum and maximum were printed under the box as words and enforced on // the server, with nothing in between. The box took any figure without // complaint, the running total quoted it, and the refusal arrived only at // Add to basket. (His find, 7 Sept.) // // The box carries its bounds in the display basis the shopper reads, in // both bases for a live toggle flip, plus the sentence as a template the // client fills with the current-basis figure. The prefilled -say twin is // the fallback for a page with no money formatter to hand. The server // stays the enforcer; this only moves the news to the field. $bb_bounds = ''; if ( isset( $def['price_min'] ) ) { $bb_bounds .= sprintf( ' data-bb-pmin="%s" data-bb-pmin-ex="%s" data-bb-pmin-in="%s" data-bb-pmin-say="%s"', esc_attr( (string) $bb_show( (float) $def['price_min'] ) ), esc_attr( (string) $bb_ex( (float) $def['price_min'] ) ), esc_attr( (string) $bb_in( (float) $def['price_min'] ) ), esc_attr( sprintf( // AT THE FIELD, SAY THE RULE. The till's sentence names the field, // because a cart notice arrives with no context. Here the label sits // an inch above, so repeating it reads as a stutter. (His look, 7 Sept.) /* translators: %s: the smallest amount accepted */ __( 'Enter at least %s.', 'addon-boss' ), // aob-money-raw: the bound in the basis the shopper reads, // exactly as the server compares it. addon_boss_plain_price( $bb_show( (float) $def['price_min'] ) ) ) ) ); } if ( isset( $def['price_max'] ) ) { $bb_bounds .= sprintf( ' data-bb-pmax="%s" data-bb-pmax-ex="%s" data-bb-pmax-in="%s" data-bb-pmax-say="%s"', esc_attr( (string) $bb_show( (float) $def['price_max'] ) ), esc_attr( (string) $bb_ex( (float) $def['price_max'] ) ), esc_attr( (string) $bb_in( (float) $def['price_max'] ) ), esc_attr( sprintf( /* translators: %s: the largest amount accepted */ __( 'Enter at most %s.', 'addon-boss' ), // aob-money-raw: the upper twin of price_min, same reason. addon_boss_plain_price( $bb_show( (float) $def['price_max'] ) ) ) ) ); } if ( isset( $def['price_min'] ) || isset( $def['price_max'] ) ) { /* translators: %s: the smallest amount accepted */ $bb_bounds .= ' data-bb-say-min="' . esc_attr__( 'Enter at least %s.', 'addon-boss' ) . '"'; /* translators: %s: the largest amount accepted */ $bb_bounds .= ' data-bb-say-max="' . esc_attr__( 'Enter at most %s.', 'addon-boss' ) . '"'; } printf( ''; // WHICH BASIS THE SHOPPER WAS READING travels with the form. A tax // toggle keeps its state in the browser, so the server cannot know // which side the page showed when the amount was typed; this field // says so, and the script updates it on every flip. Declaring it is // money-safe: bounds and charge convert together, so whatever basis // is claimed, the shopper pays the figure they saw against the bounds // they saw. printf( '', esc_attr( 'incl' === get_option( 'woocommerce_tax_display_shop', 'excl' ) ? 'incl' : 'excl' ) ); $bb_pmin = isset( $def['price_min'] ) ? (float) $def['price_min'] : null; $bb_psug = isset( $def['price_suggested'] ) ? (float) $def['price_suggested'] : null; // The bounds are always said, never a guessing game after a refusal. if ( null !== $bb_pmin || null !== $bb_psug ) { // The ladder reads in order: minimum, suggestion, maximum. All three // describe the box below them in the basis the shopper reads, and // each figure carries data-delta so the toggle repaint moves it with // every other price on the page. $bb_bit = static function ( string $bb_tpl, float $bb_v ) use ( $bb_show ): string { return sprintf( $bb_tpl, '' . esc_html( addon_boss_plain_price( $bb_show( $bb_v ) ) ) . '' // aob-money-raw: the bound in the shopper's display basis, converted by bb_show above. ); }; $bb_bits = array(); if ( null !== $bb_pmin ) { /* translators: %s: a money amount */ $bb_bits[] = $bb_bit( __( 'Minimum %s', 'addon-boss' ), $bb_pmin ); // aob-money-raw: display basis, converted above. } if ( null !== $bb_psug ) { /* translators: %s: a money amount */ $bb_bits[] = $bb_bit( __( 'Suggested: %s', 'addon-boss' ), $bb_psug ); // aob-money-raw: display basis, converted above. } $bb_pmax = (float) ( $def['price_max'] ?? 0 ); if ( $bb_pmax > 0 ) { /* translators: %s: a money amount */ $bb_bits[] = $bb_bit( __( 'Maximum %s', 'addon-boss' ), $bb_pmax ); // aob-money-raw: display basis, converted above. } echo '' . wp_kses( implode( ' · ', $bb_bits ), array( 'span' => array( 'data-delta' => true ) ) ) . ''; } // THE COMPLAINT SITS BESIDE THE RULE IT BROKE. // // The group's refusal line renders at the TOP of the card, which is right // for a missing choice: the whole card is the offender. A bounded amount // is different: its rule is printed at the bottom, under the box, so the // complaint lands there too, and the group's own line stays for // everything else. (His look, 7 Sept.) if ( isset( $def['price_min'] ) || isset( $def['price_max'] ) ) { echo ''; } echo '
'; 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 && ! in_array( $type, array( 'select', 'color' ), true ) ) { // A dropdown and a colour price by the chosen option; a stored field fee // from before the sanitiser zeroed them must not be promised here either. // 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( addon_boss_money( $fee, $product, array( 'sign' => true ) ) ) . esc_html( $suffix ) . ''; } if ( '' !== $bb_desc ) { // The same slot and the same dress the engine's OWN computed lines use (a // number's fence, a date's earliest day), so the two read as one voice // under the field. Never merged with them, though, and never seeded from // them: what the engine works out stays the engine's and is always true of // the settings; what the merchant typed stays theirs and is never written // for them. Gravity Forms settled this exact question the same way, by // rendering its generated file rules as their own line wearing the // description class beside the merchant's text. printf( '%s', esc_attr( $bb_desc_id ), esc_html( $bb_desc ) ); } 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 one reader for "which side is the VAT toggle showing", as a script * fragment every surface that paints money embeds. * * Asked of the TOGGLE'S OWN MEMORY rather than of the page, because reading the * page is a race and on a reload we lose it: the plugin restores the shopper's * choice in its own script, and whichever of us paints first wins. That is the * whole of the reload defect - the switch reads Incl VAT, our figures read Ex, * and the two agree again the moment somebody toggles, because a toggle fires * the event we already listen for. Nothing was ever wrong with the arithmetic. * * What the plugin stores is "switched away from the shop's own basis", so that * flag and the basis together answer the question with no timing in it at all. * With nothing stored (nobody has toggled in this browser) the page is showing * the shop's own basis, and the DOM agrees, so the DOM is read instead. * * One copy, emitted three times, so the two cannot drift apart. */ function addon_boss_wts_reader_js(): string { return <<<'JS' function bbWtsIncl( basis ) { var raw = null; try { raw = window.localStorage.getItem( "wdevs_tax_switch_is_switched" ); } catch ( e ) { raw = null; // a browser refusing storage: fall through to the page. } if ( null !== raw ) { var switched = "true" === String( raw ).trim(); return ( "incl" === basis && ! switched ) || ( "excl" === basis && switched ); } var active = document.querySelector( ".wts-price-wrapper .wts-active" ); if ( active ) { return active.classList.contains( "wts-price-incl" ); } return "incl" === basis; } JS; } /** * 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' ) ) ); // The shop's OWN display basis rides along, because the toggle plugin // remembers "switched away from it" rather than "showing tax": the two // together are the only way to answer which side is on without waiting to // see what the plugin does to the page. return ' data-wts="1" data-wts-rate="' . esc_attr( (string) $rate ) . '"' . ' data-tax-basis="' . esc_attr( get_option( 'woocommerce_tax_display_shop', 'excl' ) ) . '"'; } // ----------------------------------------------------------------- // 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.', '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'] ?? '' ) ), // What the PRODUCT PAGE calls this set, when that should differ from // the library name. A feed files sets under searchable names ("GD56 - // ... Colour", the style code leading so a decorator can find them at // two hundred products), and that name worn as the shopper's group // heading read as a part number. Empty means the name serves both, // which is every hand-made set today. 'label' => sanitize_text_field( (string) ( $raw['label'] ?? '' ) ), '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'] ?? '' ) ), // Where the set came from: a supplier integration writes its own name // here when it creates the set. Empty means the merchant built it, and // empty is deliberately never given a word of its own on the page: // absence is the marking, and only the supplier's rows wear a badge. 'source' => sanitize_text_field( (string) ( $raw['source'] ?? '' ) ), // Whether a picked choice's photo also becomes the product's main // image. Absent means yes, so every set that existed before this // switch keeps doing exactly what it did. 'photo_swaps' => ! isset( $raw['photo_swaps'] ) || ! empty( $raw['photo_swaps'] ), // 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(); } } // Modules may carry their own per-set keys through the same sanitise pass, // so a set never stores a key nothing cleaned. return (array) apply_filters( 'addon_boss_sanitize_option_set', $out, $raw ); } // ================================================================= // 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']; } // Where the set came from (a supplier feed writes its name here; a set the // merchant builds carries none). Carried through edits the same way, so a // rename or a reprice never launders a supplier's set into an unmarked one. if ( ! isset( $set['source'] ) && isset( $sets[ $id ]['source'] ) ) { $set['source'] = $sets[ $id ]['source']; } elseif ( isset( $set['source'] ) ) { $set['source'] = sanitize_text_field( (string) $set['source'] ); if ( '' === $set['source'] ) { unset( $set['source'] ); } } // 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 for 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 ); } /** * The words a SHOPPER sees for a set: its display label, else its name. * * One function because the words surface in five places (the group heading, * the pick snapshot the cart and order print, the virtual attribute label, * the required and bounds messages), and five copies of "label or name" * would drift. */ function addon_boss_set_shopper_name( array $set ): string { $bb_label = (string) ( $set['label'] ?? '' ); return '' !== $bb_label ? $bb_label : (string) ( $set['name'] ?? '' ); } 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; } // The measured rate cascades exactly like the choice photos and deltas: // the set carries the default, and a rate on this product's attachment // row wins here alone. Overlaid at the resolver, the one seam every // surface reads, so the page, the JS payload, the validation and the // cart all price from the same number without the measured module // knowing the feature exists. $bb_measure = (array) ( $set['measure'] ?? array() ); if ( isset( $row['rate'] ) && '' !== $row['rate'] && 'measured' === (string) ( $set['kind'] ?? 'choices' ) ) { $bb_measure['rate'] = (float) $row['rate']; } // Modules ride the same seam (the one list every surface reads), with // the stored set and the product's own row both in hand for cascades. $resolved[] = (array) apply_filters( 'addon_boss_resolved_set_row', array( 'id' => $row['set'], 'name' => $set['name'], // The shopper-facing heading, when it differs from the library // name. Carried or it dies here (see the whitelist note below). 'label' => (string) ( $set['label'] ?? '' ), 'required' => ! empty( $row['required'] ), 'when' => is_array( $row['when'] ?? null ) ? $row['when'] : array(), 'kind' => (string) ( $set['kind'] ?? 'choices' ), 'measure' => $bb_measure, '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'] ), // 🔴 This list is a WHITELIST, not a passthrough. A flag added to the // set editor and the sanitiser but not to this line is saved, shown // ticked in the admin, and then silently absent everywhere the // storefront reads it. Same downgrade-safe reading as show_stock: // absent means on, only an explicit false stops the swap. 'photo_swaps' => ! isset( $set['photo_swaps'] ) || ! empty( $set['photo_swaps'] ), 'multi' => ! empty( $set['multi'] ), 'min' => max( 0, (int) ( $set['min'] ?? 0 ) ), 'max' => max( 0, (int) ( $set['max'] ?? 0 ) ), 'options' => $options, ), $set, $row ); } 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' => addon_boss_set_shopper_name( $resolved_set ), '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; } /** * The settings each scheduling field type owns, so a build without the module * that draws them carries them through a save instead of wiping them. * * @var array> */ const ADDON_BOSS_SCHEDULING_KEYS = array( 'date' => array( 'prep_days', 'book_ahead', 'cutoff', 'closed_days', 'closed_dates', 'date_face', 'date_bands' ), 'time' => array( 'time_open', 'time_close', 'time_step', 'time_face', 'time_bands' ), ); /** * 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; } } // A measured set's per-product rate. Empty = no override, the set's own // rate stands (the same absence rule as the deltas above); an explicit 0 // is a real value, still floored by the set's minimum charge. Junk and // negatives are NOT overrides: wc_format_decimal turns word salad into '', // and a negative rate would be a negative price path, so both fall back to // the set. The '' state is stored, not omitted, so a saved row always // carries the key and true absence keeps meaning "row from before this". $bb_rate = trim( (string) ( $row['rate'] ?? '' ) ); if ( '' !== $bb_rate ) { $bb_fmt = wc_format_decimal( $bb_rate ); $bb_rate = ( '' !== $bb_fmt && is_numeric( $bb_fmt ) && (float) $bb_fmt >= 0 ) ? (float) $bb_fmt : ''; } // Modules carry their own per-row keys through the same pass, so a row // never stores a key nothing cleaned. return (array) apply_filters( 'addon_boss_sanitize_set_row', array( 'set' => (string) $row['set'], 'required' => ! empty( $row['required'] ), 'deltas' => $deltas, 'images' => $images, 'hide' => $hide, 'rate' => $bb_rate, // 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(), ), $row ); } /** * 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 && '' !== addon_boss_set_shopper_name( $set ) ) { return addon_boss_set_shopper_name( $set ); } } 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; } // A bundle is never griddable, whatever meta it carries. It has picks, not // variations, and it prices itself from what its own configurator collects. // The grid takes the page over, so laid on a bundle it removes the // configurator: no picks, nothing to price, and the shopper gets a £0.00 // product with a dead Add to cart (BundleBoss #31). // // The meta reaches a bundle by outliving its product. Turn the grid on for a // variable product or an optioned simple, change that product into a bundle // later, and '_bundle_boss_order_layout' stays behind; BundleBoss's own // exporter already calls that meta "never on a bundle". Without this line the // checks below answer "has it a griddable set?", and one leftover option set // is enough to say yes. if ( $product->is_type( 'bundle' ) ) { 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 ) ); } /** * An option delta carried onto the DISPLAY basis, sign intact. * * wc_get_price_to_display() floors any passed price at zero, so a * cheaper-choice delta (a plain garment at -3.00 on a printed product) * silently became 0 in every figure built from it: the page total held at * the base price while the cart correctly charged less, and the till and * the page disagreed. The conversion runs on the magnitude and the sign * rides around it, so a reduction converts exactly as a surcharge does. */ function addon_boss_delta_to_display( $product, float $delta ): float { if ( 0.0 === $delta ) { return 0.0; } $bb_disp = (float) wc_get_price_to_display( $product, array( 'price' => abs( $delta ), 'qty' => 1 ) ); return $delta < 0 ? -$bb_disp : $bb_disp; } 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': case 'color': $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 left blank', '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 stored reveal rule read into the one canonical shape: act + groups of * conditions. Conditions inside a group must all hold; any one group holding * fires the rule. Pure data traversal with bounds — nothing here evaluates, * repairs or judges; that is the reveal module's job. It exists in the engine * so the hidden carriers and the save reader can round-trip every stored shape * even in a build with no reveal module at all. * * Shapes read: legacy {set,is}; flat {match,conditions}, where match 'any' * becomes one group per condition (OR of singles is exactly what any meant); * and {groups:[...]} as itself. Bounded so a hand-crafted post cannot balloon: * six groups of eight conditions, twenty values in a list. * * @param mixed $when The stored rule, any shape. * @return array{act:string,groups:array>} */ function addon_boss_when_to_groups( $when ): array { $when = is_array( $when ) ? $when : array(); $act = ( 'hide' === (string) ( $when['act'] ?? '' ) ) ? 'hide' : 'show'; $bb_ops = array( 'is', 'is_not', 'is_any', 'filled', 'empty', 'more', 'less', 'exact' ); $bb_cond = static function ( $c ) use ( $bb_ops ) { if ( ! is_array( $c ) ) { return null; } $bb_set = sanitize_text_field( (string) ( $c['set'] ?? '' ) ); $bb_field = '' === $bb_set ? sanitize_text_field( (string) ( $c['field'] ?? '' ) ) : ''; if ( '' === $bb_set && '' === $bb_field ) { return null; } $op = in_array( $c['op'] ?? 'is', $bb_ops, true ) ? (string) $c['op'] : 'is'; $bb_val = $c['value'] ?? ''; if ( is_array( $bb_val ) ) { $bb_list = array(); foreach ( $bb_val as $bb_v ) { if ( is_scalar( $bb_v ) && count( $bb_list ) < 20 ) { $bb_list[] = sanitize_text_field( (string) $bb_v ); } } $bb_val = $bb_list; } else { $bb_val = is_scalar( $bb_val ) ? sanitize_text_field( (string) $bb_val ) : ''; } return array( 'set' => $bb_set, 'field' => $bb_field, 'op' => $op, 'value' => $bb_val ); }; $groups = array(); if ( isset( $when['groups'] ) && is_array( $when['groups'] ) ) { foreach ( $when['groups'] as $bb_g ) { if ( count( $groups ) >= 6 ) { break; } $bb_conds = array(); foreach ( (array) ( is_array( $bb_g ) && isset( $bb_g['conditions'] ) ? $bb_g['conditions'] : $bb_g ) as $c ) { if ( count( $bb_conds ) >= 8 ) { break; } $bb_c = $bb_cond( $c ); if ( null !== $bb_c ) { $bb_conds[] = $bb_c; } } if ( $bb_conds ) { $groups[] = $bb_conds; } } } elseif ( isset( $when['conditions'] ) && is_array( $when['conditions'] ) ) { $bb_conds = array(); foreach ( $when['conditions'] as $c ) { if ( count( $bb_conds ) >= 8 ) { break; } $bb_c = $bb_cond( $c ); if ( null !== $bb_c ) { $bb_conds[] = $bb_c; } } if ( $bb_conds ) { if ( 'any' === ( $when['match'] ?? 'all' ) ) { foreach ( $bb_conds as $bb_c ) { $groups[] = array( $bb_c ); } } else { $groups[] = $bb_conds; } } } elseif ( isset( $when['set'] ) && array_key_exists( 'is', $when ) && '' !== (string) $when['set'] ) { $groups[] = array( array( 'set' => sanitize_text_field( (string) $when['set'] ), 'field' => '', 'op' => 'is', 'value' => sanitize_text_field( (string) $when['is'] ), ), ); } return array( 'act' => $act, 'groups' => $groups ); } /** * The storage shape for a canonical act+groups rule: the SMALLEST shape that * says it. One plain show "is" on a set stores in the legacy {set,is}, so an * ordinary single rule is byte-identical on disk to one saved years ago and * every older reader keeps reading it. One group stores flat; only a rule that * genuinely says "or when" stores groups. Hide rides an 'act' prefix. * * @param array $norm The {act,groups} shape (see addon_boss_when_to_groups()). * @return array The array to store on the row; empty for no rule. */ function addon_boss_when_store_shape( array $norm ): array { $act = ( 'hide' === (string) ( $norm['act'] ?? '' ) ) ? 'hide' : 'show'; $groups = array_values( array_filter( array_map( 'array_values', (array) ( $norm['groups'] ?? array() ) ) ) ); if ( ! $groups ) { return array(); } if ( 1 === count( $groups ) && 1 === count( $groups[0] ) && 'show' === $act && 'is' === (string) ( $groups[0][0]['op'] ?? '' ) && '' === (string) ( $groups[0][0]['field'] ?? '' ) && ! is_array( $groups[0][0]['value'] ) ) { return array( 'set' => (string) $groups[0][0]['set'], 'is' => (string) $groups[0][0]['value'], ); } if ( 1 === count( $groups ) ) { $when = array( 'match' => 'all', 'conditions' => $groups[0], ); return 'hide' === $act ? array( 'act' => 'hide' ) + $when : $when; } $when = array( 'groups' => $groups ); return 'hide' === $act ? array( 'act' => 'hide' ) + $when : $when; } /** * The operators ONE trigger offers, worded so the rule reads as a sentence a * person would say out loud — and no more than four of them, because eleven * operators on screen at once is how the rivals' builders got their support * queues. A single-pick set speaks is / is not / is any of / is answered; a * several-of set speaks includes / does not include; a text box is filled in * or left blank; a number compares. The stored operator vocabulary is wider * than any one trigger's menu, which is the point: the menu fits the trigger. * * @param string $kind 'set' or 'field'. * @param string $type The field's type when kind is 'field'. * @param bool $multi Whether a set trigger takes several answers. * @param bool $has_options Whether a set trigger has a pickable option list. * @return array Operator => the word it prints as, in menu order. */ function addon_boss_trigger_op_choices( string $kind, string $type = '', bool $multi = false, bool $has_options = true ): array { if ( 'set' === $kind ) { if ( ! $has_options ) { // A set priced off its own panel (a size grid, a measured size) has // no option list to compare against, but "answered at all" is still // a fact about it worth ruling on. return array( 'filled' => __( 'is answered', 'addon-boss' ) ); } if ( $multi ) { return array( 'is' => __( 'includes', 'addon-boss' ), 'is_not' => __( 'does not include', 'addon-boss' ), ); } return array( 'is' => __( 'is', 'addon-boss' ), 'is_not' => __( 'is not', 'addon-boss' ), 'is_any' => __( 'is any of', 'addon-boss' ), 'filled' => __( 'is answered', 'addon-boss' ), ); } if ( 'number' === $type ) { return array( 'more' => __( 'is more than', 'addon-boss' ), 'less' => __( 'is less than', 'addon-boss' ), 'exact' => __( 'is exactly', 'addon-boss' ), ); } $bb_types = function_exists( 'addon_boss_personalisation_field_types' ) ? addon_boss_personalisation_field_types() : array(); $bb_words = addon_boss_field_op_words( $type ); if ( ! empty( $bb_types[ $type ]['has_options'] ) || ! empty( $bb_types[ $type ]['color_options'] ) ) { // A dropdown field and a colour field are picked from a list, so they // speak the same four words a single-pick set does — worded by type. return array( 'is' => __( 'is', 'addon-boss' ), 'is_not' => __( 'is not', 'addon-boss' ), 'is_any' => __( 'is any of', 'addon-boss' ), 'filled' => (string) $bb_words['filled'], ); } return array( 'filled' => (string) $bb_words['filled'], 'empty' => (string) $bb_words['empty'], ); } /** * 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 ) : ''; } /** * A colour field's answer as a RULE sees it: any custom pick answers plainly * as "custom", whatever hex it carries. A rule can offer the merchant's own * swatches and the one word Custom; it can never name a specific free-typed * hex, because "when they pick #37b24d" is not a rule anyone can keep true. * * Registered on the colour type as its rule_answer callback — the engine's * generic seam, so the rule state never knows what a colour is. * * @param string $answer The posted answer. * @return string */ function addon_boss_colour_rule_answer( string $answer ): string { return 0 === strpos( $answer, 'custom:' ) ? 'custom' : $answer; } /** * 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 { $bb_types = addon_boss_personalisation_field_types(); 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 ); $bb_ans = addon_boss_field_rule_value( is_array( $posted ) ? ( $posted[ $bb_key ] ?? null ) : null ); // A type may restate its answer in rule terms (a custom colour answers // as the one word "custom"), through its own registered callback. $bb_map = $bb_types[ (string) ( $bb_def['type'] ?? '' ) ]['rule_answer'] ?? null; if ( '' !== $bb_ans && $bb_map && is_callable( $bb_map ) ) { $bb_ans = (string) call_user_func( $bb_map, $bb_ans ); } $picks[ $bb_id ] = $bb_ans; $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 = addon_boss_set_shopper_name( $set ); $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 ) ); } // One veto seam for anything reasoning about the picks TOGETHER, in the // same chokepoint the bounds hold, so a refusal is a refusal on the page // and at the till alike. Null objects to nothing; a WP_Error is the // shopper's own sentence. $bb_stop = apply_filters( 'addon_boss_multi_pick_stop', null, $set, $keys ); if ( is_wp_error( $bb_stop ) ) { return $bb_stop; } $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 ) ) { // A module can adjust a panel-priced snapshot off the OTHER // answers on the page (a table's named uplift when Two colours // is picked). It runs HERE because this is the one place the // picks and the activity map stand beside the freshly priced // snapshot; with nothing hooked it passes byte-identical. $bb_snap = apply_filters( 'addon_boss_kind_snapshot_adjust', $bb_snap, $set, $picks, $active, $source ); if ( is_wp_error( $bb_snap ) ) { return $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' ), addon_boss_set_shopper_name( $set ) ) ); } 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' ), addon_boss_set_shopper_name( $set ) ) ); } $snapshots[] = $snap; } /** * The resolved snapshots, before anything charges off them. A module may * re-cut deltas that belong TOGETHER (per-combination pricing charges a * colour and size pair as one exact cell); with no subscriber they pass * through untouched. Filtered HERE, at the one resolve boundary, so the * add-to-cart check, the cart line, the order rows and the emails can * never disagree about what a pair costs. * * @param array $snapshots * @param WC_Product $product */ return apply_filters( 'addon_boss_standalone_option_snapshots_resolved', $snapshots, $product ); } /** * 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. // Plain text for the same reason the field suffix is: meta renders where // our CSS never follows, and wc_price invites dual-basis tax markup. if ( 'set' === ( $snap['table_basis'] ?? '' ) ) { return ' (' . addon_boss_money( $shown, null, array( 'plain' => true, 'convert' => false ) ) . ')'; } return ' (' . ( $fee > 0 ? '+' : '-' ) . addon_boss_money( $shown, null, array( 'plain' => true, 'convert' => false ) ) . ')'; } /** * 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 ), $product ); if ( is_wp_error( $resolved ) ) { // Typed but wrong is its own state: the message says what to fix. // Dropping it as if empty would lose the answer, or the money. return $resolved; } 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 ), $product ); if ( is_wp_error( $resolved ) ) { return $resolved; // typed but wrong: the message says what to fix. } 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 */ __( '%1$s needs at least %2$s files. You have added %3$s.', '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' ), ); // A colour's code rides beside its words so the admin order can paint a // real chip; a hidden field's row is marked for the admin's eyes so no // customer surface ever prints it. Absent keys stay absent, keeping every // other field's snapshot byte-identical to the one it has always written. if ( '' !== (string) ( $resolved['hex'] ?? '' ) ) { $snap['hex'] = (string) $resolved['hex']; } if ( '' !== (string) ( $resolved['display_cart'] ?? '' ) ) { // A type's shorter cart-only form rides beside the full display. $snap['display_cart'] = (string) $resolved['display_cart']; } if ( ! empty( $resolved['admin_only'] ) ) { $snap['admin_only'] = true; } // 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 ''; } // Plain text, never wc_price markup: this suffix rides in cart meta and // order rows, which render wherever our CSS does not follow (the block // cart, the emails), and a dual-basis tax plugin filtering wc_price // prints both of its figures mashed together there. $shown = addon_boss_money( $fee, $product, array( 'plain' => true, 'context' => $context ) ); // NEVER THE SAME FIGURE TWICE ON ONE LINE. Where a shopper names the amount // themselves, the answer already IS the charge, and the cart read // "Add a tip for the maker: GBP 5.00 (+GBP 5.00)". Said once is the whole // truth. They part company only where the answer was typed in one basis and // the cart is read in another, and then the pair is worth printing: GBP 5.00 // named, GBP 6.00 to pay. if ( isset( $fsnap['display'] ) && (string) $fsnap['display'] === $shown ) { return ''; } return ' (' . addon_boss_money( $fee, $product, array( 'plain' => true, 'sign' => true, 'context' => $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 ); // A once-for-the-line adjustment (a rush charge on a priced grid) // is a whole-line amount whatever the quantity, exactly like a // 'once' field fee, so it spreads the same way and WooCommerce's // per-unit times quantity lands back on the one charge. $spread_fees += (float) ( $snap['adj_once'] ?? 0 ) / $qty; continue; } $unit_fees += (float) ( $snap['delta'] ?? 0 ); $spread_fees += (float) ( $snap['adj_once'] ?? 0 ) / $qty; } // 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 -------------------------------------------- /** * Give every cart meta row the 'key' the Store API reads. The classic cart * backfills it from 'name' (wc_get_formatted_cart_item_data); the Store API * does not, so on a block-theme cart each row printed "undefined" where its * label belongs. Applied at every exit of the item-data filter below. * * @param array $data The filter's rows so far. * @return array The same rows, each with a key. */ function addon_boss_cart_rows_keyed( array $data ): array { foreach ( $data as &$bb_row ) { if ( is_array( $bb_row ) && ! isset( $bb_row['key'] ) && isset( $bb_row['name'] ) ) { $bb_row['key'] = $bb_row['name']; } } unset( $bb_row ); return $data; } 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 ) { if ( ! empty( $bb_snap['admin_only'] ) ) { continue; // the merchant talking to their own order screen, not a cart row. } $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 ); $bb_tail = esc_html( addon_boss_standalone_field_style_suffix( $fsnap ) ) . addon_boss_standalone_field_charge_suffix( $fsnap, $product, 'cart' ); $bb_row = array( 'name' => '' !== (string) ( $fsnap['label'] ?? '' ) ? (string) $fsnap['label'] : __( 'Detail', 'addon-boss' ), 'value' => $val . $bb_tail, ); // A type may carry a shorter cart-only form (a date reads // "Mon 8 Sep 2026" here and the store's full format everywhere // else); both carts prefer a row's display over its value. if ( '' !== (string) ( $fsnap['display_cart'] ?? '' ) ) { $bb_row['display'] = esc_html( (string) $fsnap['display_cart'] ) . $bb_tail; } $data[] = $bb_row; } } if ( empty( $cart_item[ ADDON_BOSS_STANDALONE_OPTIONS_KEY ] ) ) { return addon_boss_cart_rows_keyed( $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' ), ); // A priced grid's named adjustments each read as their own row, so // "Second colour +10% (5.80)" is auditable at every hop rather than // vanishing into one grown figure. The module that computed them owns // the wording; without it the stored lines simply do not print. if ( ! empty( $snap['adj_lines'] ) && function_exists( 'addon_boss_table_adj_line_text' ) ) { foreach ( (array) $snap['adj_lines'] as $bb_adj ) { $data[] = array( 'name' => (string) ( $bb_adj['name'] ?? '' ), 'value' => addon_boss_table_adj_line_text( (array) $bb_adj, $product, 'cart' ), ); } } } return addon_boss_cart_rows_keyed( $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; } if ( ! empty( $fsnap['admin_only'] ) ) { // The structured copy above already carries it; a readable row would // print it on the customer's order page and in their emails, which // is exactly what a hidden field promises never to do. The admin // order screen prints these from the structured copy instead. 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 named adjustments reach the order line by name too: the packing // bench and the printer read "Second colour: +10% (5.80)" exactly as // the shopper did in the cart. if ( ! empty( $snap['adj_lines'] ) && function_exists( 'addon_boss_table_adj_line_text' ) ) { foreach ( (array) $snap['adj_lines'] as $bb_adj ) { $item->add_meta_data( (string) ( $bb_adj['name'] ?? '' ), addon_boss_table_adj_line_text( (array) $bb_adj, $product, 'cart' ) ); } } } } // ---- Admin order screen extras --------------------------------- // // Two things only the merchant sees. A hidden field's row (kept off every // customer surface above) prints here, because the order screen is who it was // written for. A colour field's row gains a painted chip beside its words, // because "#9b111e" is a code and a red square is a colour. add_action( 'woocommerce_after_order_itemmeta', 'addon_boss_field_admin_order_extras', 15, 2 ); function addon_boss_field_admin_order_extras( $item_id, $item ): void { if ( ! is_admin() || ! $item instanceof WC_Order_Item_Product ) { return; } $fsnaps = $item->get_meta( ADDON_BOSS_PRODUCT_FIELDS_META, true ); if ( ! is_array( $fsnaps ) || ! $fsnaps ) { return; } $bb_rows = array(); foreach ( $fsnaps as $fsnap ) { if ( ! is_array( $fsnap ) ) { continue; } if ( ! empty( $fsnap['admin_only'] ) && '' !== (string) ( $fsnap['display'] ?? '' ) ) { $bb_rows[] = '' . esc_html( (string) ( $fsnap['label'] ?? '' ) ) . ': ' . esc_html( (string) $fsnap['display'] ) . ' ' . esc_html__( '(not shown to the customer)', 'addon-boss' ) . ''; continue; } if ( '' !== (string) ( $fsnap['hex'] ?? '' ) ) { $bb_rows[] = '' . '' . esc_html( (string) ( $fsnap['label'] ?? '' ) ) . ': ' . esc_html( (string) ( $fsnap['display'] ?? '' ) ) . ''; } } if ( $bb_rows ) { echo '
' . wp_kses_post( implode( '', $bb_rows ) ) . '
'; } } // ================================================================= // 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-bad,#b3261e);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;}' // The colour field: real radios under painted discs, the house tick on the // picked one, the custom hatch folded until the script opens it. . '.bb-addon-colour{display:flex;flex-wrap:wrap;gap:10px 14px;align-items:center;}' // A display rule on a class quietly beats the [hidden] attribute's UA // display:none; these keep "hidden" meaning hidden for every flexed // piece the script reveals. . '.bb-colour-custom[hidden],.bb-colour-pick[hidden],.bb-price-chips[hidden]{display:none;}' . '.bb-colour-sw{display:inline-flex;align-items:center;gap:7px;cursor:pointer;margin:0;}' . '.bb-colour-sw input{position:absolute;opacity:0;width:1px;height:1px;}' . '.bb-colour-disc{width:26px;height:26px;border-radius:8px;border:2px solid rgba(0,0,0,.12);box-sizing:border-box;display:inline-block;position:relative;}' . '.bb-colour-sw input:checked + .bb-colour-disc{border-color:currentColor;box-shadow:0 0 0 2px #fff inset,0 0 0 1.5px currentColor;}' . '.bb-colour-sw input:focus-visible + .bb-colour-disc{outline:2px solid currentColor;outline-offset:2px;}' . '.bb-colour-name{font-size:0.92em;}' . '.bb-colour-fee{opacity:.75;}' . '.bb-colour-anydisc{background:conic-gradient(#e85c46,#f5d547,#4c9e6e,#3d5a80,#e85c46);}' // The custom cluster takes a line of its OWN, deliberately. It is wide // (chip, words, fee, and the code pill holding its place), so left in // the flow it wrapped as a unit and left a hole in the middle of the // swatch row. Its own line reads as a considered second step, and the // space that follows it is ordinary trailing space nobody sees. . '.bb-colour-custom{display:flex;flex-basis:100%;align-items:center;gap:12px;flex-wrap:nowrap;margin-top:2px;}' // ONE pill, not two boxes: the wrap wears the border, the chip and the // code sit borderless inside it. Typed selectors so a theme's own // input dress (full width, tall boxes) cannot inflate the pair. . '.bb-colour-pick{display:inline-flex;align-items:center;gap:2px;padding:3px 4px;border:1px solid rgba(0,0,0,.18);border-radius:10px;background:#fff;}' . '.bb-colour-pick input[type=color].bb-colour-picker{width:30px;height:30px;min-height:30px;max-width:30px;padding:1px;border:0;border-radius:7px;background:none;box-sizing:border-box;flex:0 0 auto;cursor:pointer;}' . '.bb-colour-pick input[type=text].bb-colour-hex{width:6.4em;max-width:6.4em;min-height:30px;height:30px;padding:0 8px;font-size:15px;border:0;box-shadow:none;outline:none;background:transparent;box-sizing:border-box;flex:0 0 auto;}' . '.bb-colour-pick:focus-within{border-color:currentColor;}' // The pair keeps its place while hidden, so picking Custom reveals it // IN PLACE: nothing below the row moves. . '.bb-colour-custom .bb-colour-pick[hidden]{display:inline-flex;visibility:hidden;}' // Name your price: chips above a currency-prefixed box; chips hidden until the // script that makes them work exists. . '.bb-addon-ownprice .bb-price-chips{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 8px;}' // color:inherit matters: themes paint button text white for their own // filled buttons, and white on our transparent chip is an empty pill. . '.bb-price-chip{border:1.5px solid rgba(0,0,0,.2);border-radius:8px;background:transparent;color:inherit;padding:6px 14px;font:inherit;cursor:pointer;}' . '.bb-price-chip.is-on{border-color:currentColor;box-shadow:inset 0 0 0 1px currentColor;font-weight:600;}' . '.bb-price-entry{display:inline-flex;align-items:center;gap:6px;}' . '.bb-price-symbol{opacity:.7;}' . '.bb-price-input{max-width:9em;font-size:16px;}' . '.bb-addon-ownprice .bb-addon-help{display:block;margin-top:6px;}' . '.bb-addon-mailhint{display:block;font-size:.85em;margin-top:4px;text-decoration:underline;text-underline-offset:2px;cursor:pointer;background:none;border:0;padding:0;font-family:inherit;color:inherit;}' // The combobox a long list upgrades into. 16px input so phones never zoom; // the list scrolls inside itself; unavailable options stay listed, dimmed. // Dressed in the panel's own tokens, not in hardcoded greys. It was a // white box with a hairline and a 8px tick, which beside our own // controls read as a browser default somebody forgot to style, and made // the whole control look like a search box rather than a dropdown. . '.bb-combo{position:relative;display:block;max-width:420px;}' . '.bb-combo-input{width:100%;font-size:16px;box-sizing:border-box;padding-right:40px;cursor:pointer;}' // A real chevron, big enough to read as one, and it turns over when the // list is open so the control says which way it is facing. . '.bb-combo-chev{position:absolute;inset-inline-end:14px;top:50%;width:10px;height:10px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;' . 'transform:translateY(-75%) rotate(45deg);opacity:.55;cursor:pointer;transition:transform .16s ease,opacity .16s ease;pointer-events:auto;}' . '.bb-combo:hover .bb-combo-chev{opacity:.9;}' . '.bb-combo-input[aria-expanded="true"] ~ .bb-combo-chev{transform:translateY(-25%) rotate(225deg);opacity:.9;}' // The list matches the BOX, not the page. Theme body type can be 20px and // more, which turned sixteen slots into a wall you scroll rather than a // list you scan, and made the soft panel disagree with the hard square // control above it. 16px is the input's own size, set so phones never // zoom, and the two now read as one control. . '.bb-combo-list{position:absolute;z-index:30;left:0;right:0;top:calc(100% + 4px);margin:0;padding:4px;list-style:none;font-size:16px;line-height:1.4;' . 'background:var(--aob-surface,#fff);color:var(--aob-text,#1f2328);border:1px solid var(--aob-line,#e6e4df);border-radius:10px;' . 'max-height:17.5em;overflow-y:auto;overscroll-behavior:contain;box-shadow:0 2px 6px rgba(24,26,30,.06),0 18px 40px -12px rgba(24,26,30,.28);}' . '.bb-combo-opt{display:flex;align-items:baseline;gap:8px;padding:7px 10px;border-radius:7px;cursor:pointer;color:inherit;line-height:1.4;}' . '.bb-combo-opt mark{background:none;color:inherit;font-weight:700;text-decoration:underline;text-underline-offset:2px;}' // Keyboard and pointer land on the same state, so arrowing down looks // exactly like hovering. Ink tint, never the accent: coral is for things // you press, not for marking where you are. . '.bb-combo-opt:hover,.bb-combo-opt.is-hl{background:color-mix(in srgb,var(--aob-ink,#16181d) 8%,transparent);}' // What you are on already, marked with a tick rather than a colour, so it // survives a dark theme and a colour-blind eye alike. . '.bb-combo-opt.is-on{font-weight:600;}' . '.bb-combo-opt.is-on::after{content:"\\2713";margin-inline-start:auto;opacity:.75;font-weight:400;}' . '.bb-combo-opt.is-dis{color:var(--aob-mut,#7d8590);cursor:default;}' . '.bb-combo-opt.is-dis:hover{background:none;}' . '.bb-combo-unav{margin-inline-start:auto;font-size:.85em;opacity:.8;}' . '.bb-combo-none{padding:9px 11px;color:var(--aob-mut,#7d8590);}' . '.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 REFUSAL IS NOT THE BRAND COLOUR. // // This block wore --aob-accent, the merchant's own colour, on the theory // that a refusal should match the Choose echo. On a shop whose accent is // green (#0f6b52 on the dev store) that painted every refusal green, which // reads as a tick. Worse, .bb-when-revealed draws the SAME inset edge in // the SAME colour, so a group that had just been refused and a group that // had just appeared were indistinguishable. His find, 7 Sept: "there's no // real indication as to why it's not letting you add to cart". There was // one. It was green, 12.5px, and looked like everything else. // // --aob-bad never derives from the accent. A merchant can set a house red; // they cannot accidentally set the refusal colour to their brand colour by // picking one on the Colours screen. . '.bb-standalone-options .bb-attr-group.bb-gmiss{box-shadow:inset 3px 0 0 var(--aob-bad,#b3261e),0 1px 2px rgba(24,26,30,.04),0 10px 28px rgba(24,26,30,.05);background:color-mix(in srgb,var(--aob-bad,#b3261e) 4%,var(--aob-surface,#fff));}' . '.bb-standalone-options .bb-po-section .bb-attr-group.bb-gmiss{box-shadow:inset 3px 0 0 var(--aob-bad,#b3261e);}' // The words of the block, at the offender: 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:13px;font-weight:700;color:var(--aob-bad,#b3261e);}' . '.bb-standalone-options .bb-gmiss-note[hidden]{display:none;}' . '.bb-standalone-options .bb-gmiss .bb-addon-ownprice .bb-addon-help{color:var(--aob-bad,#b3261e);}' . '.bb-standalone-options .bb-gmiss-here{margin-top:3px;}' . '.bb-st-line.bb-st-bad .bb-st-line-n,.bb-st-line.bb-st-bad .bb-st-free-v{color:var(--aob-bad,#b3261e);font-weight:600;}' . '.bb-st-line.bb-st-bad .bb-st-free-v::before{content:"\A0\B7\A0";}' // AND THE CONTROL HOLDING THE OFFENDING ANSWER SAYS SO ITSELF. // // aria-invalid was set on it and nothing was drawn: assistive tech knew, // and a shopper looking straight at a box holding 500 against a maximum of // 20 saw a box that looked exactly like every other box on the page. . '.bb-standalone-options .bb-gmiss .bb-addon-input[aria-invalid="true"],' . '.bb-standalone-options .bb-gmiss .bb-tbl-sel[aria-invalid="true"],' . '.bb-standalone-options .bb-gmiss .bb-tbl-typedin[aria-invalid="true"],' . '.bb-standalone-options .bb-gmiss select.bundle-attr-select[aria-invalid="true"]' . '{border-color:var(--aob-bad,#b3261e);background:color-mix(in srgb,var(--aob-bad,#b3261e) 5%,var(--aob-surface,#fff));}' . '.bb-standalone-options .bb-gmiss .bb-price-symbol{color:var(--aob-bad,#b3261e);}' . '.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;}' // A chip is a flex row, so the whitespace the markup puts between the // label and this note is dropped entirely: "A4" and "210 × 297 mm" met // with no gap at all, reading as one run-on word. Space between flex // items has to be spacing, never a space character. . '.bb-mdims-note{color:inherit;opacity:.7;font-size:0.82em;font-weight:400;margin-left:.5em;}' . '.bb-mchip .bb-chip-delta{margin-left:.5em;}' . '.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;}' // A free answer is what they CHOSE, not what they are paying for: it // names itself and leaves the money column empty, so the eye still runs // down one clean column of figures. // A charged line wears its answer the same way, so "Collection time: // 9:30 am £4.00" reads as one sentence with the free lines above it. // // Scoped on the class alone, NOT under .bb-standalone-total. The separator // is the only thing standing between the label and the answer, and the // sticky peek bar shows a COPY of these lines outside that container: with // an ancestor in the selector every line in the bar read "Any date, no // rules at allThu, 10 Sept 2026". A rule that draws a word boundary has to // travel with the markup, wherever it is shown. . '.bb-st-free-v{color:var(--aob-mut,#7d8590);}' . '.bb-st-free-v::before{content:":\00a0";}' . '.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);}' // A grid disables its own Add button until the order is complete, and // the bar's stand-in follows it. It has to LOOK unwilling too, or the // shopper taps a button that looks ready and nothing happens. . '.bb-st-peek .bb-peek-cta[disabled]{background:#c4cad4;box-shadow:none;cursor:not-allowed;}' . '.bb-st-peek .bb-peek-cta[disabled]:hover{filter:none;}' ); // 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; } // A field for the admin's eyes only (a hidden merchant note) draws NO card: // an empty white shell on the product page is exactly the shelf-of-nothing // this page must never show. The bare renderer still runs, so a // fill-from-link input can land in the form when that switch is on. $bb_all_types = addon_boss_personalisation_field_types(); if ( ! empty( $bb_all_types[ (string) $field['type'] ]['admin_only'] ) ) { addon_boss_personalisation_render_field( $field, 'bb_pf[' . $field['key'] . ']', null, 'block', $product ); 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() ); // A FIELD THAT CAN BLOCK NEEDS SOMEWHERE TO SAY WHY. // // This note used to belong to required fields alone, because only a required // field could hold up the basket. A name-your-price box with a minimum or a // maximum can hold it up too, optional or not, and without this line it did // so in silence: the field went red and said nothing. Closing a limit // changes three things, and this is the third. if ( '' !== $bb_miss_attr || isset( $field['price_min'] ) || isset( $field['price_max'] ) ) { 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', $product ), 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. } // Whose money this panel is about to print, said once, so a field type whose // render callback has no product in its shape still converts what it shows. // Restored at the end of the panel, so a second product later in the request // (a shop loop, a related-products row) can never inherit this one's basis. $bb_money_was = addon_boss_money_product( $product ); // 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 ) ) ) . '"'; } // Whether the pairs also carry PRICES is the price module's own business // (Pro): it prints its map through the options-extra hook below, in the // same wire shape the counts travel in. 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. // // The set's display label beats its library name here. A supplier // feed names sets to be FOUND ("GD56 - ... Colour", the code leading, // asked 23 Aug); worn as the heading that read as a part number on // the product page (seen 14 Sept, the Printful shop). The shopper's // word and the decorator's word are different jobs, so a set may // carry both. $bb_head_name = addon_boss_set_shopper_name( $set ); 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 ) { // Both halves are already in the display basis, so the seam formats // rather than converts a second time. $bump_html = addon_boss_money( (float) $base_disp + addon_boss_delta_to_display( $product, $delta ), null, array( 'convert' => false ) ); } elseif ( 0.0 !== $delta ) { $bump_html = addon_boss_money( $delta, $product, array( 'sign' => true ) ); } 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' => addon_boss_delta_to_display( $product, $delta ), ); }; // 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. // autocomplete="off" asks the browser not to restore this control on // reload (the chips it mirrors come back clean, so a restored select // is a pick the screen never shows); the boot reconcile is the // backstop for browsers that restore it anyway. 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(); // HELD, not printed here. A receipt is a summary, so it belongs after // everything it summarises. Printed inline it came out at priority 10, // which put it ABOVE the decoration panel that renders at 14: the page // asked a shopper to total up before it had finished asking what they // wanted. Held now and printed on a late hook, so any module adding to // this surface lands above the total instead of below it. ob_start(); echo '
' . '
' . ( '' !== $base_disp ? '
' . esc_html( $bb_rcpt_name ) . '' . wp_kses_post( addon_boss_money( (float) $base_disp, null, array( 'convert' => false ) ) ) . '
' : '' ) . '
' // "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( addon_boss_money( (float) $base_disp, null, array( 'convert' => false ) ) ) : '' ) . '
' . '' . '' . '
'; addon_boss_standalone_receipt_held( (string) ob_get_clean() ); echo ''; addon_boss_money_product( $bb_money_was ); // the panel is done; hand the basis back. } /** * The receipt's markup, held between the options render and the late hook * that prints it. A getter with no argument; pass a string to store one. * * @param string|null $set Markup to hold, or null to read and clear. * @return string The held markup; '' once read. */ function addon_boss_standalone_receipt_held( ?string $set = null ): string { static $held = ''; if ( null !== $set ) { $held = $set; return ''; } $out = $held; $held = ''; return $out; } add_action( 'woocommerce_before_add_to_cart_button', 'addon_boss_print_standalone_receipt', 20 ); /** * Print the held receipt, last on this surface. * * Priority 20 leaves the whole 10-to-19 band for blocks that a shopper is * still ANSWERING: the option sets and fields at 10, the print areas at 14, * and whatever a later module adds. The sum comes after the questions. */ function addon_boss_print_standalone_receipt(): void { $bb_held = addon_boss_standalone_receipt_held(); if ( '' === $bb_held ) { return; } // Its own wrapper, because the options panel has closed by now and the // receipt's styling is scoped to that class. echo '
' . $bb_held . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built and escaped above. } /** * 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, // The hidden select is the form carrier the chips drive, and a browser // restores form controls on reload while the chips' classes come back // clean. Left alone, the restored select posts (and prices) a pick the // screen never showed, and the exclusion rules grey partners of a // choice nobody made. The chips are the control, so the select is made // to agree with them at boot and again after the browser's late // restore; only when a ghost was actually cleared does the select say // change, so the receipt and the rules re-settle. function bbChipReconcile() { document.querySelectorAll( ".bb-standalone-options .bb-chips[data-for-select]" ).forEach( function ( wrap ) { var group = wrap.closest( ".bb-attr-group" ); var sel = group ? group.querySelector( "select[data-attribute=\"" + wrap.dataset.forSelect + "\"]" ) : null; if ( ! sel ) { return; } var keys = []; wrap.querySelectorAll( ".bb-chip.is-selected" ).forEach( function ( c ) { keys.push( c.getAttribute( "data-value" ) || "" ); } ); var moved = false; for ( var i = 0; i < sel.options.length; i++ ) { var want = sel.multiple ? keys.indexOf( sel.options[ i ].value ) !== -1 : sel.options[ i ].value === ( keys[0] || "" ); if ( sel.options[ i ].selected !== want ) { sel.options[ i ].selected = want; moved = true; } } if ( moved ) { sel.dispatchEvent( new Event( "change", { bubbles: true } ) ); } } ); } bbChipReconcile(); window.addEventListener( "load", bbChipReconcile ); // The restore has no event of its own and can land after load; two // quiet retries cover it, and agreeing is free. setTimeout( bbChipReconcile, 400 ); setTimeout( bbChipReconcile, 1200 ); // 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( window.bbMoneyRound( amount, dec ) ).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 box = document.querySelector( ".bb-standalone-total" ); return bbWtsIncl( box ? ( box.dataset.taxBasis || "excl" ) : "excl" ); } 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 ); } } ); } // The labels a shopper reads BEFORE picking: "Hat box (+£9.00)" in a // dropdown, the badge on a chip or a tick row. The server writes them // once, in the shop's own basis, so on their own they cannot answer the // VAT toggle: the dropdown went on promising +£9.00 while the receipt // line under it, which does answer, charged £10.80. Two figures for one // choice, on one screen. // // The dual-basis pair cannot be planted here the way it is in the total: // an