is_type( 'variable' ) ) { return array(); } // Only attributes actually used for variations become sets. A variable // product often carries descriptive attributes too (Material, Brand) that // no variation varies on; those belong on the product, not in a chooser. $axes = array(); foreach ( $product->get_attributes() as $attr ) { if ( ! $attr instanceof WC_Product_Attribute || ! $attr->get_variation() ) { continue; } $slug = (string) $attr->get_name(); $terms = array(); foreach ( $attr->get_options() as $opt ) { if ( $attr->is_taxonomy() ) { $term = get_term( (int) $opt ); if ( $term instanceof WP_Term ) { $terms[ $term->slug ] = $term->name; } continue; } $terms[ sanitize_title( (string) $opt ) ] = (string) $opt; } if ( $terms ) { $axes[ $slug ] = array( 'label' => wc_attribute_label( $slug, $product ), 'terms' => $terms, ); } } if ( ! $axes ) { return array(); } // A stable option key per attribute value, so the same colour keeps the // same key across every cell and every stock row. $keys = array(); foreach ( $axes as $slug => $axis ) { foreach ( array_keys( $axis['terms'] ) as $value ) { $k = 'op_' . substr( md5( $slug . '|' . $value ), 0, 10 ); $keys[ $slug ][ $value ] = $k; } } $cells = array(); $stock = array(); $prices = array(); $images = 0; $skus = 0; $partial = 0; $onsale = 0; $parcels = array(); $total = 0; foreach ( $product->get_children() as $child_id ) { $var = wc_get_product( $child_id ); if ( ! $var instanceof WC_Product_Variation ) { continue; } ++$total; // A variation may leave an axis "any", which is not a pair and cannot // become a priced cell. Counted and reported rather than guessed at. $picks = array(); $whole = true; foreach ( array_keys( $axes ) as $slug ) { $value = (string) ( $var->get_attribute( $slug ) ); $value = '' !== $value ? sanitize_title( $value ) : ''; if ( '' === $value || ! isset( $keys[ $slug ][ $value ] ) ) { $whole = false; break; } $picks[ $slug ] = $keys[ $slug ][ $value ]; } if ( ! $whole ) { ++$partial; continue; } // The REGULAR price, never get_price(): that returns the sale price // while a variation is on sale, and a cell map holds one figure, so a // sale would become the permanent price and the shop would undercharge // for good once the sale ended. $price = $var->get_regular_price( 'edit' ); if ( '' === $price || null === $price ) { $price = $var->get_price( 'edit' ); } if ( '' !== $price && null !== $price ) { $cells[] = array( 'picks' => $picks, 'price' => (float) $price ); $prices[] = (float) $price; } if ( '' !== (string) $var->get_sale_price( 'edit' ) ) { ++$onsale; } if ( $var->managing_stock() && null !== $var->get_stock_quantity() ) { $stock[] = array( 'picks' => $picks, 'qty' => (int) $var->get_stock_quantity() ); } // What this pair weighs and measures. A 3XL hoodie is not the same // parcel as an XS tee, and a simple product has one of each, so these // travel in their own map beside the prices. $ship = array( 'picks' => $picks ); foreach ( array( 'weight', 'length', 'width', 'height' ) as $bb_f ) { $getter = 'get_' . $bb_f; $val = (string) $var->$getter( 'edit' ); if ( '' !== $val ) { $ship[ $bb_f ] = $val; } } if ( count( $ship ) > 1 ) { $parcels[] = $ship; } if ( '' !== (string) $var->get_sku() ) { ++$skus; } // 'edit', because the view context falls back to the PARENT image: every // variation then claims a photograph and the receipt promises pictures // that do not exist. Only a variation's own photo counts. if ( $var->get_image_id( 'edit' ) ) { ++$images; } } // The options the merchant had already attached ON TOP of the variations. // AddOnBoss works with variable products as they are, so a shop may have // been running fields and sets over this product for months; they are part // of what is being copied, and losing them would punish exactly the // merchants who used the plugin most. $attached = array(); foreach ( (array) get_post_meta( (int) $product->get_id(), '_bundle_boss_product_options', true ) as $bb_row ) { if ( is_array( $bb_row ) ) { $attached[] = $bb_row; } } // Reviews are comments on the post, so a copy does not bring them. Counted // directly rather than through WooCommerce's cached meta, which lags a // fresh review by one sync. $reviews = (int) get_comments( array( 'post_id' => (int) $product->get_id(), 'type' => 'review', 'status' => 'approve', 'count' => true, ) ); return array( 'source' => (int) $product->get_id(), 'name' => (string) $product->get_name(), 'axes' => $axes, 'keys' => $keys, 'base' => $prices ? (float) min( $prices ) : 0.0, 'cells' => $cells, 'stock' => $stock, 'parcels' => $parcels, 'attached' => $attached, 'counts' => array( 'variations' => $total, 'priced' => count( $cells ), 'stocked' => count( $stock ), 'skus' => $skus, 'images' => $images, 'partial' => $partial, 'onsale' => $onsale, 'parcels' => count( $parcels ), 'axes' => count( $axes ), 'attached' => count( $attached ), 'reviews' => $reviews, ), ); } /** * The plan as a receipt: one line per thing that happens, including the things * that do not. Every figure is the merchant's own. * * A receipt that only lists the wins is an advertisement. The losses are the * half a merchant needs before they decide, so they are not softened and they * are not left to be discovered later. * * @return array kind is 'keep' or 'lose'. */ function addon_boss_variation_plan_notes( array $plan ): array { if ( ! $plan ) { return array(); } $c = $plan['counts']; $notes = array(); // One count only: a second count in the same sentence cannot share the // plural switch, and "across 1 choices" shipped that way. The axes show // themselves as the set rows directly above this receipt. $notes[] = array( 'kind' => 'keep', 'text' => sprintf( /* translators: %s: number of variations */ _n( '%s variation becomes option sets on one product.', '%s variations become option sets on one product.', $c['variations'], 'addon-boss' ), number_format_i18n( $c['variations'] ) ), ); // Two true sentences, not one hedged one. A build that reads the pair map // charges each combination its exact figure; a build that does not charges // the choices' own extra charges, which the converter now fills in. Saying // "each pair keeping its own figure" on a build that cannot do it would be // the plugin telling a merchant their prices are right when they are close. // The test is the file, not the plan: the pair map is read wherever its // file is on disk, licence or not, so a lapse never changes a live charge. // // Extra charges add up, and a supplier's price list often does not: 3XL // costing more in Black than in White is ordinary. A build that cannot read // the pair map charges such a pair its choices added together, which is a // different figure from the one the shop set. Those pairs are counted out // of the carry-over line and named in a line of their own, so the two // figures add up to the prices read instead of claiming all of them. $exact = function_exists( 'addon_boss_combo_price_for' ); $uneven = $exact ? 0 : addon_boss_variation_uneven_pairs( $plan ); $notes[] = array( 'kind' => 'keep', 'text' => $exact ? sprintf( /* translators: 1: variations carrying a price, 2: variations in total */ __( '%1$s of %2$s prices carry over, each pair keeping its own figure.', 'addon-boss' ), number_format_i18n( $c['priced'] ), number_format_i18n( $c['variations'] ) ) : sprintf( /* translators: 1: prices the choices' extra charges reproduce exactly, 2: variations in total */ __( '%1$s of %2$s prices carry over as the extra charge on each choice.', 'addon-boss' ), number_format_i18n( max( 0, (int) $c['priced'] - $uneven ) ), number_format_i18n( $c['variations'] ) ), ); if ( $uneven > 0 ) { $notes[] = array( 'kind' => 'lose', 'text' => sprintf( /* translators: %s: number of size-and-colour pairs whose price will change */ _n( "%s pair will cost a different amount after the rebuild. This build adds each choice's extra charge together, and that pair was not priced that way.", "%s pairs will cost a different amount after the rebuild. This build adds each choice's extra charge together, and those pairs were not priced that way.", $uneven, 'addon-boss' ), number_format_i18n( $uneven ) ), ); } if ( $c['stocked'] > 0 ) { $notes[] = addon_boss_engine_can( 'option-stock' ) ? array( 'kind' => 'keep', 'text' => sprintf( /* translators: %s: number of variations holding a stock figure */ __( '%s stock figures carry over, one for each pair.', 'addon-boss' ), number_format_i18n( $c['stocked'] ) ), ) : array( 'kind' => 'lose', 'text' => sprintf( /* translators: %s: number of variations holding a stock figure */ __( '%s stock figures cannot carry over. This build counts stock on the product, not on a single choice, so the draft prices every pair but counts none of them.', 'addon-boss' ), number_format_i18n( $c['stocked'] ) ), ); } // Counted from the photographs the build will place, not from variations // that wear one: four variations can carry two pictures, and a picture all // of them share stays the product's own photograph. The picture following // the choice is a separate behaviour that only some builds have, so it is // promised only where it is kept. Every build shows a choice's photograph // on the choice itself, because a set whose choices carry pictures draws // them as photo cards. $photos = count( addon_boss_variation_choice_photos( $plan ) ); if ( $photos > 0 ) { $notes[] = array( 'kind' => 'keep', 'text' => addon_boss_engine_can( 'option-photos' ) ? sprintf( /* translators: %s: number of choices that will carry a photograph */ _n( '%s photograph carries over, so the picture still follows the choice.', '%s photographs carry over, so the picture still follows the choice.', $photos, 'addon-boss' ), number_format_i18n( $photos ) ) : sprintf( /* translators: %s: number of choices that will carry a photograph */ _n( '%s photograph carries over, shown on its choice.', '%s photographs carry over, each shown on its choice.', $photos, 'addon-boss' ), number_format_i18n( $photos ) ), ); } if ( ! empty( $c['parcels'] ) ) { $notes[] = array( 'kind' => 'keep', 'text' => sprintf( /* translators: %s: number of variations carrying a weight or dimensions */ __( '%s weights and dimensions carry over, so shipping is charged on the pair the shopper picked.', 'addon-boss' ), number_format_i18n( $c['parcels'] ) ), ); } if ( ! empty( $c['attached'] ) ) { $notes[] = array( 'kind' => 'keep', 'text' => sprintf( /* translators: %s: number of option sets and fields already attached to this product */ _n( '%s option set or field already on this product carries over.', '%s option sets and fields already on this product carry over.', $c['attached'], 'addon-boss' ), number_format_i18n( $c['attached'] ) ), ); } if ( ! empty( $c['onsale'] ) ) { $notes[] = array( 'kind' => 'lose', 'text' => sprintf( /* translators: %s: number of variations currently on sale */ __( '%s sale prices cannot carry over. A pair holds one figure, so the full price is kept and the sale would have become permanent.', 'addon-boss' ), number_format_i18n( $c['onsale'] ) ), ); } if ( $c['skus'] > 0 ) { $notes[] = array( 'kind' => 'lose', 'text' => sprintf( /* translators: %s: number of variations carrying their own SKU */ __( '%s SKUs cannot carry over. A product with options has one SKU, so a shop that works from the code of each combination should stay with variations.', 'addon-boss' ), number_format_i18n( $c['skus'] ) ), ); } if ( ! empty( $c['reviews'] ) ) { $notes[] = array( 'kind' => 'lose', 'text' => sprintf( /* translators: %s: number of approved reviews on the variable product */ _n( '%s review does not copy. It stays with the original until the copy takes its place, which brings it across.', '%s reviews do not copy. They stay with the original until the copy takes its place, which brings them across.', $c['reviews'], 'addon-boss' ), number_format_i18n( $c['reviews'] ) ), ); } if ( $c['partial'] > 0 ) { $notes[] = array( 'kind' => 'lose', 'text' => sprintf( /* translators: %s: number of variations that leave a choice as "any" */ __( '%s variations leave a choice as "any". These are skipped.', 'addon-boss' ), number_format_i18n( $c['partial'] ) ), ); } $notes[] = array( 'kind' => 'keep', 'text' => __( 'Your variable product is not touched. This makes a draft beside it.', 'addon-boss' ), ); return $notes; } /** * What each choice adds on its own, over the cheapest combination. * * The shape a supplier bridge already writes and the pair map already expects: * every axis but one held at its cheapest choice, so the deltas sum to the true * price wherever the grid is additive. Where it is not, the pair map carries the * exact figure and a build that can read it re-cuts these to match. * * This is what a build with no per-pair pricing charges from, so it is the * difference between a converted 3XL selling for its own price and selling for * the cheapest variation's. * * @param array $plan From addon_boss_variation_plan(). * @return array> axis slug => choice key => delta. */ function addon_boss_variation_choice_deltas( array $plan ): array { $cells = (array) ( $plan['cells'] ?? array() ); if ( ! $cells ) { return array(); } $base = (float) ( $plan['base'] ?? 0.0 ); // The cheapest combination: the one the base price already covers. $cheapest = null; foreach ( $cells as $cell ) { if ( null === $cheapest || (float) $cell['price'] < (float) $cheapest['price'] ) { $cheapest = $cell; } } $anchor_picks = (array) ( $cheapest['picks'] ?? array() ); $by_key = array(); foreach ( $cells as $cell ) { $picks = (array) $cell['picks']; ksort( $picks ); $by_key[ (string) wp_json_encode( $picks ) ] = (float) $cell['price']; } $out = array(); foreach ( (array) ( $plan['axes'] ?? array() ) as $slug => $axis ) { foreach ( array_keys( (array) $axis['terms'] ) as $value ) { $key = $plan['keys'][ $slug ][ $value ] ?? ''; if ( '' === (string) $key ) { continue; } // This choice, everything else at its cheapest. A cell's picks are // keyed by the generated option KEY, not the raw attribute value. $picks = $anchor_picks; $picks[ $slug ] = $key; ksort( $picks ); $price = $by_key[ (string) wp_json_encode( $picks ) ] ?? null; if ( null === $price ) { // That exact combination is not sold. The cheapest cell this // choice appears in is the honest answer: it never charges for // a step the shopper did not take. foreach ( $cells as $cell ) { if ( ( $cell['picks'][ $slug ] ?? null ) === $key && ( null === $price || (float) $cell['price'] < $price ) ) { $price = (float) $cell['price']; } } } if ( null !== $price ) { $out[ $slug ][ $key ] = addon_boss_round_money( (float) $price - $base, 2 ); } } } return $out; } /** * How many priced pairs a build without per-pair pricing would charge a * different figure: the pairs whose own price is not the base plus the extra * charge of each of their choices. * * Reads the same deltas the build writes, so the count is exactly the pairs * that will change, not an estimate of them. * * @param array $plan From addon_boss_variation_plan(). */ function addon_boss_variation_uneven_pairs( array $plan ): int { $deltas = addon_boss_variation_choice_deltas( $plan ); $base = (float) ( $plan['base'] ?? 0.0 ); $n = 0; foreach ( (array) ( $plan['cells'] ?? array() ) as $cell ) { $charged = $base; foreach ( (array) $cell['picks'] as $slug => $key ) { $charged += (float) ( $deltas[ $slug ][ $key ] ?? 0.0 ); } if ( abs( addon_boss_round_money( $charged, 2 ) - addon_boss_round_money( (float) $cell['price'], 2 ) ) >= 0.005 ) { ++$n; } } return $n; } /** * The photograph each choice will wear: choice key => attachment id. * * Read with 'edit', because the view context falls back to the parent image * and every choice on every axis would inherit the same picture. And a photo * only follows an axis it actually DISTINGUISHES: the shot on the black/S * variation belongs to Black, not to S, so an axis keeps its map only when at * least two of its choices ended up with different pictures. First one wins * within an axis: a colour is one colour however many sizes it comes in. * * The build reads it to place the pictures and the receipt reads it to count * them, so the two cannot disagree. Counting variations instead promised four * photographs where the build placed two, and promised pictures to a product * whose variations all share one, which the build rightly leaves on the * product rather than on any choice. * * @param array $plan From addon_boss_variation_plan(). * @return array */ function addon_boss_variation_choice_photos( array $plan ): array { $source = ! empty( $plan['source'] ) ? wc_get_product( (int) $plan['source'] ) : null; if ( ! $source instanceof WC_Product ) { return array(); } $axis_photos = array(); foreach ( $source->get_children() as $child_id ) { $var = wc_get_product( $child_id ); if ( ! $var instanceof WC_Product_Variation ) { continue; } $img = (int) $var->get_image_id( 'edit' ); if ( $img < 1 ) { continue; } foreach ( array_keys( (array) ( $plan['axes'] ?? array() ) ) as $slug ) { $value = sanitize_title( (string) $var->get_attribute( $slug ) ); $key = $plan['keys'][ $slug ][ $value ] ?? ''; if ( '' !== $key && ! isset( $axis_photos[ $slug ][ $key ] ) ) { $axis_photos[ $slug ][ $key ] = $img; } } } $photos = array(); foreach ( $axis_photos as $bb_map ) { if ( count( array_unique( $bb_map ) ) >= 2 ) { $photos += $bb_map; } } return $photos; } /** * Build the plan: option sets, then a draft product carrying them. * * The original is read and never written. The draft is left as a draft on * purpose, so nothing reaches a shopper until the merchant has looked at it. * * @return int The new draft product id, or 0 when nothing could be built. */ function addon_boss_variation_build( array $plan ): int { if ( ! $plan || ! function_exists( 'addon_boss_save_option_set' ) ) { return 0; } $source = wc_get_product( (int) $plan['source'] ); if ( ! $source instanceof WC_Product ) { return 0; } // Which photograph belongs to which choice: the same rule the receipt // counts by, so the receipt never promises a picture the build drops. $photos = addon_boss_variation_choice_photos( $plan ); // What each choice adds on its own, worked out the way a supplier bridge // writes it: every other choice held at its cheapest, so the figures add up // to the true price wherever the grid is additive. // // These are NOT redundant with the cell map below. A build that can charge // per pair RE-CUTS these so their sum is the cell exactly; a build that // cannot falls back to them and charges additively. Leaving them at zero // meant a free shop charged the cheapest variation's price for every // combination, so a 3XL that sold for 6.20 as a variation would have gone // out at 3.55 forever. $deltas = addon_boss_variation_choice_deltas( $plan ); // One set per varying attribute. $set_ids = array(); foreach ( $plan['axes'] as $slug => $axis ) { $options = array(); foreach ( $axis['terms'] as $value => $label ) { $key = $plan['keys'][ $slug ][ $value ]; $options[] = array( 'key' => $key, 'label' => $label, 'swatch' => '', 'swatch2' => '', 'desc' => '', 'image_id' => (int) ( $photos[ $key ] ?? 0 ), 'delta' => (float) ( $deltas[ $slug ][ $key ] ?? 0.0 ), 'dims' => array( 0.0, 0.0, 0.0 ), ); } $set_ids[ $slug ] = addon_boss_save_option_set( array( 'name' => $axis['label'], 'kind' => 'choices', 'options' => $options, ) ); } // The draft, built from the original rather than in place of it. $draft = new WC_Product_Simple(); $draft->set_name( sprintf( /* translators: %s: the name of the variable product this was copied from */ __( '%s (Copy)', 'addon-boss' ), $plan['name'] ) ); $draft->set_status( 'draft' ); $draft->set_description( $source->get_description() ); $draft->set_short_description( $source->get_short_description() ); $draft->set_category_ids( $source->get_category_ids() ); $draft->set_image_id( $source->get_image_id() ); $draft->set_gallery_image_ids( $source->get_gallery_image_ids() ); $draft->set_tax_status( $source->get_tax_status() ); $draft->set_tax_class( $source->get_tax_class() ); // Everything else a simple product can hold. WooCommerce's own Duplicate // clones the whole product and then clears a short list; copying a // hand-picked few instead is how a shipping class or a purchase note // disappears without anybody noticing. The reset list below matches Woo's. $draft->set_tag_ids( $source->get_tag_ids() ); $draft->set_featured( $source->get_featured() ); $draft->set_catalog_visibility( $source->get_catalog_visibility() ); $draft->set_menu_order( $source->get_menu_order() ); $draft->set_reviews_allowed( $source->get_reviews_allowed() ); $draft->set_purchase_note( $source->get_purchase_note() ); $draft->set_cross_sell_ids( $source->get_cross_sell_ids() ); $draft->set_upsell_ids( $source->get_upsell_ids() ); $draft->set_weight( $source->get_weight() ); $draft->set_length( $source->get_length() ); $draft->set_width( $source->get_width() ); $draft->set_height( $source->get_height() ); $draft->set_shipping_class_id( $source->get_shipping_class_id() ); // The attributes a variation never varied on (Material, Brand) still // describe the product, so they travel as ordinary product attributes. // The varying ones are becoming option sets and would only be noise here. $keep_attrs = array(); foreach ( $source->get_attributes() as $attr_key => $attr ) { if ( $attr instanceof WC_Product_Attribute && ! $attr->get_variation() ) { $keep_attrs[ $attr_key ] = $attr; } } $draft->set_attributes( $keep_attrs ); $draft->set_regular_price( (string) $plan['base'] ); $draft->set_price( (string) $plan['base'] ); $id = (int) $draft->save(); if ( $id < 1 ) { return 0; } $config = array(); foreach ( $set_ids as $sid ) { if ( '' !== (string) $sid ) { // Explicitly empty override maps, so the stored row has the same // shape the admin form writes and nothing downstream has to guess. $config[] = array( 'type' => 'set', 'set' => $sid, 'required' => true, 'deltas' => array(), 'images' => array(), ); } } // What was already attached on top of the variations follows the converted // sets, mirroring the old page's order: the variation pickers came first, // the add-ons after them. foreach ( (array) ( $plan['attached'] ?? array() ) as $bb_row ) { if ( is_array( $bb_row ) ) { $config[] = $bb_row; } } $draft->update_meta_data( '_bundle_boss_product_options', $config ); // Every pair's own price, in the ONE currency the cell map speaks: a cell // is the pair's price LESS THE BASE, never the price itself. The base is // already on the product and the resolve boundary adds the cells on top of // it, so a whole price written here is charged twice over: a £6.20 shirt // went out at £9.75, with the page still saying £5.89. The sets are sorted // because the key spelling is a convention every writer shares. $sets = array_values( array_filter( $set_ids ) ); sort( $sets ); $rows = array(); foreach ( $plan['cells'] as $cell ) { $picks = array(); foreach ( $plan['axes'] as $slug => $ignored ) { $sid = $set_ids[ $slug ] ?? ''; if ( '' !== (string) $sid && isset( $cell['picks'][ $slug ] ) ) { $picks[ $sid ] = $cell['picks'][ $slug ]; } } if ( count( $picks ) === count( $sets ) ) { $rows[] = array( 'picks' => $picks, 'delta' => round( (float) $cell['price'] - (float) $plan['base'], 4 ), ); } } if ( $rows && function_exists( 'addon_boss_combo_prices_put' ) ) { // The sanctioned writer where it exists: it floors a negative and drops // a cell it cannot key, so the converter keeps no second spelling of // either rule. It saves the product itself. $draft->save(); addon_boss_combo_prices_put( $id, $sets, $rows ); $draft = wc_get_product( $id ); } elseif ( $rows && function_exists( 'addon_boss_combo_price_key' ) ) { // A free build writes the map too. It cannot READ it — per-pair pricing // is Pro — but the figures are the merchant's own, and throwing them // away here would mean re-converting the day they upgrade. $cells = array(); foreach ( $rows as $row ) { $key = addon_boss_combo_price_key( $row['picks'], $sets ); if ( '' !== $key ) { $cells[ $key ] = max( 0.0, (float) $row['delta'] ); } } if ( $cells ) { $draft->update_meta_data( '_addon_boss_combo_prices', array( 'sets' => $sets, 'cells' => $cells ) ); } $draft->save(); } else { $draft->save(); } // What each pair weighs and measures, so a courier quotes the right parcel // for a 3XL rather than the product's single figure for every size. if ( ! empty( $plan['parcels'] ) && function_exists( 'addon_boss_combo_ship_put' ) ) { $rows = array(); foreach ( $plan['parcels'] as $row ) { $picks = array(); foreach ( $plan['axes'] as $slug => $ignored ) { $sid = $set_ids[ $slug ] ?? ''; if ( '' !== (string) $sid && isset( $row['picks'][ $slug ] ) ) { $picks[ $sid ] = $row['picks'][ $slug ]; } } if ( count( $picks ) === count( $sets ) ) { $rows[] = array_merge( $row, array( 'picks' => $picks ) ); } } if ( $rows ) { addon_boss_combo_ship_put( $id, $sets, $rows ); } } // What is on the shelf, pair by pair. Silently absent without the plan, the // way every other Pro capability is: the merchant's figures are not written // somewhere they would rot, they simply stay on the original. if ( $plan['stock'] && function_exists( 'addon_boss_combo_stock_put' ) && addon_boss_engine_can( 'option-stock' ) ) { $pairs = array(); $left = 0; foreach ( $plan['stock'] as $row ) { $picks = array(); foreach ( $plan['axes'] as $slug => $ignored ) { $sid = $set_ids[ $slug ] ?? ''; if ( '' !== (string) $sid && isset( $row['picks'][ $slug ] ) ) { $picks[ $sid ] = $row['picks'][ $slug ]; } } if ( count( $picks ) === count( $sets ) ) { $pairs[] = array( 'picks' => $picks, 'qty' => (int) $row['qty'] ); $left += (int) $row['qty']; } } if ( $pairs ) { addon_boss_combo_stock_put( $id, $sets, $pairs ); $fresh = wc_get_product( $id ); if ( $fresh instanceof WC_Product ) { $fresh->set_manage_stock( true ); $fresh->set_stock_quantity( $left ); $fresh->set_stock_status( $left > 0 ? 'instock' : 'outofstock' ); $fresh->save(); } } } // Both ends of the trip remember each other, so a second visit to the // variable product offers the draft that exists rather than quietly // building a second one and a second copy of every set with it. $source->update_meta_data( ADDON_BOSS_CONVERTED_TO, $id ); $source->save(); update_post_meta( $id, ADDON_BOSS_CONVERTED_FROM, (int) $plan['source'] ); // The count on the checklist is now wrong by one product; ask fresh next time. delete_transient( 'addon_boss_variation_load' ); return $id; } /** * The draft already built from this product, or 0. * * Heals itself: a merchant who looked at a draft, disagreed and binned it gets * the offer back rather than a link to a post that is not there. */ function addon_boss_variation_built_draft( int $product_id ): int { $draft = (int) get_post_meta( $product_id, ADDON_BOSS_CONVERTED_TO, true ); if ( $draft < 1 ) { return 0; } $post = get_post( $draft ); if ( ! $post instanceof WP_Post || 'trash' === $post->post_status ) { delete_post_meta( $product_id, ADDON_BOSS_CONVERTED_TO ); return 0; } return $draft; } // --------------------------------------------------------------------- // The offer itself. It lives INSIDE the Product options tab, below the // attach UI: AddOnBoss works on top of variations without converting // anything, so the tab leads with that and the rebuild is the card // underneath, never the room. Two more doors lead here: a row action on // variable products in the list, and the landing checklist's count line. // --------------------------------------------------------------------- /** Who may do this: the same capability that may edit the product in the first place. */ function addon_boss_variation_convert_allowed( int $product_id ): bool { return current_user_can( 'edit_post', $product_id ); } /** * A row action on variable products in the list: the offer sits on the thing * itself. It opens the product with the card in view and builds nothing. */ add_filter( 'post_row_actions', 'addon_boss_variation_row_action', 10, 2 ); function addon_boss_variation_row_action( array $actions, $post ): array { if ( ! $post instanceof WP_Post || 'product' !== $post->post_type || 'trash' === $post->post_status || ! addon_boss_variation_convert_allowed( (int) $post->ID ) ) { return $actions; } $product = wc_get_product( $post->ID ); if ( ! $product instanceof WC_Product || ! $product->is_type( 'variable' ) ) { return $actions; } $actions['addon_boss_rebuild'] = '' . esc_html__( 'Options instead of variations', 'addon-boss' ) . ''; return $actions; } /** The offer card, at the bottom of the Product options tab on a variable product. */ add_action( 'addon_boss_options_panel_after', 'addon_boss_variation_offer_card' ); function addon_boss_variation_offer_card( $product ): void { if ( ! $product instanceof WC_Product || ! $product->is_type( 'variable' ) || ! addon_boss_variation_convert_allowed( (int) $product->get_id() ) ) { return; } $plan = addon_boss_variation_plan( (int) $product->get_id() ); $notes = addon_boss_variation_plan_notes( $plan ); if ( ! $notes ) { return; // Nothing varies, so there is nothing to offer. } // The not-touched promise prints beside the button, where the hesitation // happens, rather than as one more receipt line. $safe = __( 'Your variable product is not touched. This makes a draft beside it.', 'addon-boss' ); $keeps = array(); $loses = array(); foreach ( $notes as $note ) { if ( $note['text'] === $safe ) { continue; } if ( 'keep' === $note['kind'] ) { $keeps[] = $note['text']; } else { $loses[] = $note['text']; } } echo '
'; echo '
' . esc_html__( 'Options instead of variations', 'addon-boss' ) . '' . esc_html__( 'Rather run this as one product? A rebuild makes a draft beside this one.', 'addon-boss' ) . '
'; // The sets the rebuild would make, with the shop's own choice names on // them, because "2 option sets" is an argument and "Colour: Black, Navy, // Red" is their Tuesday. foreach ( $plan['axes'] as $axis ) { echo '
' . esc_html( (string) $axis['label'] ) . ''; $shown = 0; foreach ( (array) $axis['terms'] as $label ) { if ( $shown >= 8 ) { break; } echo '' . esc_html( (string) $label ) . ''; $shown++; } $more = count( (array) $axis['terms'] ) - $shown; if ( $more > 0 ) { echo '' . esc_html( sprintf( /* translators: %s: how many more there are beyond the ones shown */ __( '+%s more', 'addon-boss' ), number_format_i18n( $more ) ) ) . ''; } echo '
'; } // A heading with nothing under it reads as a list that failed to load. A // simple shop often loses nothing at all, so each column only appears when // it has something to say, and one column alone takes the full width. echo '
'; if ( $keeps ) { echo '

' . esc_html__( 'Carries over', 'addon-boss' ) . '

    '; foreach ( $keeps as $bb_t ) { echo '
  • ' . esc_html( $bb_t ) . '
  • '; } echo '
'; } if ( $loses ) { echo '

' . esc_html__( 'Does not', 'addon-boss' ) . '

    '; foreach ( $loses as $bb_t ) { echo '
  • ' . esc_html( $bb_t ) . '
  • '; } echo '
'; } echo '
'; $url = wp_nonce_url( admin_url( 'admin-post.php?action=addon_boss_variation_convert&product=' . (int) $product->get_id() ), 'addon_boss_variation_convert_' . (int) $product->get_id() ); // Already done once. Lead with the draft rather than the offer: building // again would make a second draft AND a second copy of every set, which is // how a library fills with duplicate Colours nobody meant to create. $built = addon_boss_variation_built_draft( (int) $product->get_id() ); echo '
'; if ( empty( $plan['counts']['priced'] ) ) { // Every pair fell out of the plan (all "any" rows, or nothing priced). // Offering the button anyway would build a draft that charges nothing. echo '' . esc_html__( 'There is nothing to build yet: no combination carries a price of its own. The lines above say why.', 'addon-boss' ) . ''; } elseif ( $built > 0 ) { echo '' . esc_html__( 'Open the copy', 'addon-boss' ) . ''; echo '' . esc_html__( 'Duplicate it again', 'addon-boss' ) . ''; echo '' . esc_html__( 'This product already has an option-set copy.', 'addon-boss' ) . ''; } else { // The ask before the build. A stray draft and a stray copy of every set // is a tidy-up job nobody chose, and this button sits under a panel // people scroll through, so a mis-click is the likely way to get one. printf( '%5$s', esc_url( $url ), esc_attr__( 'Duplicating…', 'addon-boss' ), esc_attr( sprintf( /* translators: %s: the variable product a draft would be built from */ __( 'Build a draft copy of %s with option sets?', 'addon-boss' ), $product->get_name() ) ), esc_attr__( 'Build the draft', 'addon-boss' ), esc_html__( 'Duplicate with option sets', 'addon-boss' ) ); echo '' . esc_html( $safe ) . ''; } echo '
'; echo '
'; // The ask, and then the busy state the ask hands over to. addon_boss_variation_confirm_js(); // The row action's landing: WooCommerce's tabs are driven by its own ready // handler, so the switch runs from ready too, after the tab exists. The // scroll is window-only: scrollIntoView walks every scrollable ancestor, // and WooCommerce's .panel-wrap is overflow:hidden yet programmatically // scrollable, so it shifted the tab rail out of view with no scrollbar // to bring it back and the whole box read as broken. wp_print_inline_script_tag( 'jQuery(function(){if(!/[?&]bb_rebuild=1/.test(window.location.search)){return;}' . 'var t=document.querySelector(\'.product_data_tabs a[href="#bundle_boss_options_panel"]\');if(t){t.click();}' . 'var c=document.getElementById("addon-boss-variation-convert");if(c){var r=c.getBoundingClientRect();' . 'window.scrollTo({top:Math.max(0,r.top+window.pageYOffset-Math.max(80,(window.innerHeight-r.height)/2))});}});' ); } // --------------------------------------------------------------------- // After the build: the check. The draft's own tab opens with real pairs // to spot-check, then offers the finish as one confirmed move. // --------------------------------------------------------------------- /** One dismissal, this product only: the card never returns on it. */ const ADDON_BOSS_CHECK_DISMISSED = '_addon_boss_check_dismissed'; /** * Three pairs from the stored map: cheapest, one from the middle, dearest. * Checking the ends catches a wrong step faster than checking neighbours. * * @return array */ function addon_boss_variation_check_rows( int $draft_id ): array { $map = get_post_meta( $draft_id, '_addon_boss_combo_prices', true ); if ( ! is_array( $map ) || empty( $map['cells'] ) || ! is_array( $map['cells'] ) ) { return array(); } // A cell is the pair's charge OVER the base, so the receipt adds the base // back to print the figure the shopper actually sees. Reading the cell as // a whole price here would show the right number for the wrong reason and // hide the day the convention drifts. $bb_prod = function_exists( 'wc_get_product' ) ? wc_get_product( $draft_id ) : null; $bb_base = $bb_prod instanceof WC_Product ? (float) $bb_prod->get_regular_price() : 0.0; $sets = function_exists( 'addon_boss_option_sets' ) ? addon_boss_option_sets() : array(); $labels = array(); foreach ( (array) ( $map['sets'] ?? array() ) as $sid ) { foreach ( (array) ( $sets[ $sid ]['options'] ?? array() ) as $opt ) { if ( isset( $opt['key'], $opt['label'] ) ) { $labels[ $sid . '::' . $opt['key'] ] = (string) $opt['label']; } } } // A stored key sorts its set ids so one pair always keys the same way. The // merchant reads this card against their own variations screen, where the // sets sit in the order they arranged them, so the label follows that order // rather than the storage one. Otherwise a Colour-then-Size product checks // out as "M · Black" and every row costs them a mental flip. $order = array(); foreach ( (array) get_post_meta( $draft_id, ADDON_BOSS_PRODUCT_OPTIONS_META, true ) as $bb_row ) { if ( 'set' === ( $bb_row['type'] ?? '' ) && ! empty( $bb_row['set'] ) ) { $order[] = (string) $bb_row['set']; } } $cells = array(); foreach ( $map['cells'] as $key => $price ) { $parts = array(); foreach ( explode( '|', (string) $key ) as $piece ) { if ( ! isset( $labels[ $piece ] ) ) { continue; } $rank = array_search( (string) strstr( $piece, '::', true ), $order, true ); $parts[ false === $rank ? 900 + count( $parts ) : $rank ] = $labels[ $piece ]; } if ( ! $parts ) { continue; } ksort( $parts ); $cells[] = array( 'label' => implode( ' · ', $parts ), 'price' => addon_boss_round_money( $bb_base + (float) $price, 2 ), ); } if ( ! $cells ) { return array(); } usort( $cells, static fn( $a, $b ) => $a['price'] <=> $b['price'] ); $rows = array(); foreach ( array_unique( array( 0, intdiv( count( $cells ), 2 ), count( $cells ) - 1 ) ) as $i ) { $rows[] = $cells[ $i ]; } return $rows; } /** * Whether the product a draft was built from counted stock on any variation. * * The same test the plan uses to gather stock rows, so the check card and the * receipt agree on whether there was stock to speak about. */ function addon_boss_variation_source_held_stock( int $draft_id ): bool { $source = wc_get_product( (int) get_post_meta( $draft_id, ADDON_BOSS_CONVERTED_FROM, true ) ); if ( ! $source instanceof WC_Product ) { return false; } foreach ( $source->get_children() as $child_id ) { $var = wc_get_product( $child_id ); if ( $var instanceof WC_Product && $var->managing_stock() && null !== $var->get_stock_quantity() ) { return true; } } return false; } /** How many choice photographs the converted sets carry, for the check card's line. */ function addon_boss_variation_check_photo_count( int $draft_id ): int { $map = get_post_meta( $draft_id, '_addon_boss_combo_prices', true ); $sets = function_exists( 'addon_boss_option_sets' ) ? addon_boss_option_sets() : array(); $n = 0; foreach ( (array) ( is_array( $map ) ? ( $map['sets'] ?? array() ) : array() ) as $sid ) { foreach ( (array) ( $sets[ $sid ]['options'] ?? array() ) as $opt ) { if ( ! empty( $opt['image_id'] ) ) { $n++; } } } return $n; } /** * The one confirm step both converter buttons wear, printed once per screen. * * Neither button destroys anything, so this is not a warning; it is a pause. * The duplicate leaves a stray draft AND a stray copy of every set behind, and * the swap moves a live web address, and both sit inside a panel people scroll * through, which is exactly where a mis-click happens. The question states what * is about to happen in the merchant's own nouns and names the product, so a * person with several drafts open cannot confirm the wrong one. * * Focus lands on Cancel: for a guard whose whole job is stopping an accidental * activation, the safe answer is the one a stray keypress should reach first. * * With JavaScript off the links simply work, as they always did. The step is a * courtesy, never the authorisation: that is the nonce on the URL. */ function addon_boss_variation_confirm_js(): void { static $printed = false; if ( $printed ) { return; } $printed = true; $words = wp_json_encode( array( 'cancel' => __( 'Cancel', 'addon-boss' ), 'aria' => __( 'Please confirm', 'addon-boss' ), ) ); wp_print_inline_script_tag( '(function(){var W=' . $words . ';' . 'document.addEventListener("click",function(e){' . 'var a=e.target&&e.target.closest?e.target.closest("[data-bb-ask]"):null;if(!a){return;}' . 'if(a.dataset.bbGone){e.preventDefault();return;}' . 'e.preventDefault();e.stopImmediatePropagation();' . 'var row=a.closest(".bb-vc-acts,.bb-vc-handacts");if(!row||row.dataset.bbAsking){return;}' . 'row.dataset.bbAsking="1";' . 'var box=document.createElement("div");box.className="bb-vc-ask"+("heavy"===a.dataset.bbWeight?" is-heavy":"");' . 'box.setAttribute("role","group");box.setAttribute("aria-label",W.aria);' . 'var q=document.createElement("p");q.className="bb-vc-askq";q.textContent=a.dataset.bbAsk;box.appendChild(q);' . 'var acts=document.createElement("div");acts.className="bb-vc-askacts";' . 'var yes=document.createElement("button");yes.type="button";yes.className="button bb-cta bb-vc-askyes";yes.textContent=a.dataset.bbYes;' . 'var no=document.createElement("button");no.type="button";no.className="button bb-vc-quiet bb-vc-askno";no.textContent=W.cancel;' . 'acts.appendChild(yes);acts.appendChild(no);box.appendChild(acts);' . 'row.style.display="none";row.parentNode.insertBefore(box,row.nextSibling);' . 'function close(){box.remove();row.style.display="";delete row.dataset.bbAsking;' . 'document.removeEventListener("keydown",esc);try{a.focus();}catch(x){}}' . 'function esc(ev){if("Escape"===ev.key){close();}}' . 'document.addEventListener("keydown",esc);' . 'no.addEventListener("click",close);' . 'yes.addEventListener("click",function(){' . 'a.dataset.bbGone="1";' . 'if(a.dataset.bbBusy){yes.textContent=a.dataset.bbBusy;}' . 'yes.classList.add("disabled");no.classList.add("disabled");no.disabled=true;yes.disabled=true;' . 'window.location.href=a.href;});' . 'try{no.focus();}catch(x){}' . '},true);}());' ); } /** The check card, at the top of the Product options tab on a converted draft. */ add_action( 'addon_boss_options_panel_before', 'addon_boss_variation_check_card' ); function addon_boss_variation_check_card( $product ): void { if ( ! $product instanceof WC_Product ) { return; } $id = (int) $product->get_id(); if ( 'draft' !== get_post_status( $id ) || get_post_meta( $id, ADDON_BOSS_CHECK_DISMISSED, true ) ) { return; } $source_id = (int) get_post_meta( $id, ADDON_BOSS_CONVERTED_FROM, true ); $src = $source_id > 0 ? get_post( $source_id ) : null; if ( ! $src instanceof WP_Post || ! addon_boss_variation_convert_allowed( $id ) ) { return; } $name = $src->post_title; echo '
'; printf( '

%1$s %2$s

', esc_html( sprintf( /* translators: %s: the variable product this draft was built from */ __( 'Built from %s.', 'addon-boss' ), $name ) ), esc_html__( 'This is a draft: nothing reaches a shopper until you publish it. Check a pair or two before you do:', 'addon-boss' ) ); $rows = addon_boss_variation_check_rows( $id ); if ( $rows ) { echo ''; foreach ( $rows as $row ) { // aob-money-raw: an admin check against the merchant's own stored figure. // "Charged before" is the variation's price as entered, the number they // compare the draft's page against, not a charge on a display basis. echo ''; } echo '
' . esc_html__( 'Pick', 'addon-boss' ) . '' . esc_html__( 'Charged before', 'addon-boss' ) . '
' . esc_html( $row['label'] ) . '' . wp_kses_post( wc_price( $row['price'] ) ) . '
'; } echo '
    '; // Each line asks the merchant to look for what this build will show. Every // build shows a choice's photograph on the choice; only a build with option // photographs also swaps the main picture, so only that build asks for it. $photos = addon_boss_variation_check_photo_count( $id ); if ( $photos > 0 && addon_boss_engine_can( 'option-photos' ) ) { echo '
  • ' . esc_html( sprintf( /* translators: %s: number of choice photographs the converted sets carry */ _n( '%s photograph came across. On the page, the picture should follow the choice.', '%s photographs came across. On the page, the picture should follow the choice.', $photos, 'addon-boss' ), number_format_i18n( $photos ) ) ) . '
  • '; } elseif ( $photos > 0 ) { echo '
  • ' . esc_html( sprintf( /* translators: %s: number of choice photographs the converted sets carry */ _n( '%s photograph came across. On the page, it shows on its choice.', '%s photographs came across. On the page, each shows on its choice.', $photos, 'addon-boss' ), number_format_i18n( $photos ) ) ) . '
  • '; } // Stock is claimed only when a pair holds a figure, which needs a build // that counts per pair AND a source that counted at all. A source that held // figures this build cannot keep gets the line saying where stock counts // now; a source that counted nothing gets no line, as the receipt says // nothing about it either. $pairs = addon_boss_engine_can( 'option-stock' ) && function_exists( 'addon_boss_combo_stock_all' ) ? addon_boss_combo_stock_all( $id ) : array(); if ( $pairs ) { echo '
  • ' . esc_html__( 'Stock came across, one figure for each pair.', 'addon-boss' ) . '
  • '; } elseif ( addon_boss_variation_source_held_stock( $id ) ) { echo '
  • ' . esc_html__( 'Stock counts on the product here. Set one figure under Inventory if you manage stock.', 'addon-boss' ) . '
  • '; } echo '
'; echo '
' . esc_html( sprintf( /* translators: %s: the variable product this draft was built from */ __( "When it reads right, this draft can take the original's place: the name and the web address change hands, the reviews come across, and %s retires to a draft. Nothing is deleted.", 'addon-boss' ), $name ) ) . '
'; $swap_url = wp_nonce_url( admin_url( 'admin-post.php?action=addon_boss_variation_swap&product=' . $id ), 'addon_boss_variation_swap_' . $id ); $dismiss_url = wp_nonce_url( admin_url( 'admin-post.php?action=addon_boss_variation_check_dismiss&product=' . $id ), 'addon_boss_variation_dismiss_' . $id ); echo '
'; // The heavier ask of the two: this one publishes, moves a live web address // and retires the original. The question names the product so a merchant // with several drafts open cannot confirm the wrong one. printf( '%4$s', esc_url( $swap_url ), esc_attr( sprintf( /* translators: %s: the original variable product's name */ __( 'Publish this copy and give it the name and web address of %s? The original retires to a draft. Nothing is deleted.', 'addon-boss' ), $name ) ), esc_attr__( 'Yes, swap them', 'addon-boss' ), esc_html__( 'Publish and take its place', 'addon-boss' ) ); echo '' . esc_html( sprintf( /* translators: %s: the original variable product's name */ __( 'Open %s', 'addon-boss' ), $name ) ) . ''; echo '' . esc_html__( 'Dismiss', 'addon-boss' ) . ''; echo '
'; echo '
'; // The swap is asked for before it runs, and the ask also swallows a second // click: two racing swap requests would re-suffix the retired original's // address for nothing. addon_boss_variation_confirm_js(); // The build's redirect lands here, and this card IS the landing message // (the old success notice is gone). A card inside a closed tab is a notice // nobody received, so on that arrival the tab opens itself. wp_print_inline_script_tag( 'jQuery(function(){if(!/[?&]addon_boss_convert=built/.test(window.location.search)){return;}' . 'var t=document.querySelector(\'.product_data_tabs a[href="#bundle_boss_options_panel"]\');if(t){t.click();}' . 'var c=document.querySelector(".bb-vc-check");if(c){c.scrollIntoView({block:"center"});}});' ); } /** * Swap places: the draft goes live under the original's name and address, * the reviews come across, and the original retires to a draft. One move * doing the four finishing steps a merchant would otherwise do by hand, * each forgettable. Nothing is deleted. * * Requires the capability to edit BOTH products: this is the one place a * product's screen changes another product, so it is explicit, named on the * button, and double-checked here. */ function addon_boss_variation_swap( int $draft_id ): bool { $source_id = (int) get_post_meta( $draft_id, ADDON_BOSS_CONVERTED_FROM, true ); $src = $source_id > 0 ? get_post( $source_id ) : null; $dft = get_post( $draft_id ); if ( ! $src instanceof WP_Post || ! $dft instanceof WP_Post ) { return false; } if ( ! current_user_can( 'edit_post', $draft_id ) || ! current_user_can( 'edit_post', $source_id ) ) { return false; } $old_slug = $src->post_name; $old_title = $src->post_title; // The original steps aside first, freeing the address for the copy to take // exactly: a draft still holds its slug, so without the rename the copy // would publish at team-tee-2 and the old address would go nowhere. wp_update_post( array( 'ID' => $source_id, 'post_status' => 'draft', 'post_name' => $old_slug . '-variations', ) ); // The reviews are the shop's history and belong to the product shoppers // will meet, not to a retired draft they cannot reach. $comment_ids = get_comments( array( 'post_id' => $source_id, 'fields' => 'ids', 'status' => 'any', ) ); foreach ( $comment_ids as $cid ) { wp_update_comment( array( 'comment_ID' => (int) $cid, 'comment_post_ID' => $draft_id, ) ); } wp_update_comment_count( $source_id ); wp_update_comment_count( $draft_id ); if ( class_exists( 'WC_Comments' ) && method_exists( 'WC_Comments', 'clear_transients' ) ) { WC_Comments::clear_transients( $source_id ); WC_Comments::clear_transients( $draft_id ); } wp_update_post( array( 'ID' => $draft_id, 'post_status' => 'publish', 'post_title' => $old_title, 'post_name' => $old_slug, ) ); if ( function_exists( 'wc_delete_product_transients' ) ) { wc_delete_product_transients( $source_id ); wc_delete_product_transients( $draft_id ); } return true; } function addon_boss_variation_swap_handle(): void { $draft = isset( $_GET['product'] ) ? (int) $_GET['product'] : 0; check_admin_referer( 'addon_boss_variation_swap_' . $draft ); if ( $draft < 1 || ! addon_boss_variation_swap( $draft ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to swap these products.', 'addon-boss' ), '', array( 'response' => 403 ) ); } wp_safe_redirect( add_query_arg( 'addon_boss_convert', 'swapped', get_edit_post_link( $draft, 'url' ) ) ); exit; } add_action( 'admin_post_addon_boss_variation_swap', 'addon_boss_variation_swap_handle' ); function addon_boss_variation_check_dismiss_handle(): void { $draft = isset( $_GET['product'] ) ? (int) $_GET['product'] : 0; check_admin_referer( 'addon_boss_variation_dismiss_' . $draft ); if ( $draft < 1 || ! current_user_can( 'edit_post', $draft ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to edit this product.', 'addon-boss' ), '', array( 'response' => 403 ) ); } update_post_meta( $draft, ADDON_BOSS_CHECK_DISMISSED, '1' ); wp_safe_redirect( (string) get_edit_post_link( $draft, 'url' ) ); exit; } add_action( 'admin_post_addon_boss_variation_check_dismiss', 'addon_boss_variation_check_dismiss_handle' ); /** Build it, then put the merchant in front of the draft rather than a message about it. */ function addon_boss_variation_convert_handle(): void { $id = isset( $_GET['product'] ) ? (int) $_GET['product'] : 0; if ( $id < 1 || ! addon_boss_variation_convert_allowed( $id ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to duplicate this product.', 'addon-boss' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'addon_boss_variation_convert_' . $id ); $draft = addon_boss_variation_build( addon_boss_variation_plan( $id ) ); if ( $draft < 1 ) { wp_safe_redirect( add_query_arg( 'addon_boss_convert', 'failed', get_edit_post_link( $id, 'url' ) ) ); exit; } wp_safe_redirect( add_query_arg( 'addon_boss_convert', 'built', get_edit_post_link( $draft, 'url' ) ) ); exit; } add_action( 'admin_post_addon_boss_variation_convert', 'addon_boss_variation_convert_handle' ); /** What happened, said on the screen the merchant lands on. */ add_action( 'admin_notices', static function () { $state = isset( $_GET['addon_boss_convert'] ) ? sanitize_key( wp_unslash( $_GET['addon_boss_convert'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading our own redirect flag to print a notice. // 'built' prints nothing: the check card at the top of the Product // options tab IS the landing message, and saying it twice on one // screen is how the box card came to repeat its fill instruction. if ( 'swapped' === $state ) { echo '

' . esc_html__( 'Live. This product now holds the name, the web address and the reviews. The variable original is retired to a draft.', 'addon-boss' ) . '

'; return; } if ( 'failed' === $state ) { echo '

' . esc_html__( 'Nothing was changed. This product has no attributes its variations vary on.', 'addon-boss' ) . '

'; } } );