sanitize_text_field( wp_unslash( $_POST['set_name'] ?? '' ) ), 'kind' => sanitize_text_field( wp_unslash( $_POST['set_kind'] ?? 'choices' ) ), 'shape' => sanitize_text_field( wp_unslash( $_POST['set_shape'] ?? '' ) ), // A tick, so an absent field means unticked. The sanitiser's "absent // means yes" default is for sets saved BEFORE the switch existed, and // the two must not be confused: a merchant who unticks it posts // nothing, and that has to read as no. 'photo_swaps' => ! empty( $_POST['set_photo_swaps'] ), 'photo_latest' => ! empty( $_POST['set_photo_latest'] ), 'pin_chips' => ! empty( $_POST['set_pin_chips'] ), 'show_stock' => addon_boss_option_stock_enabled() ? ! empty( $_POST['set_show_stock'] ) : null, 'multi' => '1' === sanitize_text_field( wp_unslash( $_POST['set_multi'] ?? '' ) ), 'min' => absint( $_POST['set_min'] ?? 0 ), 'max' => absint( $_POST['set_max'] ?? 0 ), 'options' => $rows, ); // The words shoppers read where they differ from the name: posted only // when the editor's "Show the name instead" asks to drop them, so every // other save carries them through (addon_boss_option_set_as_saved). if ( isset( $_POST['set_label'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- both callers check the editor's nonce first (set-preview.php header). $bb_raw['label'] = sanitize_text_field( wp_unslash( $_POST['set_label'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- as above. } // Each registered kind reads its own panel's fields off the form, exactly // as the real save does. foreach ( addon_boss_option_set_kinds() as $bb_spec ) { if ( is_callable( $bb_spec['collect'] ?? null ) ) { $bb_raw = array_merge( $bb_raw, (array) call_user_func( $bb_spec['collect'] ) ); } } // Modules collect their own panel's fields off the same form, so the save // and the preview read one assembly and can never drift apart. return (array) apply_filters( 'addon_boss_option_set_from_form', $bb_raw ); } /** * The products a set is on, for the customer page's product picker: * published ones first, then the rest, each by title. * * @param string $set_id The set. * @param int $limit How many to offer. * @return array Product id => title. */ function addon_boss_set_preview_products( string $set_id, int $limit = 30 ): array { if ( '' === $set_id || 'new' === $set_id ) { return array(); } $ids = array_keys( (array) ( addon_boss_option_set_usage_map()[ $set_id ] ?? array() ) ); if ( ! $ids ) { return array(); } $rows = array(); foreach ( array_slice( $ids, 0, 200 ) as $bb_pid ) { $bb_post = get_post( (int) $bb_pid ); if ( ! $bb_post || 'product' !== $bb_post->post_type ) { continue; } $rows[] = array( 'id' => (int) $bb_pid, 'live' => 'publish' === $bb_post->post_status ? 0 : 1, 'title' => '' !== $bb_post->post_title ? $bb_post->post_title : __( '(no title)', 'addon-boss' ), ); } usort( $rows, static function ( array $a, array $b ): int { return array( $a['live'], strtolower( $a['title'] ) ) <=> array( $b['live'], strtolower( $b['title'] ) ); } ); $out = array(); foreach ( array_slice( $rows, 0, $limit ) as $bb_row ) { $out[ $bb_row['id'] ] = $bb_row['title']; } return $out; } // ----------------------------------------------------------------- // The panel, printed beside the set editor form. // ----------------------------------------------------------------- function addon_boss_set_preview_panel(): void { $bb_set_id = isset( $_GET['set'] ) ? sanitize_text_field( wp_unslash( $_GET['set'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing. $bb_products = addon_boss_set_preview_products( $bb_set_id ); ?>

1 ) : ?>
$draft The sanitised set from the form. */ function addon_boss_set_preview_overlay( string $render_id, array $draft ): void { $overlay = addon_boss_option_sets(); $overlay[ $render_id ] = $draft; add_filter( 'pre_option_' . ADDON_BOSS_OPTION_SETS_STORE, static function () use ( $overlay ) { return $overlay; } ); addon_boss_option_sets_forget(); } /** * The product the preview draws on: the chosen product that carries the set, * holding ONLY this set's own rows (its own charges, hidden choices and * photos apply; another set's condition never hides it), in memory only; or a * stand-in when no product carries the set, or the chosen one draws nothing * through the standard renderer (a quantity grid draws itself). * * @param string $render_id The set id the render reads. * @param int $product_id The product to draw on; 0 for the stand-in. * @param bool $stand_in Force the stand-in. * @return WC_Product */ function addon_boss_set_preview_product( string $render_id, int $product_id, bool $stand_in = false ): WC_Product { $real = ( ! $stand_in && $product_id ) ? wc_get_product( $product_id ) : null; // The lists this set's prices follow come along (addon_boss_set_preview_companions). $bb_with = addon_boss_set_preview_companions( $render_id ); if ( $real instanceof WC_Product ) { $rows = array(); $seen = array(); // In the order the shop page asks them: a list the product does not // carry comes right after the set (addon_boss_config_with_companions). // The preview put it first while the page put the size first (walk 3, // 30 Oct 2026). $bb_page = addon_boss_config_with_companions( (array) $real->get_meta( ADDON_BOSS_PRODUCT_OPTIONS_META ) ); foreach ( addon_boss_flatten_option_config( $bb_page ) as $row ) { $bb_rs = is_array( $row ) ? (string) ( $row['set'] ?? '' ) : ''; if ( '' !== $bb_rs && ( $bb_rs === $render_id || in_array( $bb_rs, $bb_with, true ) ) && ! isset( $seen[ $bb_rs ] ) ) { unset( $row['when'] ); $rows[] = $row; $seen[ $bb_rs ] = true; } } if ( isset( $seen[ $render_id ] ) ) { // In memory only: the object is never saved. $real->update_meta_data( ADDON_BOSS_PRODUCT_OPTIONS_META, $rows ); return $real; } } // A throwaway in-memory product carries the set; nothing has an id and // nothing is saved. A set type may give it a truer price and say the set // must be answered (a measured size whose price is the whole price: the // product's own figure would be meaningless beside it). $bb_set = (array) addon_boss_option_set( $render_id ); $bb_price = (float) apply_filters( 'addon_boss_set_preview_stand_in_price', 25.0, $bb_set ); $bb_product = new WC_Product_Simple(); $bb_product->set_name( __( 'Your product', 'addon-boss' ) ); $bb_product->set_regular_price( (string) $bb_price ); $bb_product->set_price( (string) $bb_price ); // Required, as a set is when first put on a product, and as the facts // card beside it says; it read "(optional)" under a card saying // "Required" (fresh testers, 18 Oct 2026). The lists its prices follow // come after it, as on a product page. $bb_rows = addon_boss_config_with_companions( array( addon_boss_set_preview_plain_row( $render_id, (bool) apply_filters( 'addon_boss_set_preview_stand_in_required', true, $bb_set ) ) ) ); $bb_product->update_meta_data( ADDON_BOSS_PRODUCT_OPTIONS_META, $bb_rows ); return $bb_product; } /** * The full attachment row shape the resolver expects: no per-product * overrides, nothing hidden, so a set previews exactly as stored. * * @param string $set_id The set. * @param bool $required Whether it must be answered. */ function addon_boss_set_preview_plain_row( string $set_id, bool $required ): array { return array( 'set' => $set_id, 'required' => $required, 'deltas' => array(), 'images' => array(), 'hide' => array(), ); } /** * The lists a set's prices follow, shown beside it on the customer page: a * measured glass priced by its edge finish, a shutter whose handle runs on * the side a list picks. Without them the page priced the set as no shop * would (the cheapest finish, the sides costing least) while the shop page * asks for the choice, and the two disagreed (owner walk, 29 Oct 2026). Each * set type names its own (filter addon_boss_set_preview_companions); only * stored lists of choices, never the set itself. * * @param string $render_id The set being previewed. * @return string[] Set ids. */ function addon_boss_set_preview_companions( string $render_id ): array { $bb_set = addon_boss_option_set( $render_id ); if ( ! $bb_set ) { return array(); } return addon_boss_set_companions( (array) $bb_set, $render_id ); } /** * The set as the standard renderer draws it on one product. * * @param WC_Product $product The product. * @return string */ function addon_boss_set_preview_render( WC_Product $product ): string { // Purchasability is forced for the render only, because an unsaved // product can never pass the real check. add_filter( 'woocommerce_is_purchasable', '__return_true' ); $prev_global = $GLOBALS['product'] ?? null; $GLOBALS['product'] = $product; $bb_held = addon_boss_preview_undecorate_prices(); ob_start(); addon_boss_render_standalone_options(); // The running total, which a product page prints on a later hook: the // preview said "Pick choices to see what shoppers pay" over no total at // all (fresh tester, 18 Oct 2026). if ( function_exists( 'addon_boss_print_standalone_receipt' ) ) { addon_boss_print_standalone_receipt(); } $component = (string) ob_get_clean(); $head = array( 'title' => $product->get_name(), 'price' => (string) $product->get_price_html(), 'add' => (string) $product->single_add_to_cart_text(), 'vat' => function_exists( 'addon_boss_editor_vat_note' ) ? addon_boss_editor_vat_note( $product ) : '', ); addon_boss_preview_redecorate_prices( $bb_held ); $GLOBALS['product'] = $prev_global; remove_filter( 'woocommerce_is_purchasable', '__return_true' ); return '' === trim( $component ) ? '' : addon_boss_set_preview_document( $component, $head ); } add_action( 'wp_ajax_addon_boss_set_preview', 'addon_boss_set_preview_ajax' ); function addon_boss_set_preview_ajax(): void { // The editor form's own nonce rides the serialized form, so previewing // proves the same intent as saving would. if ( ! current_user_can( 'manage_woocommerce' ) || ! check_ajax_referer( 'addon_boss_save_option_set_form', '_wpnonce', false ) ) { wp_send_json_error( array( 'message' => __( 'Sorry, you are not allowed to do that.', 'addon-boss' ) ), 403 ); } $mode = sanitize_key( wp_unslash( $_POST['bb_epv_mode'] ?? 'draft' ) ); $sid = sanitize_text_field( wp_unslash( $_POST['set_id'] ?? '' ) ); $pid = absint( $_POST['bb_epv_product'] ?? 0 ); // Only a product that carries this set; anything else draws the stand-in. if ( $pid && ( '' === $sid || ! isset( addon_boss_option_set_usage_map()[ $sid ][ $pid ] ) ) ) { $pid = 0; } if ( 'saved' === $mode ) { if ( '' === $sid || ! addon_boss_option_set( $sid ) ) { wp_send_json_error( array( 'message' => __( 'Save the option set once and its saved state previews here.', 'addon-boss' ) ) ); } $render_id = $sid; } else { // The serialized form IS the save contract: the same assembly, the // same carry of what the editor does not show, and the same sanitiser // the real save runs, overlaid on the store for one read. $draft = addon_boss_option_set_as_saved( addon_boss_option_set_from_form(), $sid, addon_boss_option_sets() ); $draft['id'] = '' !== $sid ? $sid : 'bb-preview-draft'; $render_id = $draft['id']; addon_boss_set_preview_overlay( $render_id, $draft ); } $bb_product = addon_boss_set_preview_product( $render_id, $pid ); // A price queued by Use £X shows here before Save set applies it, so the // customer page and the facts beside it read the same figure (fresh // tester, 18 Oct 2026). In memory only: nothing is saved. if ( 'saved' !== $mode && $pid && isset( $_POST['bb_use_price'][ $pid ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the form's nonce is checked at the top of this request. $bb_queued = wc_format_decimal( sanitize_text_field( wp_unslash( (string) $_POST['bb_use_price'][ $pid ] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- as above. if ( '' !== $bb_queued && (float) $bb_queued >= 0 && function_exists( 'addon_boss_editor_price_settable' ) && addon_boss_editor_price_settable( $bb_product ) ) { $bb_product->set_regular_price( $bb_queued ); $bb_product->set_price( $bb_queued ); } } // The lists it brings along start with nothing picked, as the product // page starts: each Required, the price From its cheapest. Pre-picking // their first choice made the preview a page no shopper sees (walk 4 and // walk 5, 30 Oct 2026); the owner picks in the preview to see a price. $html = addon_boss_set_preview_render( $bb_product ); // The chosen product draws nothing through the standard renderer (a // quantity grid draws itself): show the set on the stand-in instead. if ( '' === $html && $pid ) { $html = addon_boss_set_preview_render( addon_boss_set_preview_product( $render_id, 0, true ) ); } if ( '' === $html ) { wp_send_json_error( array( 'message' => __( 'Add an option to see the preview.', 'addon-boss' ) ) ); } wp_send_json_success( array( 'html' => $html ) ); } /** * The preview document carries OUR stylesheet and nobody else's, so any plugin * that decorates price markup and hides half of it with its own stylesheet * arrives here with nothing to do the hiding. A tax switcher printing both the * ex and inc figures then reads as one jammed number: "£25.00£30.00" (his * find, 6 September). For the render only, the two HTML-returning price hooks * go back to WooCommerce's own output, so a preview shows one price per choice * the way a shopper sees one. * * The numeric hook stays untouched: it returns a formatted amount, never * markup, so a currency or locale plugin still decides how the figure reads. * Nothing is removed for longer than the render, and the storefront, which * loads every plugin's stylesheet, is not touched at all. * * @return array The callbacks lifted off, for handing straight back. */ function addon_boss_preview_undecorate_prices(): array { $held = array(); foreach ( array( 'wc_price', 'woocommerce_get_price_html' ) as $hook ) { // A CLONE, because the registry holds WP_Hook objects: keeping the // object itself would hand back the very instance remove_all_filters // is about to empty, and the storefront would lose its decorators for // the rest of the request. Cloning copies the callback array by value // first, so what goes back is what was there. $held[ $hook ] = isset( $GLOBALS['wp_filter'][ $hook ] ) ? clone $GLOBALS['wp_filter'][ $hook ] : null; remove_all_filters( $hook ); } // Our own "From £X" (a product priced only by its set) is the price the // page shows, not a decoration: it stays. if ( function_exists( 'addon_boss_from_price_html' ) ) { add_filter( 'woocommerce_get_price_html', 'addon_boss_from_price_html', 10, 2 ); } return $held; } /** * Puts back exactly what addon_boss_preview_undecorate_prices() lifted, so a * preview never changes what the next render on this request sees. * * @param array $held The callbacks that were lifted off. */ function addon_boss_preview_redecorate_prices( array $held ): void { foreach ( $held as $hook => $callbacks ) { if ( null === $callbacks ) { unset( $GLOBALS['wp_filter'][ $hook ] ); continue; } $GLOBALS['wp_filter'][ $hook ] = $callbacks; } } /** * A self-contained iframe document: the storefront stylesheet inlined (a * linked sheet cannot be enqueued into srcdoc), the product's title and * price over the set, a quantity box and an inert Add to cart under it, and * the storefront's own script, so the set can be picked and priced exactly * as in the shop. Nothing in the page can submit or leave it. * * @param string $component The rendered set. * @param array $head title, price (markup) and add (button words). */ function addon_boss_set_preview_document( string $component, array $head = array() ): string { // Every image loads NOW: lazy-loading inside a short iframe defers // anything below its fold forever, which reads as broken photos. $component = str_replace( ' loading="lazy"', ' loading="eager"', $component ); $css = (string) @file_get_contents( ADDON_BOSS_PATH . 'css/options.css' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a missing stylesheet degrades to an unstyled preview, never an error page. $doc = ''; $doc .= ''; // Links open in a new tab, never inside the preview. $doc .= ''; $doc .= ''; // The shop's own skin over the base sheet, and the merchant's colours over // that, exactly as a product page attaches them: without it the chips in // here were not the shop's chips (a size's name and its measurements ran // together as "A4210 × 297 mm"). if ( function_exists( 'addon_boss_standalone_options_dress' ) ) { addon_boss_standalone_options_dress(); $skin = (array) wp_styles()->get_data( 'addon-boss-options-front', 'after' ); if ( $skin ) { $doc .= ''; } } $doc .= ''; // A VAT switch writes every price as a pair and its own stylesheet hides // the half not chosen. That sheet never reaches this document, so the // total read "£16.94£20.33". With no switch in here, the preview shows the // shop's own basis, the one the VAT line under the title names. $doc .= ''; // The storefront script expects jQuery, exactly as on a product page. This // is the preview frame's own document (srcdoc), which has no script queue // to enqueue into: core's own copy, by its own URL. $doc .= ''; // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- a standalone srcdoc document, see above. $doc .= '
'; if ( '' !== (string) ( $head['title'] ?? '' ) ) { $doc .= '

' . esc_html( (string) $head['title'] ) . '

'; } if ( '' !== (string) ( $head['price'] ?? '' ) ) { $doc .= '

' . wp_kses_post( (string) $head['price'] ) . '

'; } if ( '' !== (string) ( $head['vat'] ?? '' ) ) { $doc .= '

' . esc_html( (string) $head['vat'] ) . '

'; } // A real cart form, so the storefront script finds what it looks for; it // can never submit. // The quantity box as WooCommerce prints it, its label and all, so the // storefront script names it as the product page does: "Number of packs" // over a pack list's box. The preview showed a bare box (print shop // tester, 18 Oct 2026). $doc .= '
' . $component; $doc .= '
' // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- WooCommerce's own word for its own field. . '
'; $doc .= '
'; $doc .= '
'; $doc .= '

' . esc_html__( 'Pick choices to see what shoppers pay. Add to cart works on the product page itself.', 'addon-boss' ) . '

'; $doc .= '
'; // The storefront's own driver, and whatever each module appends to it, // exactly as a product page runs them. Building it can ask for a little // CSS or script of its own (addon_boss_inline_css / _js), which late in a // request is printed on the spot: inside this build, that put a '; } $doc .= wp_get_inline_script_tag( $boot ); if ( $held['js'] ) { $doc .= wp_get_inline_script_tag( implode( "\n", $held['js'] ) ); } } $doc .= ''; return $doc; }