| 1 |
<?php |
| 2 |
|
| 3 |
namespace StoreEngine\Admin; |
| 4 |
|
| 5 |
use StoreEngine\Utils\Helper; |
| 6 |
|
| 7 |
if ( ! defined( 'ABSPATH' ) ) { |
| 8 |
exit; |
| 9 |
} |
| 10 |
|
| 11 |
class Menu { |
| 12 |
|
| 13 |
public static function init() { |
| 14 |
$self = new self(); |
| 15 |
add_action( 'admin_menu', [ $self, 'admin_menu' ] ); |
| 16 |
add_filter( 'storeengine/admin_menu_list', [ __CLASS__, 'inject_retail_menu_items' ] ); |
| 17 |
add_filter( 'storeengine/admin_menu_list', [ __CLASS__, 'inject_withdrawals_menu_item' ] ); |
| 18 |
// Runs last so it overrides whatever priority each addon set for itself — |
| 19 |
// the canonical submenu order lives in one place (see canonical_order()). |
| 20 |
add_filter( 'storeengine/admin_menu_list', [ __CLASS__, 'apply_canonical_order' ], 999 ); |
| 21 |
} |
| 22 |
|
| 23 |
/** |
| 24 |
* Single source of truth for the StoreEngine submenu order. |
| 25 |
* |
| 26 |
* Slug (without the `storeengine` prefix collision) => sort priority. Grouped |
| 27 |
* into three bands so the menu reads top-to-bottom as: everyday selling → |
| 28 |
* add-ons/operations → system. Addons register their own menu items with |
| 29 |
* their own priorities; apply_canonical_order() normalises them against this |
| 30 |
* map so the order never drifts as addons come and go. |
| 31 |
* |
| 32 |
* @return array<string,int> |
| 33 |
*/ |
| 34 |
protected static function canonical_order(): array { |
| 35 |
$s = STOREENGINE_PLUGIN_SLUG; |
| 36 |
|
| 37 |
return [ |
| 38 |
// --- Selling (core commerce, right after Orders) --- |
| 39 |
$s => 0, // Dashboard |
| 40 |
"$s-products" => 10, |
| 41 |
"$s-reviews" => 14, // Reviews — folded under Products |
| 42 |
"$s-faqs" => 15, // FAQs — folded under Products |
| 43 |
"$s-size-charts" => 16, // Size Charts — folded under Products |
| 44 |
"$s-orders" => 20, |
| 45 |
"$s-coupons" => 30, |
| 46 |
"$s-funnel-builder" => 31, // Funnels — standalone menu (was the Marketing group's slot) |
| 47 |
"$s-funnels" => 31, // alt slug |
| 48 |
"$s-customers" => 40, |
| 49 |
"$s-roles" => 41, // Roles — folded under Customers |
| 50 |
"$s-payments" => 50, |
| 51 |
"$s-subscriptions" => 60, |
| 52 |
"$s-installment-plans" => 70, |
| 53 |
"$s-membership-rules" => 80, // Access Rules |
| 54 |
"$s-membership-members" => 81, // Members |
| 55 |
"$s-membership-analytics" => 82, // Analytics |
| 56 |
"$s-affiliate" => 90, |
| 57 |
"$s-affiliates" => 90, // alt slug |
| 58 |
"$s-withdrawals" => 100, // right after Affiliates |
| 59 |
// --- Add-ons / operations --- |
| 60 |
"$s-inventory" => 200, |
| 61 |
"$s-returns" => 210, |
| 62 |
"$s-fraud-shield" => 220, |
| 63 |
"$s-pos" => 230, |
| 64 |
"$s-order-bumps" => 240, |
| 65 |
"$s-vendors" => 260, |
| 66 |
"$s-deployments" => 270, |
| 67 |
"$s-deployment-analytics" => 271, |
| 68 |
"$s-manage-licenses" => 280, |
| 69 |
"$s-ai" => 290, |
| 70 |
// --- System --- |
| 71 |
"$s-addons" => 910, |
| 72 |
"$s-logs" => 915, // directly above Tools |
| 73 |
"$s-tools" => 920, |
| 74 |
"$s-webhooks" => 930, |
| 75 |
"$s-settings" => 990, |
| 76 |
"$s-get-pro" => 1000, |
| 77 |
]; |
| 78 |
} |
| 79 |
|
| 80 |
/** |
| 81 |
* Normalise every menu item's priority against canonical_order(). Items not |
| 82 |
* in the map keep their own priority but are floored into the add-ons band so |
| 83 |
* a stray addon can never jump above the core selling menus. |
| 84 |
* |
| 85 |
* @param array $menu |
| 86 |
* |
| 87 |
* @return array |
| 88 |
*/ |
| 89 |
public static function apply_canonical_order( array $menu ): array { |
| 90 |
$order = self::canonical_order(); |
| 91 |
|
| 92 |
foreach ( $menu as $slug => &$item ) { |
| 93 |
if ( isset( $order[ $slug ] ) ) { |
| 94 |
$item['priority'] = $order[ $slug ]; |
| 95 |
} elseif ( ( $item['priority'] ?? 0 ) < 200 ) { |
| 96 |
// Unknown addon: drop it into the add-ons band (after selling, |
| 97 |
// before system) instead of letting a low priority hoist it up. |
| 98 |
$item['priority'] = 300 + (int) ( $item['priority'] ?? 0 ); |
| 99 |
} |
| 100 |
} |
| 101 |
unset( $item ); |
| 102 |
|
| 103 |
return $menu; |
| 104 |
} |
| 105 |
|
| 106 |
/** |
| 107 |
* Register the shared "Withdrawals" management menu. |
| 108 |
* |
| 109 |
* Both the multi-vendor and affiliate addons feed payout/withdrawal |
| 110 |
* requests into this screen, so it must appear whenever EITHER addon is |
| 111 |
* active. Previously the multi-vendor addon owned the menu outright (see |
| 112 |
* MultiVendor\Admin::register_menu), so turning multi-vendor off hid the |
| 113 |
* affiliate withdrawals too. Registering it centrally — gated by both |
| 114 |
* addon statuses — keeps it reachable for either addon independently. |
| 115 |
* |
| 116 |
* The sub-items and the gating capability adapt to whichever addons are |
| 117 |
* active. The React BackendDashboard route falls back to the affiliate |
| 118 |
* Payouts screen when multi-vendor is inactive. |
| 119 |
*/ |
| 120 |
public static function inject_withdrawals_menu_item( array $menu ): array { |
| 121 |
$has_multi_vendor = Helper::get_addon_active_status( 'multi-vendor' ); |
| 122 |
$has_affiliate = Helper::get_addon_active_status( 'affiliate' ); |
| 123 |
|
| 124 |
if ( ! $has_multi_vendor && ! $has_affiliate ) { |
| 125 |
return $menu; |
| 126 |
} |
| 127 |
|
| 128 |
$sub_items = []; |
| 129 |
if ( $has_multi_vendor ) { |
| 130 |
$sub_items[] = [ 'slug' => '', 'title' => __( 'Vendor Payouts', 'storeengine' ) ]; |
| 131 |
} |
| 132 |
if ( $has_affiliate ) { |
| 133 |
$sub_items[] = [ 'slug' => 'affiliate', 'title' => __( 'Affiliate Payouts', 'storeengine' ) ]; |
| 134 |
} |
| 135 |
|
| 136 |
$menu['storeengine-withdrawals'] = [ |
| 137 |
'title' => __( 'Payouts', 'storeengine' ), |
| 138 |
// Vendor managers use the vendor cap; an affiliate-only store falls |
| 139 |
// back to the standard admin cap so the screen stays reachable. |
| 140 |
'capability' => $has_multi_vendor ? 'manage_storeengine_vendor' : 'manage_options', |
| 141 |
'priority' => 36, |
| 142 |
'sub_items' => $sub_items, |
| 143 |
]; |
| 144 |
|
| 145 |
return $menu; |
| 146 |
} |
| 147 |
|
| 148 |
/** |
| 149 |
* Inject Inventory, POS, Returns, and Reports entries so the React admin |
| 150 |
* shell shows them in the side menu and the route switcher mounts the |
| 151 |
* React pages. Each addon-owned menu is gated by its own active status — |
| 152 |
* disabling the addon removes the entry. Reports remains visible |
| 153 |
* unconditionally; tabs that depend on specific addons (e.g. profit |
| 154 |
* from cost-profit) are gated at the REST / React level. |
| 155 |
*/ |
| 156 |
public static function inject_retail_menu_items( array $menu ): array { |
| 157 |
$has_inventory_pro = Helper::get_addon_active_status( 'inventory-pro' ); |
| 158 |
|
| 159 |
if ( Helper::get_addon_active_status( 'inventory' ) ) { |
| 160 |
$inventory_sub_items = [ |
| 161 |
[ 'slug' => '', 'title' => __( 'Stock', 'storeengine' ) ], |
| 162 |
]; |
| 163 |
|
| 164 |
if ( $has_inventory_pro ) { |
| 165 |
$inventory_sub_items[] = [ 'slug' => 'locations', 'title' => __( 'Locations', 'storeengine' ) ]; |
| 166 |
} |
| 167 |
|
| 168 |
$inventory_sub_items[] = [ 'slug' => 'movements', 'title' => __( 'Movements', 'storeengine' ) ]; |
| 169 |
|
| 170 |
// Barcode label generator is free — only needs the (free) Inventory |
| 171 |
// addon, not inventory-pro. |
| 172 |
$inventory_sub_items[] = [ 'slug' => 'barcodes', 'title' => __( 'Barcode Labels', 'storeengine' ) ]; |
| 173 |
|
| 174 |
if ( $has_inventory_pro ) { |
| 175 |
$inventory_sub_items[] = [ 'slug' => 'transfer', 'title' => __( 'Transfer', 'storeengine' ) ]; |
| 176 |
$inventory_sub_items[] = [ 'slug' => 'stock-subscribers', 'title' => __( 'Restock sub', 'storeengine' ) ]; |
| 177 |
} |
| 178 |
|
| 179 |
$menu[ STOREENGINE_PLUGIN_SLUG . '-inventory' ] = [ |
| 180 |
'title' => __( 'Inventory', 'storeengine' ), |
| 181 |
'capability' => 'manage_options', |
| 182 |
'priority' => 25, |
| 183 |
'sub_items' => $inventory_sub_items, |
| 184 |
]; |
| 185 |
} |
| 186 |
|
| 187 |
// POS and Returns menus now ship with their respective Pro addons |
| 188 |
// (see StoreEnginePro\Addons\Pos\Hooks::register_menu_items() and |
| 189 |
// StoreEnginePro\Addons\Returns\Hooks::register_menu_items()), so the |
| 190 |
// menu entry and its SPA route disappear together when the addon — or |
| 191 |
// the whole Pro plugin — is absent or disabled. |
| 192 |
|
| 193 |
// (Cost-profit reporting is now folded into the main Dashboard |
| 194 |
// `OverviewCards` row via the `storeengine/analytics/stats` filter |
| 195 |
// — see cost-profit/admin.php::append_profit_stats().) |
| 196 |
|
| 197 |
return $menu; |
| 198 |
} |
| 199 |
|
| 200 |
/** |
| 201 |
* Presentational grouping for the React sidebar. |
| 202 |
* |
| 203 |
* Each group becomes a single expandable top-level item whose children are |
| 204 |
* the listed page slugs. Only children that actually exist in the (already |
| 205 |
* filtered/ordered) flat menu are pulled in, so a group shrinks — or |
| 206 |
* disappears entirely — as its addons are toggled off. Children keep their |
| 207 |
* own `?page=` route untouched: grouping is a pure menu-tree transform (see |
| 208 |
* get_menu_tree()), the WP submenu-page registration and every React route |
| 209 |
* stay exactly as before. |
| 210 |
* |
| 211 |
* `priority` positions the group where its members used to sit in the |
| 212 |
* canonical order (Marketing ≈ Coupons, Finance ≈ Payments). |
| 213 |
* |
| 214 |
* @return array<string,array> |
| 215 |
*/ |
| 216 |
protected static function menu_groups(): array { |
| 217 |
$s = STOREENGINE_PLUGIN_SLUG; |
| 218 |
|
| 219 |
return [ |
| 220 |
// Products with its taxonomy/attribute screens as raw-route children. |
| 221 |
"$s-products" => [ |
| 222 |
'title' => __( 'Products', 'storeengine' ), |
| 223 |
'priority' => 10, |
| 224 |
'primary' => "$s-products", |
| 225 |
'primary_label' => __( 'All Products', 'storeengine' ), |
| 226 |
'children' => [ |
| 227 |
"$s-reviews", // Reviews — folded under Products |
| 228 |
"$s-faqs", // FAQs — folded under Products |
| 229 |
"$s-size-charts", // Size Charts — folded under Products |
| 230 |
"$s-coupons", // Coupons — folded under Products |
| 231 |
"$s-category", |
| 232 |
"$s-tags", |
| 233 |
"$s-attributes", |
| 234 |
"$s-brands", |
| 235 |
], |
| 236 |
], |
| 237 |
// Orders is a group *backed by a real page* (`primary`): its own |
| 238 |
// list view leads (as "All Orders") followed by its existing |
| 239 |
// sub-items (e.g. Abandoned Carts), then the folded-in pages. A |
| 240 |
// folded page that carries its own sub-items (e.g. Returns) keeps |
| 241 |
// them as a second nesting level, which get_menu_tree() preserves |
| 242 |
// so their sub-pages stay reachable from the sidebar. |
| 243 |
"$s-orders" => [ |
| 244 |
'title' => __( 'Orders', 'storeengine' ), |
| 245 |
'priority' => 20, |
| 246 |
'primary' => "$s-orders", |
| 247 |
'primary_label' => __( 'All Orders', 'storeengine' ), |
| 248 |
'children' => [ |
| 249 |
"$s-abandoned-cart", |
| 250 |
"$s-returns", |
| 251 |
"$s-couriers", // Shipments — folded under Orders |
| 252 |
"$s-order-bumps", // Order Bumps — folded under Orders |
| 253 |
], |
| 254 |
], |
| 255 |
"$s-customers" => [ |
| 256 |
'title' => __( 'Customers', 'storeengine' ), |
| 257 |
'priority' => 40, |
| 258 |
'primary' => "$s-customers", |
| 259 |
'primary_label' => __( 'All Customers', 'storeengine' ), |
| 260 |
'children' => [ |
| 261 |
"$s-roles", // Roles — every registered role across all add-ons |
| 262 |
], |
| 263 |
], |
| 264 |
// Membership: a pure container (no page of its own) whose children are |
| 265 |
// the Access Rules builder and the Members list. Both slugs are |
| 266 |
// registered by the membership addon (see Membership\Hooks::admin_menu_items), |
| 267 |
// so the whole group appears only while the addon is active and lands |
| 268 |
// on the first child (Access Rules). |
| 269 |
"$s-membership" => [ |
| 270 |
'title' => __( 'Membership', 'storeengine' ), |
| 271 |
'priority' => 80, |
| 272 |
'children' => [ |
| 273 |
"$s-membership-rules", // Access Rules |
| 274 |
"$s-membership-members", // Members |
| 275 |
"$s-membership-analytics", // Analytics |
| 276 |
], |
| 277 |
], |
| 278 |
// Fraud Shield (pro): its own top-level menu — many screens fold in |
| 279 |
// as raw-route children. |
| 280 |
"$s-fraud-shield" => [ |
| 281 |
'title' => __( 'Fraud Shield', 'storeengine' ), |
| 282 |
'priority' => 24, |
| 283 |
'primary' => "$s-fraud-shield", |
| 284 |
'primary_label' => __( 'Dashboard', 'storeengine' ), |
| 285 |
'children' => [ |
| 286 |
"$s-fraud-shield-queue", |
| 287 |
"$s-fraud-shield-rules", |
| 288 |
"$s-fraud-shield-blocklist", |
| 289 |
"$s-fraud-shield-activity", |
| 290 |
"$s-fraud-shield-settings", |
| 291 |
], |
| 292 |
], |
| 293 |
// POS (pro): its management screens fold in as raw-route children. |
| 294 |
"$s-pos" => [ |
| 295 |
'title' => __( 'POS', 'storeengine' ), |
| 296 |
'priority' => 27, |
| 297 |
'primary' => "$s-pos", |
| 298 |
'primary_label' => __( 'Sessions', 'storeengine' ), |
| 299 |
'children' => [ |
| 300 |
"$s-pos-registers", |
| 301 |
"$s-pos-staff", |
| 302 |
"$s-pos-card-readers", |
| 303 |
"$s-pos-reports", |
| 304 |
], |
| 305 |
], |
| 306 |
// Dropshipping (pro): its screens fold in as raw-route children. |
| 307 |
"$s-dropship" => [ |
| 308 |
'title' => __( 'Dropshipping', 'storeengine' ), |
| 309 |
'priority' => 28, |
| 310 |
'primary' => "$s-dropship", |
| 311 |
'primary_label' => __( 'Dispatch queue', 'storeengine' ), |
| 312 |
'children' => [ |
| 313 |
"$s-dropship-routes", |
| 314 |
"$s-dropship-supplier-rules", |
| 315 |
"$s-dropship-connectors", |
| 316 |
"$s-dropship-settings", |
| 317 |
], |
| 318 |
], |
| 319 |
// Suppliers (pro): Purchase Orders folds in as a raw-route child. |
| 320 |
"$s-suppliers" => [ |
| 321 |
'title' => __( 'Suppliers', 'storeengine' ), |
| 322 |
'priority' => 26, |
| 323 |
'primary' => "$s-suppliers", |
| 324 |
'primary_label' => __( 'Suppliers', 'storeengine' ), |
| 325 |
'children' => [ |
| 326 |
"$s-purchase-orders", |
| 327 |
], |
| 328 |
], |
| 329 |
// Licenses is a page with its own sub-items (All Licenses, Sites); |
| 330 |
// Deployments (with its Analytics sub-page) folds in beneath it as |
| 331 |
// a second level. |
| 332 |
"$s-manage-licenses" => [ |
| 333 |
'title' => __( 'Licenses', 'storeengine' ), |
| 334 |
'priority' => 35, |
| 335 |
'primary' => "$s-manage-licenses", |
| 336 |
'primary_label' => __( 'All Licenses', 'storeengine' ), |
| 337 |
'children' => [ |
| 338 |
"$s-installation-events", |
| 339 |
"$s-deployments", |
| 340 |
"$s-deployment-analytics", |
| 341 |
], |
| 342 |
], |
| 343 |
// Funnel Builder is its own top-level menu (no "Marketing" wrapper). |
| 344 |
// It's a flat, addon-registered item; canonical_order() positions it |
| 345 |
// where Marketing used to sit. It surfaces top-level simply by not |
| 346 |
// being folded into any group here. |
| 347 |
// Payments is a page-backed group: the payments list leads |
| 348 |
// ("One-time"), and Subscriptions / Installment Plans fold in as |
| 349 |
// their own raw-route pages (page=storeengine-subscriptions, |
| 350 |
// page=storeengine-installment-plans) — consistent with every other |
| 351 |
// group's children (SureCart-style raw routes, not ?path= params). |
| 352 |
"$s-payments" => [ |
| 353 |
'title' => __( 'Payments', 'storeengine' ), |
| 354 |
'priority' => 50, |
| 355 |
'primary' => "$s-payments", |
| 356 |
'primary_label' => __( 'One-time', 'storeengine' ), |
| 357 |
'children' => [ |
| 358 |
"$s-subscriptions", |
| 359 |
"$s-installment-plans", |
| 360 |
], |
| 361 |
], |
| 362 |
// Affiliates, Vendors and Payouts are their own top-level menus (no |
| 363 |
// "Partners" wrapper). Each is a flat item registered by its addon |
| 364 |
// (affiliate/multi-vendor) or centrally (Payouts, see |
| 365 |
// inject_withdrawals_menu_item); canonical_order() positions them |
| 366 |
// (Affiliates 90, Payouts 100). They surface top-level simply by not |
| 367 |
// being folded into any group here. |
| 368 |
]; |
| 369 |
} |
| 370 |
|
| 371 |
/** |
| 372 |
* The grouped menu tree the React sidebar renders. |
| 373 |
* |
| 374 |
* Starts from the flat get_menu_lists() (fully filtered + ordered), then |
| 375 |
* folds each group's children into a single expandable parent. The child's |
| 376 |
* standalone top-level row is removed and re-emitted as a `sub_items` entry |
| 377 |
* carrying its own `page` slug, so the React `MenuItem` links straight to |
| 378 |
* the child's existing route — no addon or route switcher needs touching. |
| 379 |
* |
| 380 |
* WP page registration still iterates the flat get_menu_lists(), so every |
| 381 |
* child page URL and capability check remains valid. |
| 382 |
* |
| 383 |
* @return array |
| 384 |
*/ |
| 385 |
public static function get_menu_tree(): array { |
| 386 |
$menu = self::get_menu_lists(); |
| 387 |
|
| 388 |
// Grouping is a presentational nicety for the full admin. For a |
| 389 |
// restricted (non-admin) user — e.g. a Pro role-permission staff member — |
| 390 |
// the flat, per-permission menu is filtered down to only the pages they |
| 391 |
// may reach. Folding those into groups whose *parent* page they can't |
| 392 |
// access would bury or drop a permitted child (e.g. Coupons lives under |
| 393 |
// the Products group, so a staff user with coupon access but no product |
| 394 |
// access would lose the Coupons menu entirely). Render the menu flat for |
| 395 |
// them so every permitted page stays a reachable top-level item. |
| 396 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 397 |
return $menu; |
| 398 |
} |
| 399 |
|
| 400 |
foreach ( self::menu_groups() as $group_slug => $group ) { |
| 401 |
$sub_items = []; |
| 402 |
$primary = $group['primary'] ?? null; |
| 403 |
|
| 404 |
// A group backed by a real page leads with that page's own list |
| 405 |
// view + its existing sub-items (both routed under the page). |
| 406 |
if ( $primary && isset( $menu[ $primary ] ) ) { |
| 407 |
$primary_subs = $menu[ $primary ]['sub_items'] ?? []; |
| 408 |
|
| 409 |
// Only synthesize an "All …" default when the page doesn't |
| 410 |
// already ship its own default (slug '') sub-item — pages like |
| 411 |
// Licenses do, so reusing theirs avoids a duplicate row. |
| 412 |
$has_default = false; |
| 413 |
foreach ( $primary_subs as $existing ) { |
| 414 |
if ( ! isset( $existing['page'] ) && '' === ( $existing['slug'] ?? '' ) ) { |
| 415 |
$has_default = true; |
| 416 |
break; |
| 417 |
} |
| 418 |
} |
| 419 |
|
| 420 |
if ( ! $has_default ) { |
| 421 |
$sub_items[] = [ |
| 422 |
'slug' => '', |
| 423 |
'title' => $group['primary_label'] ?? $menu[ $primary ]['title'], |
| 424 |
]; |
| 425 |
} |
| 426 |
|
| 427 |
foreach ( $primary_subs as $existing ) { |
| 428 |
$sub_items[] = $existing; |
| 429 |
} |
| 430 |
} |
| 431 |
|
| 432 |
// Fold in each child page. A child that has its own sub-items keeps |
| 433 |
// them as a second nesting level (grandchildren), so pages like |
| 434 |
// Fraud Shield don't lose access to their sub-screens. |
| 435 |
foreach ( $group['children'] ?? [] as $child_slug ) { |
| 436 |
if ( ! isset( $menu[ $child_slug ] ) ) { |
| 437 |
continue; |
| 438 |
} |
| 439 |
|
| 440 |
$entry = [ |
| 441 |
'page' => $child_slug, |
| 442 |
'title' => $menu[ $child_slug ]['title'], |
| 443 |
]; |
| 444 |
|
| 445 |
if ( ! empty( $menu[ $child_slug ]['sub_items'] ) ) { |
| 446 |
$entry['sub_items'] = $menu[ $child_slug ]['sub_items']; |
| 447 |
} |
| 448 |
|
| 449 |
$sub_items[] = $entry; |
| 450 |
|
| 451 |
unset( $menu[ $child_slug ] ); |
| 452 |
} |
| 453 |
|
| 454 |
// Path-based sub-tabs: rendered under the primary page via ?path=, |
| 455 |
// not as separate pages. The standalone page each replaces |
| 456 |
// (`requires`) is absorbed into this group. |
| 457 |
foreach ( $group['path_children'] ?? [] as $path_child ) { |
| 458 |
$requires = $path_child['requires'] ?? null; |
| 459 |
if ( $requires && ! isset( $menu[ $requires ] ) ) { |
| 460 |
continue; |
| 461 |
} |
| 462 |
|
| 463 |
$sub_items[] = [ |
| 464 |
'slug' => $path_child['slug'], |
| 465 |
'title' => $path_child['title'], |
| 466 |
]; |
| 467 |
|
| 468 |
if ( $requires ) { |
| 469 |
unset( $menu[ $requires ] ); |
| 470 |
} |
| 471 |
} |
| 472 |
|
| 473 |
// A primary-backed group with only its own page (no folded children) |
| 474 |
// gains nothing from grouping — leave the page as-is. |
| 475 |
if ( empty( $sub_items ) || ( $primary && count( $sub_items ) <= 1 ) ) { |
| 476 |
continue; |
| 477 |
} |
| 478 |
|
| 479 |
// Where the parent row navigates: a primary-backed group (Orders) |
| 480 |
// lands on its own page; a pure container (Marketing) lands on its |
| 481 |
// first page-based child. Used by both the React menu and the native |
| 482 |
// WP submenu registration. |
| 483 |
$landing = $primary; |
| 484 |
if ( ! $landing ) { |
| 485 |
foreach ( $sub_items as $si ) { |
| 486 |
if ( ! empty( $si['page'] ) ) { |
| 487 |
$landing = $si['page']; |
| 488 |
break; |
| 489 |
} |
| 490 |
} |
| 491 |
} |
| 492 |
|
| 493 |
// Reuse the primary page's slug so its route/registration is intact; |
| 494 |
// otherwise mint the group's own (page-less) slug. |
| 495 |
$menu[ $group_slug ] = [ |
| 496 |
'title' => $group['title'], |
| 497 |
'capability' => $menu[ $primary ]['capability'] ?? 'manage_options', |
| 498 |
'priority' => $group['priority'], |
| 499 |
'sub_items' => $sub_items, |
| 500 |
'is_group' => true, |
| 501 |
'landing' => $landing ?: '', |
| 502 |
]; |
| 503 |
} |
| 504 |
|
| 505 |
uasort( $menu, function ( $a, $b ) { |
| 506 |
return ( $a['priority'] ?? 0 ) <=> ( $b['priority'] ?? 0 ); |
| 507 |
} ); |
| 508 |
|
| 509 |
return $menu; |
| 510 |
} |
| 511 |
|
| 512 |
public static function get_menu_lists() { |
| 513 |
$menu_items = [ |
| 514 |
STOREENGINE_PLUGIN_SLUG => [ |
| 515 |
'title' => __( 'Dashboard', 'storeengine' ), |
| 516 |
'capability' => 'manage_options', |
| 517 |
'priority' => 0, |
| 518 |
], |
| 519 |
STOREENGINE_PLUGIN_SLUG . '-products' => [ |
| 520 |
'title' => __( 'Products', 'storeengine' ), |
| 521 |
'capability' => 'manage_options', |
| 522 |
'priority' => 10, |
| 523 |
], |
| 524 |
// Product taxonomy / attribute screens are their own raw-route pages |
| 525 |
// (?page=storeengine-category etc.); the Products group folds them in |
| 526 |
// as children — see menu_groups(). The parent Products route keeps a |
| 527 |
// `?path=` fallback so any un-migrated internal link still resolves. |
| 528 |
STOREENGINE_PLUGIN_SLUG . '-reviews' => [ |
| 529 |
'title' => __( 'Reviews', 'storeengine' ), |
| 530 |
'capability' => 'manage_options', |
| 531 |
'priority' => 14, |
| 532 |
], |
| 533 |
STOREENGINE_PLUGIN_SLUG . '-category' => [ |
| 534 |
'title' => __( 'Category', 'storeengine' ), |
| 535 |
'capability' => 'manage_options', |
| 536 |
'priority' => 11, |
| 537 |
], |
| 538 |
STOREENGINE_PLUGIN_SLUG . '-tags' => [ |
| 539 |
'title' => __( 'Tags', 'storeengine' ), |
| 540 |
'capability' => 'manage_options', |
| 541 |
'priority' => 12, |
| 542 |
], |
| 543 |
STOREENGINE_PLUGIN_SLUG . '-attributes' => [ |
| 544 |
'title' => __( 'Attributes', 'storeengine' ), |
| 545 |
'capability' => 'manage_options', |
| 546 |
'priority' => 13, |
| 547 |
], |
| 548 |
STOREENGINE_PLUGIN_SLUG . '-orders' => [ |
| 549 |
'title' => __( 'Orders', 'storeengine' ), |
| 550 |
'capability' => 'manage_options', |
| 551 |
'priority' => 20, |
| 552 |
], |
| 553 |
STOREENGINE_PLUGIN_SLUG . '-coupons' => [ |
| 554 |
'title' => __( 'Coupons', 'storeengine' ), |
| 555 |
'capability' => 'manage_options', |
| 556 |
'priority' => 30, |
| 557 |
], |
| 558 |
STOREENGINE_PLUGIN_SLUG . '-customers' => [ |
| 559 |
'title' => __( 'Customers', 'storeengine' ), |
| 560 |
'capability' => 'manage_options', |
| 561 |
'priority' => 40, |
| 562 |
], |
| 563 |
STOREENGINE_PLUGIN_SLUG . '-roles' => [ |
| 564 |
'title' => __( 'Roles', 'storeengine' ), |
| 565 |
'capability' => 'manage_options', |
| 566 |
'priority' => 41, |
| 567 |
], |
| 568 |
STOREENGINE_PLUGIN_SLUG . '-payments' => [ |
| 569 |
'title' => __( 'Payments', 'storeengine' ), |
| 570 |
'capability' => 'manage_options', |
| 571 |
'priority' => 50, |
| 572 |
], |
| 573 |
STOREENGINE_PLUGIN_SLUG . '-addons' => [ |
| 574 |
'title' => __( 'Add-ons', 'storeengine' ), |
| 575 |
'capability' => 'manage_options', |
| 576 |
'priority' => 90, |
| 577 |
], |
| 578 |
STOREENGINE_PLUGIN_SLUG . '-tools' => [ |
| 579 |
'title' => __( 'Tools', 'storeengine' ), |
| 580 |
'capability' => 'manage_options', |
| 581 |
'priority' => 90, |
| 582 |
], |
| 583 |
STOREENGINE_PLUGIN_SLUG . '-logs' => [ |
| 584 |
'title' => __( 'Logs', 'storeengine' ), |
| 585 |
'capability' => 'manage_options', |
| 586 |
'priority' => 80, |
| 587 |
], |
| 588 |
STOREENGINE_PLUGIN_SLUG . '-settings' => [ |
| 589 |
'title' => __( 'Settings', 'storeengine' ), |
| 590 |
'capability' => 'manage_options', |
| 591 |
'priority' => 99, |
| 592 |
], |
| 593 |
]; |
| 594 |
|
| 595 |
// The FAQ Groups library is only surfaced in the "global" FAQ mode; in |
| 596 |
// "product_only" mode products keep an inline FAQ editor and no library. |
| 597 |
if ( \StoreEngine\Utils\Helper::get_settings( 'enable_faqs', true ) |
| 598 |
&& 'product_only' !== \StoreEngine\Utils\Helper::get_settings( 'faq_mode', 'global' ) ) { |
| 599 |
$menu_items[ STOREENGINE_PLUGIN_SLUG . '-faqs' ] = [ |
| 600 |
'title' => __( 'FAQs', 'storeengine' ), |
| 601 |
'capability' => 'manage_options', |
| 602 |
'priority' => 15, |
| 603 |
]; |
| 604 |
} |
| 605 |
|
| 606 |
// Size chart library — only surfaced while the size guide is enabled, |
| 607 |
// since the charts have nowhere to render otherwise. |
| 608 |
if ( \StoreEngine\Utils\Helper::get_settings( 'enable_size_guide', false ) ) { |
| 609 |
$menu_items[ STOREENGINE_PLUGIN_SLUG . '-size-charts' ] = [ |
| 610 |
'title' => __( 'Size Charts', 'storeengine' ), |
| 611 |
'capability' => 'manage_options', |
| 612 |
'priority' => 16, |
| 613 |
]; |
| 614 |
} |
| 615 |
|
| 616 |
$menu = apply_filters( 'storeengine/admin_menu_list', $menu_items ); |
| 617 |
|
| 618 |
if ( ! defined( 'STOREENGINE_PRO_VERSION' ) ) { |
| 619 |
// Injected AFTER the apply_canonical_order filter, so it must carry its |
| 620 |
// own canonical priority — pull it straight from canonical_order() so |
| 621 |
// "Get Pro" lands in the System band (bottom) instead of the selling |
| 622 |
// band. A stray `100` here dropped it between Payments and Add-ons. |
| 623 |
$get_pro_slug = STOREENGINE_PLUGIN_SLUG . '-get-pro'; |
| 624 |
$menu[ $get_pro_slug ] = [ |
| 625 |
'title' => '<span class="dashicons dashicons-awards storeengine-blue-color"></span> ' . __( 'Get Pro', 'storeengine' ), |
| 626 |
'capability' => 'manage_options', |
| 627 |
'priority' => self::canonical_order()[ $get_pro_slug ] ?? 1000, |
| 628 |
]; |
| 629 |
} |
| 630 |
|
| 631 |
uasort( $menu, function ( $a, $b ) { |
| 632 |
return ( $a['priority'] ?? 0 ) <=> ( $b['priority'] ?? 0 ); |
| 633 |
} ); |
| 634 |
|
| 635 |
return $menu; |
| 636 |
} |
| 637 |
|
| 638 |
/** |
| 639 |
* Add admin menu page |
| 640 |
* |
| 641 |
* @return void |
| 642 |
*/ |
| 643 |
public function admin_menu() { |
| 644 |
add_menu_page( |
| 645 |
__( 'StoreEngine', 'storeengine' ), |
| 646 |
__( 'StoreEngine', 'storeengine' ), |
| 647 |
'manage_options', |
| 648 |
STOREENGINE_PLUGIN_SLUG, |
| 649 |
[ $this, 'load_main_template' ], |
| 650 |
$this->get_menu_icon(), |
| 651 |
55 |
| 652 |
); |
| 653 |
|
| 654 |
$registered = []; |
| 655 |
|
| 656 |
// SureCart-style native menu: register one visible entry per top-level |
| 657 |
// group (titled with the group name, pointing at its navigable page — |
| 658 |
// own page for page-backed groups, first child for containers). This is |
| 659 |
// all the native hover fly-out shows. |
| 660 |
foreach ( self::get_menu_tree() as $slug => $item ) { |
| 661 |
$target = ! empty( $item['landing'] ) ? $item['landing'] : $slug; |
| 662 |
|
| 663 |
if ( isset( $registered[ $target ] ) ) { |
| 664 |
continue; |
| 665 |
} |
| 666 |
|
| 667 |
add_submenu_page( |
| 668 |
STOREENGINE_PLUGIN_SLUG, |
| 669 |
$item['title'], |
| 670 |
$item['title'], |
| 671 |
$item['capability'] ?? 'manage_options', |
| 672 |
$target, |
| 673 |
[ $this, 'load_main_template' ] |
| 674 |
); |
| 675 |
$registered[ $target ] = true; |
| 676 |
} |
| 677 |
|
| 678 |
// Child / sub-pages are NOT permanently registered — that is what kept |
| 679 |
// the fly-out long. Instead, register only the page currently being |
| 680 |
// viewed (SureCart's conditional-registration trick), so a direct URL or |
| 681 |
// reload of a child route still resolves and loads the SPA. In-app |
| 682 |
// navigation is client-side React and needs no registration, and from |
| 683 |
// other screens no child is registered, so the fly-out stays to the |
| 684 |
// group names only. |
| 685 |
$current_page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 686 |
|
| 687 |
if ( $current_page && ! isset( $registered[ $current_page ] ) ) { |
| 688 |
$flat = self::get_menu_lists(); |
| 689 |
if ( isset( $flat[ $current_page ] ) ) { |
| 690 |
$item = $flat[ $current_page ]; |
| 691 |
add_submenu_page( |
| 692 |
STOREENGINE_PLUGIN_SLUG, |
| 693 |
$item['title'], |
| 694 |
$item['title'], |
| 695 |
$item['capability'], |
| 696 |
$current_page, |
| 697 |
[ $this, 'load_main_template' ] |
| 698 |
); |
| 699 |
$registered[ $current_page ] = true; |
| 700 |
} |
| 701 |
} |
| 702 |
} |
| 703 |
|
| 704 |
protected function get_menu_icon() { |
| 705 |
return apply_filters( 'storeengine/admin/toplevel_inactive_menu_icon', STOREENGINE_ASSETS_URI . 'images/logo.svg' ); |
| 706 |
} |
| 707 |
|
| 708 |
public function load_main_template() { |
| 709 |
echo '<div id="storeengine-admin" class="storeengine-admin se-admin-tw"></div>'; |
| 710 |
} |
| 711 |
} |
| 712 |
|