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'] ), '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, ); // 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 panel, printed under the set editor form. // ----------------------------------------------------------------- function addon_boss_set_preview_panel(): void { ?>

__( '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'] ?? '' ) ); 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 and // sanitiser the real save runs, overlaid on the store for one read. $draft = addon_boss_sanitize_option_set( addon_boss_option_set_from_form() ); $draft['id'] = '' !== $sid ? $sid : 'bb-preview-draft'; $render_id = $draft['id']; $overlay = addon_boss_option_sets(); $overlay[ $render_id ] = $draft; add_filter( 'pre_option_' . ADDON_BOSS_OPTION_SETS_STORE, static function () use ( $overlay ) { return $overlay; } ); } // A throwaway in-memory product carries the set; nothing has an id and // nothing is saved. Purchasability is forced for the render only, because // an unsaved product can never pass the real check. $bb_product = new WC_Product_Simple(); $bb_product->set_name( __( 'Your product', 'addon-boss' ) ); $bb_product->set_regular_price( '25' ); $bb_product->set_price( '25' ); $bb_product->update_meta_data( ADDON_BOSS_PRODUCT_OPTIONS_META, // The full attachment row shape the resolver expects: no per-product // overrides, nothing hidden — the set previews exactly as stored. array( array( 'set' => $render_id, 'deltas' => array(), 'images' => array(), 'hide' => array(), ), ) ); add_filter( 'woocommerce_is_purchasable', '__return_true' ); $prev_global = $GLOBALS['product'] ?? null; $GLOBALS['product'] = $bb_product; $bb_held = addon_boss_preview_undecorate_prices(); ob_start(); addon_boss_render_standalone_options(); $component = (string) ob_get_clean(); addon_boss_preview_redecorate_prices( $bb_held ); $GLOBALS['product'] = $prev_global; remove_filter( 'woocommerce_is_purchasable', '__return_true' ); if ( '' === trim( $component ) ) { wp_send_json_error( array( 'message' => __( 'Add an option to see the preview.', 'addon-boss' ) ) ); } wp_send_json_success( array( 'html' => addon_boss_set_preview_document( $component ) ) ); } /** * 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 ); } 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), live controls muted. */ function addon_boss_set_preview_document( string $component ): 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 .= ''; $doc .= ''; $doc .= ''; $doc .= '
'; $doc .= $component; $doc .= '
' . esc_html__( 'This is a preview of how the options look. Prices and Add to cart run on the product pages this set is attached to.', 'addon-boss' ) . '
'; $doc .= '
'; return $doc; }