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;
}