'Today', 'storesuit-orders' => 'Sales', 'storesuit-carts' => 'Live carts', 'storesuit-stock-alerts' => 'Waiting customers', 'storesuit-mail-log' => 'Email history', 'storesuit-redirects' => 'Redirects and 404s', 'storesuit-operations' => 'Order desk', 'storesuit-modules' => 'All features', 'storesuit-audit' => 'Activity log', 'storesuit-products' => 'Products', 'storesuit-customers' => 'Customers', 'storesuit-marketing' => 'Marketing', 'storesuit-reports' => 'Reports', 'storesuit-settings' => 'Settings & Help', 'storesuit-pro' => 'StoreRuler Pro', 'storesuit-order-tracking-settings' => 'Order tracking', ); private const LABELS = array(); // The engines' menu names in StoreRuler words: ProScreens::labels(). public static function label( string $text ) : string { $labels = self::LABELS; return I18n::t( $labels[$text] ?? $text ); } public static function hooks() : void { add_filter( 'admin_body_class', static function ( $classes ) { $page = self::query()['page'] ?? ''; $engine = ''; return $classes . (( self::context() ? ' ss-workspace' : '' )) . $engine; } ); add_action( 'admin_enqueue_scripts', array(__CLASS__, 'assets'), 9999 ); add_action( 'in_admin_header', array(__CLASS__, 'shell') ); add_filter( 'admin_title', array(__CLASS__, 'admin_title'), 10, 2 ); } /** * The browser tab names the screen as StoreRuler's header does. Bundled engines register their pages before their own * translations load, so the tab read "Store activity" or "Safe Catalog Bulk Editor" in a Turkish store. * WordPress escapes the result when it prints the tab title. */ public static function admin_title( $admin_title, $title ) { $page = (string) (self::query()['page'] ?? ''); $context = self::context(); // A screen without its own header title (a Pro engine screen) may name itself: storesuit_screen_title, already translated. $shown = ( is_string( $context['title'] ?? null ) && $context['title'] !== '' ? $context['title'] : (string) apply_filters( 'storesuit_screen_title', '', $page ) ); $shown = trim( wp_strip_all_tags( (string) $shown ) ); if ( $shown === '' || !is_string( $admin_title ) ) { return $admin_title; } $title = ( is_string( $title ) ? $title : '' ); if ( $title === '' ) { return $shown . $admin_title; } return ( str_starts_with( $admin_title, $title ) ? $shown . substr( $admin_title, strlen( $title ) ) : $admin_title ); } private static function query() : array { $query = array(); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Which admin page is shown, for the menu and the page title; nothing is changed. foreach ( array('page', 'view', 'tab') as $key ) { if ( isset( $_GET[$key] ) && is_string( $_GET[$key] ) ) { $query[$key] = sanitize_key( wp_unslash( $_GET[$key] ) ); } } // phpcs:enable WordPress.Security.NonceVerification.Recommended return $query; } /** Whether the logo's page (page=storesuit) opens Home rather than the day's work list: the screen's own rule (admin.js), for people who manage the store and see its money, and no view=today. */ public static function home_page() : bool { return (self::query()['view'] ?? '') !== 'today' && current_user_can( 'manage_woocommerce' ) && current_user_can( 'storesuit_finance' ); } private static function match( string $url ) : int { parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $target ); $current = self::query(); if ( empty( $target['page'] ) || ($current['page'] ?? '') !== $target['page'] ) { return 0; } foreach ( array('view', 'tab') as $key ) { if ( isset( $target[$key] ) && ($current[$key] ?? '') !== $target[$key] ) { return 0; } } return count( array_intersect_key( $target, array_flip( array('page', 'view', 'tab') ) ) ); } public static function context() : array { $page = self::query()['page'] ?? ''; // merchuno-support-v1: keep the Support page inside the existing workspace shell. if ( $page === 'storesuit-support' && class_exists( '\\MerchunoSupport\\Client' ) ) { return array( 'title' => \MerchunoSupport\Client::say( 'Support' ), 'group' => 'StoreRuler', 'key' => 'help', ); } if ( $page === 'storesuit-guide' ) { return array( 'title' => I18n::t( 'Step-by-step guide' ), 'group' => 'StoreRuler', 'key' => 'suite', ); } $pages = self::PAGES; if ( isset( $pages[$page] ) ) { $key = ( $page === 'storesuit' || $page === 'storesuit-modules' ? 'suite' : Navigation::area( admin_url( 'admin.php?page=' . $page ) ) ); $title = ( $page === 'storesuit' && (self::query()['view'] ?? '') !== 'today' ? 'Home' : $pages[$page] ); return array( 'title' => I18n::t( $title ), 'group' => 'StoreRuler', 'key' => $key, ); } $found = array(); $score = 0; foreach ( Admin::navigation() as $group ) { foreach ( $group['children'] as $child ) { $match = self::match( $child['url'] ); if ( $match > $score ) { $score = $match; $found = array( 'title' => self::label( $child['name'] ), 'group' => $group['name'], 'key' => $group['key'], ); } } } return $found; } public static function assets() : void { if ( !self::context() ) { return; } wp_enqueue_style( 'dashicons' ); wp_enqueue_style( 'storesuit-design', plugins_url( 'assets/design.css', STORESUIT_FILE ), array(), (string) filemtime( STORESUIT_DIR . 'assets/design.css' ) ); wp_enqueue_style( 'storesuit-mega', plugins_url( 'assets/mega.css', STORESUIT_FILE ), array('storesuit-design'), (string) filemtime( STORESUIT_DIR . 'assets/mega.css' ) ); wp_enqueue_style( 'storesuit-engine-ledger', plugins_url( 'assets/engine-ledger.css', STORESUIT_FILE ), array('storesuit-mega'), (string) filemtime( STORESUIT_DIR . 'assets/engine-ledger.css' ) ); I18n::enqueue_script(); wp_enqueue_script( 'storesuit-shell', plugins_url( 'assets/shell.js', STORESUIT_FILE ), array('storesuit-i18n'), (string) filemtime( STORESUIT_DIR . 'assets/shell.js' ), true ); wp_localize_script( 'storesuit-shell', 'StoreSuitShellConfig', array( 'url' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'storesuit' ), ) ); // StoreRuler Premium (0.62): one visual layer over every StoreRuler and tool screen, loaded last. wp_enqueue_style( 'storesuit-premium', plugins_url( 'assets/premium.css', STORESUIT_FILE ), array('storesuit-engine-ledger'), (string) filemtime( STORESUIT_DIR . 'assets/premium.css' ) ); wp_enqueue_style( 'storesuit-layout-fixes', plugins_url( 'assets/layout-fixes.css', STORESUIT_FILE ), array('storesuit-premium'), (string) filemtime( STORESUIT_DIR . 'assets/layout-fixes.css' ) ); wp_enqueue_script( 'storesuit-layout-fixes', plugins_url( 'assets/layout-fixes.js', STORESUIT_FILE ), array('storesuit-shell'), (string) filemtime( STORESUIT_DIR . 'assets/layout-fixes.js' ), true ); self::guide( self::query()['page'] ?? '' ); self::no_command_palette( self::query()['page'] ?? '' ); } /** * WordPress's command palette (Ctrl+K) and WooCommerce's commands for it stay off StoreRuler's own screens, which have * their menu and search in the shell (owner, 20 Oct 2026). With them go the two clients WordPress and WooCommerce put on * every admin screen for the editor's data store (wp-abilities, wc-entities), which nothing on these screens reads. Together * they brought React and the block editor's packages, about 3 MB of script the browser ran again on each tab. When another * script of the screen uses that data store or the block editor (the Design studio's editor), the two clients stay; * other plugins' screens keep everything. */ private static function no_command_palette( string $page ) : void { if ( strpos( $page, 'storesuit' ) !== 0 ) { return; } foreach ( array( 'wp-core-commands', 'wp-commands', 'wc-admin-command-palette', 'wc-admin-command-palette-analytics' ) as $handle ) { wp_dequeue_script( $handle ); } wp_dequeue_style( 'wp-commands' ); $scripts = wp_scripts(); $clients = array('wp-abilities', 'wc-entities'); $uses = static function ( string $handle, array &$seen ) use(&$uses, $scripts) : bool { if ( in_array( $handle, array('wp-core-data', 'wp-block-editor', 'wp-blocks'), true ) ) { return true; } if ( isset( $seen[$handle] ) || !isset( $scripts->registered[$handle] ) ) { return false; } $seen[$handle] = true; foreach ( (array) $scripts->registered[$handle]->deps as $dep ) { if ( $uses( (string) $dep, $seen ) ) { return true; } } return false; }; foreach ( $scripts->queue as $handle ) { $seen = array(); if ( !in_array( $handle, $clients, true ) && $uses( (string) $handle, $seen ) ) { return; } } foreach ( $clients as $handle ) { wp_dequeue_script( $handle ); } } /** The kind of module guide each free screen gets (page-onboarding.js picks its steps by it); ProScreens::guide() adds the Pro ones. */ private const GUIDES = array( 'storesuit' => 'home', 'storesuit-orders' => 'orders', 'storesuit-operations' => 'orders', 'storesuit-products' => 'products', 'storesuit-customers' => 'customers', 'storesuit-marketing' => 'marketing', 'storesuit-carts' => 'marketing', 'storesuit-stock-alerts' => 'marketing', 'storesuit-redirects' => 'marketing', 'storesuit-reports' => 'reports', 'storesuit-settings' => 'settings', 'storesuit-audit' => 'settings', 'storesuit-mail-log' => 'settings', 'storesuit-order-tracking-settings' => 'settings', 'storesuit-guide' => 'help', 'storesuit-modules' => 'help', ); /** * The module guide (1.2.8): on a person's first visit to a StoreRuler module, page-onboarding.js points at the screen's * own controls one at a time; afterwards "Open guide" brings it back. What it keeps (the modules seen, and whether guides * open by themselves) stays in that browser's localStorage under the site's path and the user's number, so the page * gives it those and the module: no name, e-mail, nonce or other secret, and it makes no request. StoreRuler Pro's page * (an offer, not a module), a screen of another plugin and a screen without a module get none. */ private static function guide( string $page ) : void { $family = self::GUIDES[$page] ?? ''; if ( $family === '' ) { return; } wp_enqueue_style( 'storesuit-page-onboarding', plugins_url( 'assets/page-onboarding.css', STORESUIT_FILE ), array('storesuit-premium'), STORESUIT_VERSION . '.' . filemtime( STORESUIT_DIR . 'assets/page-onboarding.css' ) ); wp_enqueue_script( 'storesuit-page-onboarding', plugins_url( 'assets/page-onboarding.js', STORESUIT_FILE ), array('storesuit-i18n'), STORESUIT_VERSION . '.' . filemtime( STORESUIT_DIR . 'assets/page-onboarding.js' ), true ); // A module is a page, whatever report, tab or filter it shows; the purchasing pages (goods in, the purchase desk) are one. $path = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ); wp_localize_script( 'storesuit-page-onboarding', 'StoreSuitOnboardingConfig', array( 'userId' => get_current_user_id(), 'sitePath' => ( $path !== '' ? $path : '/' ), 'module' => ( $family === 'purchasing' ? 'purchasing' : $page ), 'family' => $family, ) ); } private static function icon( string $name ) : string { return ''; } private static function item( string $name, string $url, string $icon = '', bool $active = false ) : void { echo '' . (( $icon ? wp_kses_post( self::icon( $icon ) ) : '' )) . '' . esc_html( $name ) . ''; } private static function daily_active( string $route ) : bool { parse_str( (string) wp_parse_url( admin_url( 'admin.php?page=' . $route ), PHP_URL_QUERY ), $target ); $current = array(); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Which menu item is the page being shown; nothing is changed. foreach ( array( 'page', 'flow', 'new', 'queue' ) as $key ) { if ( isset( $_GET[$key] ) && is_string( $_GET[$key] ) ) { $current[$key] = sanitize_key( wp_unslash( $_GET[$key] ) ); } } // phpcs:enable WordPress.Security.NonceVerification.Recommended return $target == $current; } /** * Words the top-bar search also finds a menu entry by (shell.js reads them from data-keywords), by route: English source * text, comma-separated, translated like any other text. StoreRuler Pro adds its own (ProScreens::keywords()). */ private const KEYWORDS = array(); /** A menu entry's search words: the English ones and, in another language, the translated ones too ('' for none). */ private static function keywords( string $url ) : string { $table = self::KEYWORDS; $key = Guide::key( $url ); $words = (string) ($table[$key] ?? $table[explode( ':', $key, 2 )[0]] ?? ''); if ( $words === '' ) { return ''; } $local = I18n::t( $words ); return ( $local === $words ? $words : $words . ', ' . $local ); } private static function mega_link( string $title, string $url, string $description = '', bool $active = false ) : void { $keywords = self::keywords( $url ); // The description shows on one line; the whole sentence stays available on hover. echo '' . esc_html( $title ) . '' . (( $description ? '' . esc_html( $description ) . '' : '' )) . ''; } private static function mega_daily( array $row ) : void { $url = admin_url( 'admin.php?page=' . $row[1] ); $task = Guide::describe( $url ); self::mega_link( $row[0], $url, $row[3] ?? $task[1] ?? '', self::daily_active( $row[1] ) ); } /** An area's two tabs when it has tools beyond its daily work: that work, and the other tools with their count (0.62). */ private static function mega_tabs( array $group ) : void { if ( !$group['children'] ) { return; } $more = ( $group['key'] === 'sales' ? I18n::t( 'More tools' ) : (string) $group['management_label'] ); echo '
' . '' . '
'; } private static function mega_group( array $group, string $active_key ) : void { $key = $group['key']; $daily = Guide::daily( $key ); // Every tool in the same panel with its short name (0.62.6, owner: "many modules sit in the background"): no hidden tab. $tabs = false; echo '
' . esc_html( $group['name'] ) . '' . (( !empty( $group['plan'] ) ? '' . esc_html( (string) $group['plan'] ) . '' : '' )) . wp_kses_post( self::icon( 'arrow-down-alt2' ) ) . '
StoreRuler / ' . esc_html( $group['name'] ) . '

' . esc_html( $group['description'] ) . '

'; if ( $tabs ) { self::mega_tabs( $group ); } echo '
'; // The Sales and Marketing panels show their daily work in sections: array( icon, heading, routes, shown ). $sections = array(); if ( $key === 'sales' ) { $sections = array(array( 'clipboard', 'Orders', array('storesuit-orders', 'storesuit-orders&queue=fulfil'), true ), array( 'money-alt', 'Payments', array('storesuit-orders&queue=payment'), current_user_can( 'storesuit_finance' ) )); } elseif ( $key === 'marketing' ) { $sections = array(array( '', 'Customer engagement', array('storesuit-carts', 'storesuit-stock-alerts'), true ), array( '', 'Search and SEO', array('storesuit-redirects'), true )); } if ( $sections ) { foreach ( $sections as $section ) { if ( !$section[3] ) { continue; } echo '

' . (( $section[0] !== '' ? wp_kses_post( self::icon( $section[0] ) ) : '' )) . esc_html( I18n::t( $section[1] ) ) . '

'; foreach ( $daily as $row ) { if ( in_array( $row[1], $section[2], true ) ) { self::mega_daily( $row ); } } echo '
'; } } else { // Settings are not daily work: their heading says they are the most used ones (P1-21). echo '

' . esc_html( I18n::t( ( $key === 'settings' ? 'Frequently used' : 'Daily work' ) ) ) . '

'; foreach ( $daily as $row ) { self::mega_daily( $row ); } echo '
'; } // A short list of other tools stays in the same panel, under the daily work. // A tool already in the daily work is not listed twice; a long list flows in two columns. $daily_pages = array_map( static fn( $row ) => (string) $row[1], $daily ); $daily_urls = array_map( static fn( $row ) => admin_url( 'admin.php?page=' . $row[1] ), $daily ); // Nor a tool with the same name as a daily item: an engine's own screen for the same job (0.62.12, owner item 39). $daily_names = array_map( static fn( $row ) => mb_strtolower( trim( (string) $row[0] ) ), $daily ); $tools = array_values( array_filter( $group['children'], static function ( $child ) use($daily_pages, $daily_urls, $daily_names) { parse_str( (string) wp_parse_url( $child['url'], PHP_URL_QUERY ), $q ); $page = (string) ($q['page'] ?? ''); if ( in_array( mb_strtolower( trim( self::label( (string) $child['name'] ) ) ), $daily_names, true ) ) { return false; } return !in_array( $child['url'], $daily_urls, true ) && (count( $q ) > 1 || !in_array( $page, $daily_pages, true )); } ) ); if ( $tools && !$tabs ) { echo '

' . esc_html( ( $key === 'sales' ? I18n::t( 'More tools' ) : (string) $group['management_label'] ) ) . '

'; foreach ( $tools as $child ) { self::mega_link( self::label( $child['name'] ), $child['url'] ); } echo '
'; } echo '
'; if ( $group['children'] && $tabs ) { // The other tools, open in their own tab (0.62, owner: no folded list that opened below the panel). echo ''; } // The panel's last link names where it goes ("Go to Settings"), not a "work area". echo '
'; } /** * How StoreRuler calls the signed-in person (0.62.3, owner: "address me by my name, not my email"): the first name, else a * display name or nickname without "@"; else nothing, and the greeting has no name. */ public static function person() : string { $user = wp_get_current_user(); foreach ( array((string) get_user_meta( $user->ID, 'first_name', true ), (string) $user->display_name, (string) $user->nickname) as $name ) { $name = trim( $name ); if ( $name !== '' && strpos( $name, '@' ) === false ) { return $name; } } return ''; } /** The account button is the avatar only; the name and the email head its menu (0.62.3). shell() closes the menu. */ private static function account() : void { $user = wp_get_current_user(); $person = self::person(); $email = (string) $user->user_email; $initial = mb_strtoupper( mb_substr( ( $person !== '' ? $person : (( $email !== '' ? $email : 'M' )) ), 0, 1 ) ); echo '
' . '' . (( $person === '' ? '' . esc_html( I18n::t( 'Add your name' ) ) . '' : '' )); } public static function shell() : void { $context = self::context(); if ( !$context ) { return; } $page = self::query()['page'] ?? ''; echo '' . esc_html( I18n::t( 'Skip to content' ) ) . '
StoreRuler'; if ( current_user_can( 'storesuit_orders' ) ) { echo ''; } // Quick actions live in the Help menu now; the account is the avatar only (0.62.3). echo '
'; } }