$attrs Extra attributes, name => value, escaped here: a set * type's panel marker, an id. True prints the bare name * (hidden); false or null leaves the attribute out. */ function addon_boss_editor_sec_open( string $key, string $title, string $sub = '', array $attrs = array() ): void { echo '
$bb_value ) { if ( null === $bb_value || false === $bb_value ) { continue; } echo ' ' . esc_attr( sanitize_key( (string) $bb_name ) ) . ( true === $bb_value ? '' : '="' . esc_attr( (string) $bb_value ) . '"' ); } echo '>'; echo '

' . esc_html( $title ) . '

'; if ( '' !== $sub ) { echo '

' . esc_html( $sub ) . '

'; } echo '
'; } /** Close the section opened last. */ function addon_boss_editor_sec_close(): void { echo '
'; } /** * A figure as plain text in the shop's own money format ("£24.00"), through * the money seam but never converted: for the admin's own numbers (what a * merchant typed, a template's price), which no display basis applies to. * Converting one would apply the basis a second time. * * @param float $amount The figure. * @param bool $sign Lead with + (a choice's charge). */ function addon_boss_editor_money_text( float $amount, bool $sign = false ): string { return html_entity_decode( addon_boss_money( $amount, null, array( 'plain' => true, 'sign' => $sign, 'convert' => false, ) ), ENT_QUOTES, 'UTF-8' ); } /** * The shop's word for its sales tax, for the middle of a sentence. WooCommerce * names it by the shop's country as a heading ("VAT", "Tax"); mid-sentence a * plain "tax" reads right, while VAT, IVA or TVA keep their own capitals. */ function addon_boss_editor_tax_word(): string { $tax = function_exists( 'WC' ) && WC()->countries ? (string) WC()->countries->tax_or_vat() : ''; if ( '' === $tax || __( 'Tax', 'woocommerce' ) === $tax ) { // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- comparing with WooCommerce's own word, never printed. $tax = __( 'tax', 'addon-boss' ); } return $tax; } /** * The one line about tax, said where prices are typed, from the shop's own * settings: whether the prices a merchant types include the tax. Nothing when * the shop does not charge tax. The same on every set, because it is a fact * about the shop, not about the set. */ function addon_boss_editor_vat_line(): string { if ( ! function_exists( 'wc_tax_enabled' ) || ! wc_tax_enabled() ) { return ''; } $tax = addon_boss_editor_tax_word(); return wc_prices_include_tax() /* translators: %s: the shop's word for sales tax, "VAT" or "tax" */ ? sprintf( __( 'Prices you type include %s, as set in WooCommerce.', 'addon-boss' ), $tax ) /* translators: %s: the shop's word for sales tax, "VAT" or "tax" */ : sprintf( __( 'Prices you type exclude %s, as set in WooCommerce.', 'addon-boss' ), $tax ); } /** * The one line on the customer's page when the shop shows prices on a * different basis than they are typed: "Your shop shows prices without VAT, * so your £24.00 shows as £20.00." Worked from the product's own price, so * the numbers are the shop's. Empty when the two bases agree, when the shop * charges no tax, or when there is no price to show it with. * * @param WC_Product $product The product on the customer's page. */ function addon_boss_editor_vat_note( WC_Product $product ): string { if ( ! function_exists( 'wc_tax_enabled' ) || ! wc_tax_enabled() || ! $product->is_taxable() ) { return ''; } $entered_inc = wc_prices_include_tax(); $shown_inc = 'incl' === get_option( 'woocommerce_tax_display_shop' ); if ( $entered_inc === $shown_inc ) { return ''; } $tax = addon_boss_editor_tax_word(); // The preview's stand-in product (no id) carries a price the editor // worked out, not one the shop typed: "your £12.10 shows as £10.08" // named a figure the owner never typed (walk, 30 Oct 2026). The rule // alone, then. if ( ! $product->get_id() ) { return $shown_inc /* translators: %s: the shop's word for sales tax, "VAT" or "tax" */ ? sprintf( __( 'Your shop shows prices with %s added, so the prices here include it.', 'addon-boss' ), $tax ) /* translators: %s: the shop's word for sales tax, "VAT" or "tax" */ : sprintf( __( 'Your shop shows prices without %s, so the prices here leave it out.', 'addon-boss' ), $tax ); } $typed = (float) $product->get_price( 'edit' ); if ( $typed <= 0 ) { return ''; } $shown = (float) wc_get_price_to_display( $product, array( 'price' => $typed ) ); if ( abs( $shown - $typed ) < 0.005 ) { return ''; } // The first figure is the one the merchant typed and the second is // already WooCommerce's display of it: both print exactly as they are. $from = addon_boss_editor_money_text( $typed ); $to = addon_boss_editor_money_text( $shown ); return $shown_inc /* translators: 1: the shop's word for sales tax, 2: a price as typed, 3: the same price as shoppers see it */ ? sprintf( __( 'Your shop shows prices with %1$s added, so your %2$s shows as %3$s.', 'addon-boss' ), $tax, $from, $to ) /* translators: 1: the shop's word for sales tax, 2: a price as typed, 3: the same price as shoppers see it */ : sprintf( __( 'Your shop shows prices without %1$s, so your %2$s shows as %3$s.', 'addon-boss' ), $tax, $from, $to ); } /** The tax line, printed (or nothing). */ function addon_boss_editor_print_vat_line(): void { $line = addon_boss_editor_vat_line(); if ( '' !== $line ) { echo '

' . esc_html( $line ) . '

'; } } /** * The Save bar: the form's one submit, kept in reach while scrolling, and a * line that turns into "You have unsaved changes" once anything differs from * what the page loaded with. A new set is unsaved from its first paint, so it * says so from the start and keeps saying it. * * @param string $state 'saved' for a stored set, 'new' for an empty new one, * 'template' for a new one a template filled. */ function addon_boss_editor_save_bar( string $state = 'saved' ): void { $new = 'saved' !== $state; if ( ! $new ) { $clean = __( 'Saving updates every product that offers this set.', 'addon-boss' ); $dirty = __( 'You have unsaved changes.', 'addon-boss' ); } elseif ( 'template' === $state ) { $clean = __( 'Started from a template. Nothing is saved until you press Save set.', 'addon-boss' ); $dirty = $clean; } else { $clean = __( 'Nothing is saved until you press Save set.', 'addon-boss' ); $dirty = $clean; } echo '
'; echo ''; echo '' . esc_html( $new ? $dirty : $clean ) . ''; echo '
'; } /** * One switch with its name, and one line under it saying what it does. A * switch whose meaning flips with it says what each side does: the line * reads $hint while on and $hint_off while off. * * @param string $inputs The field markup: the checkbox, after any hidden * field that posts the "off" value. Built by the * caller from fixed strings and checked(). * @param string $title The switch's name. * @param string $hint The line under it (while on, when $hint_off is given). * @param string $hint_off The line while off; empty when one line serves both. * @param bool $on Whether it is on now, for the first paint. */ function addon_boss_editor_toggle( string $inputs, string $title, string $hint, string $hint_off = '', bool $on = true ): void { echo '
'; echo '' . esc_html( '' !== $hint_off && ! $on ? $hint_off : $hint ) . '
'; } /** * The line under One / Several: what a customer meets. A several-of set * quotes the very sentence the product page prints over its choices. * * Its own bounds first, so "At least 0" reads as a set customers may leave * empty, then what a product that requires it prints instead, where that * differs. Required or optional is each product's to say, and the line read * "Choose at least 1." under "At least 0" with no word of why (print shop * tester, 18 Oct 2026). * * @param bool $multi Whether customers may pick several. * @param int $min The fewest they must pick (0 or 1: no lower bound). * @param int $max The most they may pick (0: no upper bound). */ function addon_boss_editor_pick_say( bool $multi, int $min, int $max ): string { if ( ! $multi ) { return __( 'Customers pick one. Tapping another replaces it.', 'addon-boss' ); } $own = addon_boss_multi_hint( $min, $max ); $req = addon_boss_multi_hint( $min, $max, true ); /* translators: %s: the sentence customers read over the choices, e.g. Choose up to 3. */ $say = sprintf( __( 'Customers read it as “%s”', 'addon-boss' ), $own ); if ( $req !== $own ) { /* translators: %s: the sentence customers read over the choices on a product that requires the set, e.g. Choose at least 1. */ $say .= ' ' . sprintf( __( 'Where a product requires it: “%s”', 'addon-boss' ), $req ); } return $say; } /** * The sentence a several-of set prints over its choices, stating its rule. * One function for the product page and the editor, so the editor quotes * exactly what customers read. * * A required set takes one at least, whatever its own lower bound says: * "Choose as many as you like." stood over a set that Add to cart refuses * empty (workwear tester, 18 Oct 2026). * * @param int $min The fewest a customer must pick (0 or 1: no lower bound). * @param int $max The most a customer may pick (0: no upper bound). * @param bool $required Whether the set must be answered. */ function addon_boss_multi_hint( int $min, int $max, bool $required = false ): string { $min = $required ? max( 1, $min ) : max( 0, $min ); $max = max( 0, $max ); $floor = $min > 1 || $required; if ( $floor && $max > 0 ) { /* translators: 1: the smallest number of choices, 2: the largest number of choices */ return sprintf( __( 'Choose at least %1$s and up to %2$s.', 'addon-boss' ), number_format_i18n( $min ), number_format_i18n( $max ) ); } if ( $floor ) { /* translators: %s: the smallest number of choices */ return sprintf( __( 'Choose at least %s.', 'addon-boss' ), number_format_i18n( $min ) ); } if ( $max > 0 ) { /* translators: %s: the largest number of choices */ return sprintf( __( 'Choose up to %s.', 'addon-boss' ), number_format_i18n( $max ) ); } return __( 'Choose as many as you like.', 'addon-boss' ); } /** * Set simple products' own prices to what their cheapest size costs, from a * set editor's Shop page facts: shop and category pages show the product's * price, and on a whole-price set (measured sizes, a price table) nothing else * keeps it in step with the sizes. * * "Use £X" queues the price on the form and Save set applies it, with the * set, like everything else on the page. It used to set the price the moment * it was pressed, so a draft left unsaved left the shop showing a price for * sizes it did not yet sell (fresh testers, 18 Oct 2026). * * @param array $queued Product id => price, as the form posted them. * @return array The prices set, by product id. */ function addon_boss_editor_use_prices( array $queued ): array { $done = array(); foreach ( $queued as $pid => $price ) { $pid = absint( $pid ); $price = wc_format_decimal( sanitize_text_field( (string) $price ) ); $product = $pid ? wc_get_product( $pid ) : null; // A product on sale shows its sale price, which a new regular price // would not move: the shop page would still read the old figure. if ( ! $product instanceof WC_Product || ! current_user_can( 'edit_post', $pid ) || ! addon_boss_editor_price_settable( $product ) || '' === $price || (float) $price < 0 ) { continue; } $product->set_regular_price( $price ); $product->save(); $done[ $pid ] = (float) $product->get_regular_price( 'edit' ); } return $done; } /** * Can the set editors' "Use £X" set this product's price? Only a simple * product that is not on sale: a variable product's price moves with its * variations, and a sale price stays in front of any new regular price. * * @param WC_Product $product The product. */ function addon_boss_editor_price_settable( WC_Product $product ): bool { return $product->is_type( 'simple' ) && ! $product->is_on_sale( 'edit' ); } /** * The products a set is on, as the set editors' Shop page facts need them: * the ones the customer page offers, each with the one price its shop page * shows (none for a product whose price moves with its variations) and * whether "Use £X" can set that price from the editor. * * @param string $set_id The set ('' for one not saved yet). * @return array */ function addon_boss_editor_set_products( string $set_id ): array { $out = array(); if ( ! function_exists( 'addon_boss_set_preview_products' ) ) { return $out; } foreach ( addon_boss_set_preview_products( $set_id ) as $pid => $title ) { $product = wc_get_product( (int) $pid ); if ( ! $product instanceof WC_Product ) { continue; } $simple = $product->is_type( 'simple' ); $price = $simple ? (string) $product->get_price( 'edit' ) : ''; // What one typed-basis unit of money reads as on the shop's pages // (a million through the conversion, for the places), and the shop's // price suffix where it is plain words: the editor's "Customers pay" // says what the shop page says (walk, 30 Oct 2026). $suffix = (string) get_option( 'woocommerce_price_display_suffix', '' ); $out[] = array( 'id' => (int) $pid, 'title' => (string) $title, 'price' => '' === $price ? null : (float) $price, 'settable' => addon_boss_editor_price_settable( $product ), 'tk' => (float) wc_get_price_to_display( $product, array( 'price' => 1000000.0 ) ) / 1000000, 'suffix' => false === strpos( $suffix, '{' ) ? trim( wp_strip_all_tags( $suffix ) ) : '', ); } return $out; } /** * The frame's own stylesheet and script, on the option sets screen only. * * @param string $hook The admin page hook. */ function addon_boss_editor_frame_assets( string $hook ): void { if ( '' === $hook || ! function_exists( 'addon_boss_option_sets_hook' ) || addon_boss_option_sets_hook() !== $hook || ! defined( 'ADDON_BOSS_URL' ) ) { return; } $css = ADDON_BOSS_URL . 'css/editor.css'; $js = ADDON_BOSS_URL . 'js/editor.js'; wp_enqueue_style( 'addon-boss-editor', $css, array(), addon_boss_engine_asset_version( $css ) ); wp_enqueue_script( 'addon-boss-editor', $js, array(), addon_boss_engine_asset_version( $js ), true ); } add_action( 'admin_enqueue_scripts', 'addon_boss_editor_frame_assets', 20 );