PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / admin / menu.php

menu.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/admin/menu.php

712 lines 25.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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