*/ 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' : ''; // Money typed in the product's panel reads as the set editors read it // (addon_boss_typed_amount()): "2,50" is 2.50, never 250. $def['fee'] = max( 0.0, (float) addon_boss_typed_amount( $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) addon_boss_typed_amount( $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( addon_boss_typed_amount( $bb_pv ) ) ) { $def[ $bb_pk ] = max( 0.0, (float) addon_boss_typed_amount( $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) addon_boss_typed_amount( $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) addon_boss_typed_amount( $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 name of whatever is unanswered, a field or a dimension */ __( '%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 name of whatever is unanswered, a field or a dimension */ __( '%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'] ?? ( $opt['label'] ?? '' ) ); // no value saved: its label, as the field draws it 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 name of whatever is unanswered, a field or a dimension */ __( '%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 name of whatever is unanswered, a field or a dimension */ __( '%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 name of whatever is unanswered, a field or a dimension */ __( '%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'] ?? ( $opt['label'] ?? '' ) ); // no value saved: its label, as the field draws it 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; } // Read as the page reads it: "19,50" is 19.50 and "1,950" is 1950. // The page said £19.50 for "19,50" while this charged £1,950. $bb_amount = addon_boss_typed_amount( $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, bold 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. The cap state matters because maxlength stops the // keys silently: it is what explains why typing stopped. It is not // an error, so it is not drawn as one. 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'; // A value already there (the cart's Edit options, a failed add sent // back) is priced now, as the till will: the module's script sets it // only after the total is first drawn, so "THE OLD MILL" read £34.00 // where the cart said £44.20 until a key was pressed. $bb_had = is_scalar( $value ) ? trim( (string) $value ) : ''; if ( '' !== $bb_had && 'char' === $mode ) { $attr_map['data-bb-live-fee'] = (string) ( $fee * ( function_exists( 'mb_strlen' ) ? mb_strlen( (string) $value ) : strlen( (string) $value ) ) ); } elseif ( '' !== $bb_had && 'per_value' === $mode && is_numeric( $bb_had ) && (float) $bb_had > 0 ) { $attr_map['data-bb-live-fee'] = (string) ( $fee * (float) $bb_had ); } } 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': // The sentence the server refuses a wrong address with, printed on the // field, so the page can say it the moment the shopper leaves the box // and the two can never disagree. 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 ) ); // The complaint sits beside the box it is about, as the amount's does. echo ''; break; case 'url': // type="text" on purpose: the strict native url check rejects // "mysite.com", which is how people actually type their site. printf( ''; echo ''; 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 ); $oval = (string) ( $opt['value'] ?? ( $opt['label'] ?? '' ) ); // no value saved: its label printf( '', esc_attr( $name ), esc_attr( $oval ), checked( $bb_val, $oval, false ), esc_attr( (string) $ofee ), $req ? ' required' : '', esc_attr( $opt['color'] ?? '#cccccc' ), esc_html( (string) ( $opt['label'] ?? $oval ) ), // 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. * * Read once per request and remembered until the library is written. WordPress * unserialises a stored option afresh on every get_option(), and this reader * is called once per choice while a page or editor draws: one bundle's items * panel asked for the library 5,852 times, a whole library each time, which * was 3.3 of its 3.7 seconds on a 287-set library and grows with every set a * shop adds (a supplier-built library made a bundle take about 45 seconds to * open for editing). Any save, add or delete of the store forgets the copy, * so a write and a read in the same request still agree. * * The copy lives in a non-persistent object cache group rather than a PHP * global, so it is kept per site after switch_to_blog() on a network and is * dropped by wp_cache_flush() along with WordPress's own option cache. * * @return array */ function addon_boss_option_sets(): array { $found = false; $sets = wp_cache_get( 'option_sets', ADDON_BOSS_OPTION_SETS_MEMO, false, $found ); if ( $found && is_array( $sets ) ) { return $sets; } $sets = addon_boss_option_sets_read(); wp_cache_set( 'option_sets', $sets, ADDON_BOSS_OPTION_SETS_MEMO ); return $sets; } /** Forget the remembered library, so the next read comes from the store. */ function addon_boss_option_sets_forget(): void { wp_cache_delete( 'option_sets', ADDON_BOSS_OPTION_SETS_MEMO ); } /** * Change the library as the database holds it now, one writer at a time. * * Every set lives in one option, so a writer reads the whole library and * writes the whole library back. One that read it early in its request (an * editor save reads it long before it writes) put back a library without * whatever another admin saved in between: a tester's new set vanished, * "saved", while two others were saving theirs (18 Oct 2026). A writer now * holds a database lock, re-reads the store past every cache, changes it * and writes it, so two saves land one after the other. Where the database * has no named locks, the fresh read alone narrows the gap to milliseconds. * * @param callable $change Gets the library; returns the library to store, or * anything else (a WP_Error, null) to store nothing. * @return mixed What $change returned. */ function addon_boss_option_sets_change( callable $change ) { global $wpdb; $bb_lock = substr( $wpdb->prefix . 'addon_boss_option_sets', 0, 64 ); $bb_was = $wpdb->suppress_errors( true ); $bb_held = '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK( %s, 5 )', $bb_lock ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- a named lock, not data: there is nothing to cache. $wpdb->suppress_errors( $bb_was ); try { wp_cache_delete( ADDON_BOSS_OPTION_SETS_STORE, 'options' ); addon_boss_option_sets_forget(); $bb_out = $change( addon_boss_option_sets() ); if ( is_array( $bb_out ) ) { // 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, $bb_out, false ); } return $bb_out; } finally { if ( $bb_held ) { $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK( %s )', $bb_lock ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- releases the named lock above. } } } const ADDON_BOSS_OPTION_SETS_MEMO = 'addon_boss_memo'; wp_cache_add_non_persistent_groups( array( ADDON_BOSS_OPTION_SETS_MEMO ) ); add_action( 'add_option_' . ADDON_BOSS_OPTION_SETS_STORE, 'addon_boss_option_sets_forget' ); add_action( 'update_option_' . ADDON_BOSS_OPTION_SETS_STORE, 'addon_boss_option_sets_forget' ); add_action( 'delete_option_' . ADDON_BOSS_OPTION_SETS_STORE, 'addon_boss_option_sets_forget' ); /** * The library as stored, shape-checked. Use addon_boss_option_sets(). * * @return array */ function addon_boss_option_sets_read(): 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(); continue; } // The same goes for a row. One written outside the set editor (an // import, a fixture, an older build) can carry a key and a label and // nothing else, and the readers of a row read its colour, photo and // charge straight: the library card's chip and the product resolver // both logged "Undefined array key" for every such row on every load. // Each key the editor writes is filled with what its absence already // meant, and a key a row does carry is left exactly as it is. Stock is // not filled: no stock key means unlimited. foreach ( $set['options'] as $i => $opt ) { if ( is_array( $opt ) ) { $sets[ $id ]['options'][ $i ] = $opt + array( 'swatch' => '', 'swatch2' => '', 'desc' => '', 'image_id' => 0, 'delta' => 0.0, 'dims' => 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 * own_sections the kind's editor draws whole sections of its own, rows and * all; the shared Price section and the choice rows stand * down for it, and the rows do not post while it is picked * start the kind's card on the new-set start screen, as a callback * returning title, line, example (one worked price from a * template's own numbers), order, blank (the recipe that opens * the kind empty) and recipes: recipe id => label and the set * that recipe starts from. A kind without one still gets a * card, from its label and desc, opening it empty * * 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, 'start' => 'addon_boss_choices_start_card', ), ); return (array) apply_filters( 'addon_boss_option_set_kinds', $own ); } /** * The list of choices' card on the new-set start screen: first, because it * is the set most shops make. Its templates are the starter sets, each tile * summed up from its own choices, so a tile never shows a number its * template does not have. * * @return array */ function addon_boss_choices_start_card(): array { $recipes = array(); foreach ( addon_boss_option_starter_sets() as $bb_rid => $bb_starter ) { $recipes[ (string) $bb_rid ] = array( 'label' => (string) $bb_starter['name'], 'set' => $bb_starter, ) + addon_boss_choices_tile( (array) $bb_starter['options'] ); } return array( 'order' => 10, 'title' => __( 'List of choices', 'addon-boss' ), 'line' => __( 'Colours, sizes, finishes or extras, each with its own price', 'addon-boss' ), 'pic' => addon_boss_choices_start_picture(), 'blank' => 'scratch', 'recipes' => $recipes, ); } /** * A starter list summed up for its template tile: its first charged choice * and how many more are charged, or else how many choices it holds (colours, * when every choice is one), with a small picture of its first few. * * @param array $options The template's choices. * @return array{say:string,mini:array} */ function addon_boss_choices_tile( array $options ): array { $priced = array(); $dots = array(); $labels = array(); foreach ( $options as $bb_opt ) { $bb_label = (string) ( $bb_opt['label'] ?? '' ); if ( '' !== $bb_label ) { $labels[] = $bb_label; } if ( (float) ( $bb_opt['delta'] ?? 0 ) > 0 ) { $priced[] = $bb_opt; } if ( '' !== (string) ( $bb_opt['swatch'] ?? '' ) ) { $dots[] = (string) $bb_opt['swatch']; } } $n = count( $options ); $colours = $n > 0 && count( $dots ) === $n; if ( $priced ) { $say = (string) $priced[0]['label'] . ' ' . addon_boss_editor_money_text( (float) $priced[0]['delta'], true ); $more = count( $priced ) - 1; if ( $more > 0 ) { /* translators: 1: the first ones, named, 2: how many more there are */ $say = sprintf( _n( '%1$s and %2$d more', '%1$s and %2$d more', $more, 'addon-boss' ), $say, $more ); } } elseif ( $colours ) { /* translators: %d: how many colours */ $say = sprintf( _n( '%d colour', '%d colours', $n, 'addon-boss' ), $n ); } else { /* translators: %d: how many choices a template holds */ $say = sprintf( _n( '%d choice', '%d choices', $n, 'addon-boss' ), $n ); } return array( 'say' => $say, 'mini' => $colours ? array( 'dots' => array_slice( $dots, 0, 7 ) ) : array( 'chips' => array_slice( $labels, 0, 4 ) ), ); } /** * The list of choices drawn as the shape shoppers meet, nothing more: a row * of colours with one picked and a row of sizes with one picked. Plain * shapes, so the card stands for any shop's choices rather than one * product's photo. * * @return string */ function addon_boss_choices_start_picture(): string { return ''; } /** * The markup an option's note may carry. * * A note that runs to a few sentences reads as a wall without bold, a * break or a short list, so a shop gets those, and a link for "see the size * guide". Nothing that can run, embed or restyle the page. * * @return array> */ function addon_boss_note_allowed_html(): array { return array( 'b' => array(), 'strong' => array(), 'i' => array(), 'em' => array(), 'br' => array(), 'p' => array(), 'ul' => array(), 'ol' => array(), 'li' => array(), 'a' => array( 'href' => true, 'title' => true, 'target' => true, 'rel' => true, ), ); } /** * An option's note, cleaned to the markup above. * * Run on save and again on every display, because a note can also arrive * by import or from before notes took markup, and a page must never print * what the save would have refused. */ function addon_boss_note_html( $note ): string { return trim( wp_kses( (string) $note, addon_boss_note_allowed_html() ) ); } /** * An option's note as it reads straight after the option's own name. * * Three places print a choice's name in bold with its note beside it: the * quantity panel above a design's cells, the grid's whole-order choices and * the named lines of a several-of set. A shop whose notes open with that * same name, as a design catalogue's often do, saw the name twice, one copy * stacked on the other. So a first line that says the name and nothing else * is left out here, with any blank paragraph it leaves at the top. A note * that only starts with the name, or never says it, prints as written, and * so does a first line holding a link, which would take the link with it. * Where a note prints on its own, nothing names it, and it keeps every word. * * @param string $name The choice's name, as printed beside the note. * @param mixed $note The choice's stored note. */ function addon_boss_note_after_name( $name, $note ): string { $html = addon_boss_note_html( $note ); $want = addon_boss_note_words( (string) $name ); if ( '' === $html || '' === $want ) { return $html; } $only_name = static function ( string $line ) use ( $want ): bool { return false === stripos( $line, '|

(?:\s| |\x{00A0}|)*

)+#iu', '', $html ); return null === $out ? $html : $out; }; if ( preg_match( '#^

(.*?)

#is', $html, $para ) ) { // The note opens with a paragraph: its first line runs to a break // inside that paragraph, or to its end. $lines = preg_split( '##i', $para[1], 2 ); if ( ! $only_name( (string) $lines[0] ) ) { return $html; } $rest = substr( $html, strlen( $para[0] ) ); $more = isset( $lines[1] ) ? $drop_blanks( (string) $lines[1] ) : ''; if ( '' !== trim( $more ) ) { $rest = '

' . $more . '

' . $rest; } } else { // The note opens with plain words: its first line runs to the first // break, paragraph or list. $line = $html; $rest = ''; if ( preg_match( '#|<(?:p|ul|ol)\b#i', $html, $stop, PREG_OFFSET_CAPTURE ) ) { $line = substr( $html, 0, (int) $stop[0][1] ); $rest = substr( $html, (int) $stop[0][1] ); } if ( ! $only_name( $line ) ) { return $html; } } return trim( $drop_blanks( $rest ) ); } /** * What a stretch of note markup says, for comparing one with another: tags * gone, entities read, and every run of space (a no-break space too) one. */ function addon_boss_note_words( string $html ): string { $text = html_entity_decode( wp_strip_all_tags( $html ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $text = preg_replace( '/[\s\x{00A0}]+/u', ' ', $text ); return trim( (string) $text ); } /** * 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'] ?? '' ) ); // A colour the merchant took away stays away. The name guess below // fills an empty swatch, so "empty" alone cannot say "no colour": // the editor's clear control posts 'none' and the row keeps a mark. $bb_no_swatch = ! $swatch && ( 'none' === (string) ( $opt['swatch'] ?? '' ) || ! empty( $opt['no_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 note shown when the shopper picks this option // (e.g. "Print protection" -> "adds a clear laminate..."). It may // carry simple markup: bold, a break, a list, a link. 'desc' => addon_boss_note_html( $opt['desc'] ?? '' ), 'image_id' => absint( $opt['image_id'] ?? 0 ), // Read as the editor reads it: "2,50" is 2.50 in any shop, never 250. 'delta' => (float) addon_boss_typed_amount( $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'] ); } if ( $bb_no_swatch ) { $row['swatch2'] = ''; $row['no_swatch'] = 1; } $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'] && empty( $bb_row_opt['no_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'] ), // On a several-of set: whether the latest pick's photo becomes the // product's main image. Absent means no, so a several-of set saved // before this switch keeps its photo exactly as it was. 'photo_latest' => ! empty( $raw['photo_latest'] ), // 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. // ================================================================= /** * The per-set keys this build knows how to write, and so may remove. The * engine's own, plus whatever its modules declare. A stored key outside the * list is carried through every save untouched. * * @return string[] */ function addon_boss_option_set_owned_keys(): array { $keys = array( 'name', 'label', 'kind', 'measure', 'table', 'options', 'group', 'source', 'photo_swaps', 'photo_latest', 'shape', 'pin_chips', 'show_stock', 'multi', 'min', 'max' ); return array_values( array_unique( array_map( 'strval', (array) apply_filters( 'addon_boss_option_set_owned_keys', $keys ) ) ) ); } /** * A set exactly as saving it would store it, without storing it. Fields the * caller never mentions ride through from the stored copy, the result is * sanitised, and a stored key no module on this build owns is kept. The save * and the editor's preview both read through here, so "Your changes" never * draws a set the save would not store. The preview used to sanitise the * form alone, skipping the carry, and headed a supplier's set with its * library name ("RX101 - Pro RTX Polo Shirt Colour") where the page and * the saved set say "Colour". * * @param array $set The set as the caller sends it. * @param string $id The set's id; a new set has no stored copy. * @param array $sets The whole store. * @return array The set as the store would hold it. */ function addon_boss_option_set_as_saved( array $set, string $id, array $sets ): array { // 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 words shoppers read when they differ from the library name (a // supplier feed writes "Colour" over "RX101 - Pro RTX Polo Shirt // Colour"). The set editor has no field for them, so an edit carries // them through like the group and the source. Until 2.6.x one Save // wiped them and the product page, the cart and the order fell back to // the library name (AOB #123). A caller that sends a label, empty // included, still decides. if ( ! isset( $set['label'] ) && isset( $sets[ $id ]['label'] ) ) { $set['label'] = $sets[ $id ]['label']; } // 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; } // The same for the several-of photo switch. if ( ! isset( $set['photo_latest'] ) && ! empty( $sets[ $id ]['photo_latest'] ) ) { $set['photo_latest'] = 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 ]; } } } $bb_clean = addon_boss_sanitize_option_set( $set ); // A stored key no module on this build owns rides through untouched: a // newer version's data, or a module this build does not carry, is not // this save's to delete. A key a module does own stays that module's to // remove (clearing every can't-go-together rule removes them). if ( isset( $sets[ $id ] ) && is_array( $sets[ $id ] ) ) { $bb_owned = addon_boss_option_set_owned_keys(); foreach ( $sets[ $id ] as $bb_key => $bb_value ) { if ( ! array_key_exists( $bb_key, $bb_clean ) && ! in_array( (string) $bb_key, $bb_owned, true ) ) { $bb_clean[ $bb_key ] = $bb_value; } } } return $bb_clean; } /** * 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_change( static function ( array $sets ) use ( $set, $id ): array { $sets[ $id ] = addon_boss_option_set_as_saved( $set, $id, $sets ); return $sets; } ); // 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; } /** * The sets a file holds, in the order they can be saved. A measured part * priced by another list (glass by its thickness) keeps that list only when * the list is already in the library, so a file listing the glass first * landed it priced at one flat rate for every thickness. A set that follows * another set in the same file now lands after the rest; everything else * keeps its place, keys and all. The list a part follows is a list of * choices, never itself a follower, so one pass is enough. * * @param array $sets Id => set, as the file holds them. * @return array The same sets, keys kept. */ function addon_boss_option_sets_landing_order( array $sets ): array { $first = array(); $after = array(); foreach ( $sets as $id => $set ) { $follows = false; foreach ( is_array( $set ) ? (array) ( $set['measure']['parts'] ?? array() ) : array() as $part ) { // The list a part is priced by, and the list its sides follow. foreach ( is_array( $part ) ? array( $part['from']['set'] ?? '', $part['sides']['set'] ?? '' ) : array() as $from ) { if ( is_scalar( $from ) && '' !== (string) $from && (string) $from !== (string) $id && array_key_exists( (string) $from, $sets ) ) { $follows = true; } } } if ( $follows ) { $after[ $id ] = $set; } else { $first[ $id ] = $set; } } return $first + $after; } /** Delete a set. Product attachments referencing it resolve to nothing. */ function addon_boss_delete_option_set( string $id ): bool { $gone = addon_boss_option_sets_change( static function ( array $sets ) use ( $id ) { if ( ! isset( $sets[ $id ] ) ) { return null; } unset( $sets[ $id ] ); return $sets; } ); if ( ! is_array( $gone ) ) { return 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; } $rows = addon_boss_option_stock_rows( $set_id ); return isset( $rows[ $key ] ) ? max( 0, $rows[ $key ] ) : null; } /** * Every count one set holds, read in one query and remembered for the request. * * A bundle of garments asks for each choice's count while drawing the chips, * again for the stock line, again for the basket check: thousands of single-row * reads on one page, which a shared host turns into tens of seconds. The memo * lives in the non-persistent group, so it never outlives the request, and * every write, sale and prune forgets it, so a count is never older than the * request reading it. * * @return array option key => stored count (not floored). */ function addon_boss_option_stock_rows( string $set_id ): array { $found = false; $rows = wp_cache_get( 'stock:' . $set_id, ADDON_BOSS_OPTION_SETS_MEMO, false, $found ); if ( $found && is_array( $rows ) ) { return $rows; } 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 per set per request; never a persistent cache, see above. $got = $wpdb->get_results( $wpdb->prepare( "SELECT option_key, qty FROM {$table} WHERE set_id = %s", $set_id ), ARRAY_A ); $rows = array(); foreach ( (array) $got as $row ) { $rows[ (string) $row['option_key'] ] = (int) $row['qty']; } wp_cache_set( 'stock:' . $set_id, $rows, ADDON_BOSS_OPTION_SETS_MEMO ); return $rows; } /** Forget one set's remembered counts, so the next read comes from the table. */ function addon_boss_option_stock_forget( string $set_id ): void { wp_cache_delete( 'stock:' . $set_id, ADDON_BOSS_OPTION_SETS_MEMO ); // Every write, sale and prune passes through here, so this is the one // place a listener can learn that a set's counts (or, for a "bbpair:" // bucket, one product's pair counts) have moved. do_action( 'addon_boss_option_stock_changed', $set_id ); } /** 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 ) ); addon_boss_option_stock_forget( $set_id ); } /** * 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 ) ); addon_boss_option_stock_forget( $set_id ); } /* * ================================================================= * 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 (SupplierBoss 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(); addon_boss_option_stock_forget( $set_id ); 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 ) : 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: 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( $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: 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 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: the choice, or the colour and size together */ __( '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: the choice, or the colour and size together */ _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'] ?? '' ); } /** * The product's attached rows. * * @param WC_Product|mixed $product The product. * @param bool $stored True for the rows exactly as saved: the * screens that edit and save them, and the * copies. Otherwise the rows the shop page * asks, with the lists a set's prices follow * (addon_boss_config_with_companions). */ function addon_boss_product_option_config( $product, bool $stored = false ): 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 ); $cfg = is_array( $cfg ) ? $cfg : array(); return $stored ? $cfg : addon_boss_config_with_companions( $cfg ); } /** * The lists a set's prices follow (a glass priced by its edge polish, a * shutter's handle side): each set type names its own through the filter * addon_boss_set_preview_companions. Only stored sets, never the set itself. * * @param array $set A stored set, with its id under 'id' or passed apart. * @param string $set_id The set's id. * @return string[] */ function addon_boss_set_companions( array $set, string $set_id ): array { $out = array(); foreach ( (array) apply_filters( 'addon_boss_set_preview_companions', array(), $set ) as $bb_cid ) { $bb_cid = (string) $bb_cid; if ( '' !== $bb_cid && $bb_cid !== $set_id && ! in_array( $bb_cid, $out, true ) && addon_boss_option_set( $bb_cid ) ) { $out[] = $bb_cid; } } return $out; } /** * Attached rows with the lists their sets' prices follow, each right after * the set that follows it and asked as that set is (required, and shown by * the same rule). A list the product carries already stays where it is. * * A glass priced "A price for each Edge polish" on a product without the * Edge polish list showed no choice and charged the cheapest polish, while * the editor's preview showed and charged the one picked: £166.17 on the * page against £215.16 in the preview (walk 3, 30 Oct 2026). The list now * comes with the set wherever the set goes, in the order the preview draws. * * @param array $config Stored rows (top level, groups holding children). * @return array */ function addon_boss_config_with_companions( array $config ): array { $have = array(); foreach ( addon_boss_flatten_option_config( $config ) as $bb_row ) { if ( is_array( $bb_row ) && '' !== (string) ( $bb_row['set'] ?? '' ) ) { $have[ (string) $bb_row['set'] ] = true; } } if ( ! $have ) { return $config; } $walk = static function ( array $rows ) use ( &$walk, &$have ): array { $out = array(); foreach ( $rows as $bb_k => $bb_row ) { if ( is_array( $bb_row ) && 'group' === ( $bb_row['row_type'] ?? '' ) ) { $bb_row['children'] = $walk( (array) ( $bb_row['children'] ?? array() ) ); } $out[] = $bb_row; $bb_sid = is_array( $bb_row ) && 'field' !== ( $bb_row['row_type'] ?? '' ) ? (string) ( $bb_row['set'] ?? '' ) : ''; $bb_set = '' !== $bb_sid ? addon_boss_option_set( $bb_sid ) : null; if ( ! $bb_set ) { continue; } foreach ( addon_boss_set_companions( (array) $bb_set, $bb_sid ) as $bb_cid ) { if ( isset( $have[ $bb_cid ] ) ) { continue; } $have[ $bb_cid ] = true; $out[] = array( 'set' => $bb_cid, 'required' => ! empty( $bb_row['required'] ), 'when' => is_array( $bb_row['when'] ?? null ) ? $bb_row['when'] : array(), 'deltas' => array(), 'images' => array(), 'hide' => array(), 'companion' => $bb_sid, ); } } return $out; }; return $walk( $config ); } /** * 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 picker's own behaviour, owned by the engine that draws the picker. * * WHY NOT THE ADMIN SCRIPT. The product-options panel loads its script from * whichever plugin got there first: the engine steps aside when the host's * admin handle is already enqueued, because a pre-split host binds the same * generic behaviours. That is fine for behaviours both copies have. It is not * fine for a control only one of them knows about, which on the other's copy * is either dead or, worse, swept up by a generic selector. This panel is the * engine's, so its script is too. * * Everything here is progressive: the select and the two tick-boxes are what * post, and they are hidden only once their cards are wired. * * @return string */ function addon_boss_options_picker_js(): string { return <<<'JS' jQuery( function ( $ ) { $( '.bb-lookcards[data-for]' ).each( function () { var $cards = $( this ); var $select = $( '#' + $cards.data( 'for' ) ); if ( ! $select.length ) { $cards.remove(); return; } $select.addClass( 'bb-look-carded' ); // The heading above these cards IS a