' . '' . '' . ''; return 'data:image/svg+xml;base64,' . base64_encode( $svg ); } /** * Register the shared parent at priority 8, before any family plugin registers * its own submenus. Idempotent: only the first family plugin creates it. */ add_action( 'admin_menu', 'addon_boss_register_bossmage_parent', 8 ); function addon_boss_register_bossmage_parent(): void { global $admin_page_hooks; if ( isset( $admin_page_hooks['bossmage'] ) ) { return; // A sibling family plugin already created the parent. } if ( ! current_user_can( 'manage_woocommerce' ) ) { return; } add_menu_page( __( 'BossMage', 'addon-boss' ), __( 'BossMage', 'addon-boss' ), 'manage_woocommerce', 'bossmage', 'addon_boss_render_bossmage_hub', addon_boss_bossmage_icon(), // BELOW Settings (80) on purpose. A family hub earns a top-level item, but // not a prominent one: 55 sat immediately above WooCommerce and Appearance, // and an earlier build used 9, which put us among the core items. The admin // menu is a workspace, not somewhere to compete for attention. // NOTE: includes/bossmage-family-menu.php is a byte-for-byte sibling in // BundleBoss; that copy needs the same number so the two cannot drift. 81 ); // Re-label the auto-created first submenu "Home" rather than a duplicate parent. add_submenu_page( 'bossmage', __( 'Home', 'addon-boss' ), __( 'Home', 'addon-boss' ), 'manage_woocommerce', 'bossmage', 'addon_boss_render_bossmage_hub' ); } /** * Group the BossMage submenu by family plugin after every plugin has registered. * Runs late (priority 200); idempotent, so a sibling running the same sort is a * no-op. Order: Home, then each family member's screens together, then anything * unrecognised, then the account screens last. */ add_action( 'admin_menu', 'addon_boss_order_bossmage_menu', 200 ); function addon_boss_order_bossmage_menu(): void { global $submenu; if ( empty( $submenu['bossmage'] ) || ! is_array( $submenu['bossmage'] ) ) { return; } $members = array_keys( addon_boss_family_members() ); $rank = static function ( $slug ) use ( $members ) { $slug = (string) $slug; if ( 'bossmage' === $slug ) { return 0; } if ( false !== strpos( $slug, '-account' ) || false !== strpos( $slug, '-pricing' ) ) { return 9000; } foreach ( $members as $i => $m ) { if ( false !== strpos( $slug, (string) $m ) || ( 'bundle-boss' === $m && false !== strpos( $slug, 'product_type=bundle' ) ) ) { return ( $i + 1 ) * 100; } } return 5000; }; $decorated = array(); foreach ( $submenu['bossmage'] as $idx => $row ) { $decorated[] = array( $rank( $row[2] ?? '' ), $idx, $row ); } usort( $decorated, static function ( $a, $b ) { return ( $a[0] <=> $b[0] ) ?: ( $a[1] <=> $b[1] ); } ); // Tag each member group so the sidebar shows ownership: the first item of a // group (the plugin's landing) is the header, the rest are indented children. $sorted = array(); $prev_group = null; foreach ( $decorated as $d ) { $group = $d[0]; $row = $d[2]; if ( $group >= 100 && $group < 9000 && false === strpos( (string) $row[0], 'bmg-' ) ) { $class = ( $group !== $prev_group ) ? 'bmg-head' : 'bmg-sub'; $row[0] = '' . $row[0] . ''; } $prev_group = $group; $sorted[] = $row; } $submenu['bossmage'] = array_values( $sorted ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- menu ordering only. } /** * Sidebar CSS for the grouped BossMage submenu: bold plugin headers, indented * children. Output once (guarded), so it is present whichever family plugin runs. */ add_action( 'admin_enqueue_scripts', 'addon_boss_bossmage_menu_css' ); function addon_boss_bossmage_menu_css(): void { // This plugin's own guard, so the constant belongs to this plugin. A sibling // carries its own; the rules are identical and idempotent, so the worst case // is the same two lines of CSS arriving twice, which changes nothing on // screen. That is a better trade than a constant shared across plugins. if ( defined( 'ADDON_BOSS_MENU_CSS_DONE' ) ) { return; } define( 'ADDON_BOSS_MENU_CSS_DONE', true ); // Ridden in on core's own admin-menu stylesheet, which is on every admin // screen, so two rules about the admin menu need no stylesheet of their own. wp_add_inline_style( 'admin-menu', '#adminmenu #toplevel_page_bossmage .wp-submenu .bmg-head{font-weight:600;}' . '#adminmenu #toplevel_page_bossmage .wp-submenu .bmg-sub{padding-left:14px;display:inline-block;opacity:.92;}' ); } /** * AddOnBoss's own product mark, as a URL. * * The same artwork the wordpress.org listing uses. It has to live in img/ * rather than .wordpress-org, because that directory is export-ignored and so * ships in no build: the mark existed all along and simply never reached an * install. Empty when the file is missing, so every caller can fall back. */ function addon_boss_product_icon_url(): string { return file_exists( ADDON_BOSS_PATH . 'img/plugin-icon.png' ) ? ADDON_BOSS_URL . 'img/plugin-icon.png' : ''; } /** * The family plugins this site is running, built here rather than collected * through a shared hook. * * The parent menu is still shared: siblings meet on the well-known `bossmage` * menu slug, which is a page slug rather than a PHP name, so one menu is still * one menu however many family plugins are installed. What is NOT shared any * more is a global hook and a global constant, which are exactly the kinds of * name the directory asks every plugin to keep to itself. A sibling is detected * by something it already owns, so nothing has to be agreed between the two. * * @return array slug => card data */ function addon_boss_family_members(): array { $members = array(); // BundleBoss, when it is running alongside. Detected by its own product // class: no shared name, nothing for it to register, so it appears here // whatever version of BundleBoss is installed. if ( class_exists( 'Bundle_Boss_Product_Bundle' ) ) { $members['bundle-boss'] = array( 'name' => 'BundleBoss', 'badge' => 'BB', 'accent' => 'linear-gradient(135deg,#20408f,#2f8a5b)', 'desc' => __( 'Product bundles, build-a-box and quantity discounts.', 'addon-boss' ), 'links' => array( __( 'Bundles', 'addon-boss' ) => admin_url( 'edit.php?post_type=product&product_type=bundle' ), ), ); } $members['addon-boss'] = array( 'name' => 'AddOnBoss', // Without this the hub fell back to the lettered badge below, while // BundleBoss sat next to it showing a real logo: one finished product // and one placeholder, on the page that introduces the family. 'icon' => addon_boss_product_icon_url(), 'badge' => 'AO', 'accent' => 'linear-gradient(135deg,#2547b0,#ea5a2b)', 'desc' => __( 'Product options, custom fields, personalisation and add-ons on any product.', 'addon-boss' ), 'links' => array( __( 'Option sets', 'addon-boss' ) => admin_url( 'admin.php?page=addon-boss-option-sets' ), // The print areas screen is a top-level door in the menu and was the // only one the hub did not offer, so the card described the plugin and // then sent you to two thirds of it. __( 'Print areas', 'addon-boss' ) => admin_url( 'admin.php?page=addon-boss-positions' ), __( 'AddOnBoss', 'addon-boss' ) => admin_url( 'admin.php?page=addon-boss' ), ), // One line per door, keyed by the link's label, so the two stop // reading identical (which is the start was a coin toss before). // Rides its OWN key: a sibling's older renderer simply ignores it // and keeps drawing the plain chips, nothing breaks. 'link_descs' => array( __( 'Option sets', 'addon-boss' ) => __( 'Your library of reusable option sets, ready to attach to products.', 'addon-boss' ), __( 'Print areas', 'addon-boss' ) => __( 'Where printing and embroidery go on a product, marked on your own photos.', 'addon-boss' ), __( 'AddOnBoss', 'addon-boss' ) => __( 'The plugin home, with the setup checklist and settings.', 'addon-boss' ), ), ); return $members; } /** * The Home hub: a card per installed family plugin with its quick links, read * from whatever registered. Whichever plugin created the parent renders this; * both siblings read the same filter so the hub is identical either way. */ function addon_boss_render_bossmage_hub(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { return; } $members = addon_boss_family_members(); ?>

.bossmage-hub__tag { color:#646970; font-size:14px; margin:2px 0 20px; } .bossmage-hub__grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:16px; max-width:920px; } .bossmage-hub__card { background:#fff; border:1px solid #dcdcde; border-radius:10px; padding:16px 18px; } .bossmage-hub__head { display:flex; align-items:center; gap:11px; margin-bottom:8px; } .bossmage-hub__badge { width:32px; height:32px; border-radius:8px; display:flex; align-items:center; justify-content:center; color:#fff; font-weight:800; font-size:13px; } .bossmage-hub__logo { width:34px; height:34px; border-radius:8px; background:#f6f7fb; border:1px solid #e3e5ea; display:flex; align-items:center; justify-content:center; flex:0 0 34px; } .bossmage-hub__logo img { width:26px; height:26px; display:block; } .bossmage-hub__head h2 { margin:0; font-size:16px; padding:0; } .bossmage-hub__active { margin-left:auto; font-size:11px; font-weight:600; color:#2f8a5b; } .bossmage-hub__desc { color:#50575e; font-size:13px; margin:0 0 12px; } .bossmage-hub__links { display:flex; flex-wrap:wrap; gap:8px; } .bossmage-hub__links a { font-size:12.5px; font-weight:600; text-decoration:none; color:#20408f; background:rgba(32,64,143,.08); border:1px solid rgba(32,64,143,.16); padding:5px 11px; border-radius:7px; } .bossmage-hub__links--rows { flex-direction:column; align-items:flex-start; gap:9px; } .bossmage-hub__link { display:flex; align-items:baseline; gap:9px; flex-wrap:wrap; } .bossmage-hub__linkdesc { font-size:12px; color:#646970; }