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