'Today', 'storesuit-orders' => 'Sales', 'storesuit-products' => 'Products', 'storesuit-customers' => 'Customers', 'storesuit-marketing' => 'Marketing', 'storesuit-reports' => 'Reports', 'storesuit-settings' => 'Settings & Help', ) as $slug => $source_label ) { $label = I18n::t( $source_label ); $cap = ( in_array( $slug, array( 'storesuit-products', 'storesuit-customers', 'storesuit-marketing', 'storesuit-reports', 'storesuit-settings' ), true ) ? 'manage_woocommerce' : 'storesuit_orders' ); $hook = add_submenu_page( 'storesuit', $label, $label, $cap, $slug, array(__CLASS__, 'render') ); // The menu's first entry is "Today" and Home is the same page: WordPress names the browser tab after the entry, so Home names its own. if ( 'storesuit' === $slug && $hook ) { add_action( 'load-' . $hook, array(__CLASS__, 'home_title') ); } } // The carts page is "Live carts" in the free build (PLAN-0.63 Q14); StoreRuler Pro names it after its abandoned carts. $hidden = array( 'storesuit-guide' => 'Quick actions', 'storesuit-carts' => 'Live carts', 'storesuit-stock-alerts' => 'Waiting customers', 'storesuit-mail-log' => 'Email history', 'storesuit-redirects' => 'Redirects and 404s', 'storesuit-modules' => 'All features', 'storesuit-audit' => 'Activity log', ); foreach ( $hidden as $slug => $source_label ) { $label = I18n::t( $source_label ); $cap = ( in_array( $slug, array( 'storesuit-audit', 'storesuit-carts', 'storesuit-stock-alerts', 'storesuit-mail-log', 'storesuit-redirects' ), true ) ? 'manage_woocommerce' : 'storesuit_orders' ); $hook = add_submenu_page( '', $label, $label, $cap, $slug, array(__CLASS__, 'render') ); if ( $hook ) { add_action( 'load-' . $hook, static function () use($label) { $GLOBALS['title'] = $label; } ); } } // Keep old bookmarks working, but do not present the same order desk twice. $order_desk = I18n::t( 'Order desk' ); $hook = add_submenu_page( '', $order_desk, $order_desk, 'storesuit_orders', 'storesuit-operations', array(__CLASS__, 'render') ); if ( $hook ) { add_action( 'load-' . $hook, static function () use($order_desk) { $GLOBALS['title'] = $order_desk; } ); } self::$navigation = array(); self::$navigation = Navigation::groups( self::$navigation ); // StoreRuler Pro's page: the last entry of the submenu while it is offered (ProPage). ProPage::menu(); } /** load-{page}: the browser tab of Home reads "Home"; the day's work list (view=today) keeps the menu's "Today". */ public static function home_title() : void { if ( Design::home_page() ) { $GLOBALS['title'] = I18n::t( 'Home' ); } } public static function assets() : void { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Only picks the scripts and styles of the admin page being shown; nothing is changed. $page = ( isset( $_GET['page'] ) && is_string( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '' ); // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( strpos( $page, 'storesuit' ) !== 0 ) { return; } // StoreRuler Pro's page is plain HTML over the shell: its stylesheet only, none of the store screens' scripts. if ( $page === ProPage::SLUG ) { ProPage::assets(); return; } wp_enqueue_style( 'storesuit', plugins_url( 'assets/admin.css', STORESUIT_FILE ), array(), STORESUIT_VERSION . '.' . filemtime( STORESUIT_DIR . 'assets/admin.css' ) ); wp_enqueue_style( 'storesuit-dashboard', plugins_url( 'assets/dashboard.css', STORESUIT_FILE ), array('storesuit'), STORESUIT_VERSION . '.' . filemtime( STORESUIT_DIR . 'assets/dashboard.css' ) ); wp_enqueue_style( 'storesuit-counter', plugins_url( 'assets/counter.css', STORESUIT_FILE ), array('storesuit-dashboard', 'storesuit-mega'), (string) filemtime( STORESUIT_DIR . 'assets/counter.css' ) ); wp_enqueue_style( 'storesuit-order-desk', plugins_url( 'assets/order-desk.css', STORESUIT_FILE ), array('storesuit'), (string) filemtime( STORESUIT_DIR . 'assets/order-desk.css' ) ); wp_enqueue_style( 'storesuit-ledger-worklists', plugins_url( 'assets/ledger-worklists.css', STORESUIT_FILE ), array('storesuit-order-desk', 'storesuit-dashboard', 'storesuit-mega'), (string) filemtime( STORESUIT_DIR . 'assets/ledger-worklists.css' ) ); I18n::enqueue_script(); wp_enqueue_script( 'storesuit-charts', plugins_url( 'assets/charts.js', STORESUIT_FILE ), array('storesuit-i18n'), STORESUIT_VERSION . '.' . filemtime( STORESUIT_DIR . 'assets/charts.js' ), true ); if ( $page === 'storesuit' ) { wp_enqueue_script( 'storesuit-home', plugins_url( 'assets/home.js', STORESUIT_FILE ), array('storesuit-charts', 'storesuit-dashboard'), STORESUIT_VERSION . '.' . filemtime( STORESUIT_DIR . 'assets/home.js' ), true ); } wp_enqueue_script( 'storesuit-saved-views', plugins_url( 'assets/saved-views.js', STORESUIT_FILE ), array('storesuit-i18n'), STORESUIT_VERSION . '.' . filemtime( STORESUIT_DIR . 'assets/saved-views.js' ), true ); if ( $page === 'storesuit-reports' ) { wp_enqueue_script( 'storesuit-reports-home', plugins_url( 'assets/reports-home.js', STORESUIT_FILE ), array('storesuit-charts'), STORESUIT_VERSION . '.' . filemtime( STORESUIT_DIR . 'assets/reports-home.js' ), true ); wp_enqueue_style( 'storesuit-reports-home', plugins_url( 'assets/reports-home.css', STORESUIT_FILE ), array('storesuit-dashboard'), STORESUIT_VERSION . '.' . filemtime( STORESUIT_DIR . 'assets/reports-home.css' ) ); } if ( $page === 'storesuit-products' ) { wp_enqueue_script( 'storesuit-products-workspace', plugins_url( 'assets/products-workspace.js', STORESUIT_FILE ), array('storesuit-i18n'), STORESUIT_VERSION . '.' . filemtime( STORESUIT_DIR . 'assets/products-workspace.js' ), true ); wp_enqueue_style( 'storesuit-products-workspace', plugins_url( 'assets/products-workspace.css', STORESUIT_FILE ), array('storesuit'), STORESUIT_VERSION . '.' . filemtime( STORESUIT_DIR . 'assets/products-workspace.css' ) ); } // The product window's "Storefront page" tab: video, unit price, pre-order and the discount rule's warnings (ProductExtras). if ( $page === 'storesuit-products' ) { wp_enqueue_script( 'storesuit-product-extras', plugins_url( 'assets/product-extras.js', STORESUIT_FILE ), array('storesuit-i18n'), (string) filemtime( STORESUIT_DIR . 'assets/product-extras.js' ), true ); wp_enqueue_style( 'storesuit-product-extras', plugins_url( 'assets/product-extras.css', STORESUIT_FILE ), array('storesuit'), (string) filemtime( STORESUIT_DIR . 'assets/product-extras.css' ) ); } wp_enqueue_script( 'storesuit-workflows', plugins_url( 'assets/workflows.js', STORESUIT_FILE ), array_merge( array('storesuit-i18n', 'storesuit-saved-views'), ( $page === 'storesuit-products' ? array('storesuit-products-workspace') : array() ) ), STORESUIT_VERSION . '.' . filemtime( STORESUIT_DIR . 'assets/workflows.js' ), true ); wp_enqueue_script( 'storesuit-merchant', plugins_url( 'assets/merchant.js', STORESUIT_FILE ), array('storesuit-i18n'), (string) filemtime( STORESUIT_DIR . 'assets/merchant.js' ), true ); if ( $page === 'storesuit-products' ) { wp_enqueue_media(); wp_enqueue_script( 'storesuit-product-add', plugins_url( 'assets/product-add.js', STORESUIT_FILE ), array('storesuit-i18n'), (string) filemtime( STORESUIT_DIR . 'assets/product-add.js' ), true ); wp_enqueue_style( 'storesuit-product-add', plugins_url( 'assets/product-add.css', STORESUIT_FILE ), array('storesuit'), (string) filemtime( STORESUIT_DIR . 'assets/product-add.css' ) ); } if ( in_array( $page, array('storesuit-orders', 'storesuit-operations'), true ) ) { wp_enqueue_script( 'storesuit-order-views', plugins_url( 'assets/order-views.js', STORESUIT_FILE ), array('storesuit-i18n'), (string) filemtime( STORESUIT_DIR . 'assets/order-views.js' ), true ); wp_enqueue_script( 'storesuit-order-desk', plugins_url( 'assets/order-desk.js', STORESUIT_FILE ), array('storesuit-i18n', 'storesuit-order-views'), (string) filemtime( STORESUIT_DIR . 'assets/order-desk.js' ), true ); } wp_enqueue_script( 'storesuit-counter', plugins_url( 'assets/counter.js', STORESUIT_FILE ), array('storesuit-i18n'), (string) filemtime( STORESUIT_DIR . 'assets/counter.js' ), true ); wp_enqueue_script( 'storesuit-dashboard', plugins_url( 'assets/dashboard.js', STORESUIT_FILE ), array('storesuit-counter'), STORESUIT_VERSION . '.' . filemtime( STORESUIT_DIR . 'assets/dashboard.js' ), true ); if ( $page === 'storesuit-stock-alerts' ) { wp_enqueue_script( 'storesuit-stock-alerts-admin', plugins_url( 'assets/stock-alerts-admin.js', STORESUIT_FILE ), array('storesuit-i18n'), (string) filemtime( STORESUIT_DIR . 'assets/stock-alerts-admin.js' ), true ); } if ( $page === 'storesuit-mail-log' ) { wp_enqueue_script( 'storesuit-mail-log', plugins_url( 'assets/mail-log.js', STORESUIT_FILE ), array('storesuit-i18n'), (string) filemtime( STORESUIT_DIR . 'assets/mail-log.js' ), true ); } if ( $page === 'storesuit-redirects' ) { wp_enqueue_script( 'storesuit-redirects', plugins_url( 'assets/redirects.js', STORESUIT_FILE ), array('storesuit-i18n'), (string) filemtime( STORESUIT_DIR . 'assets/redirects.js' ), true ); } if ( $page === 'storesuit' ) { wp_enqueue_script( 'storesuit-app-install', plugins_url( 'assets/app-install.js', STORESUIT_FILE ), array('storesuit'), (string) filemtime( STORESUIT_DIR . 'assets/app-install.js' ), true ); } // Sample data is offered to those who may manage the store only (never to a cashier); Store Staff only with the settings // permission, which its actions ask (Roles::staff_actions()). $demo = $page === 'storesuit-guide' && current_user_can( 'manage_woocommerce' ); if ( $demo ) { wp_enqueue_style( 'storesuit-demo-data', plugins_url( 'assets/demo-data.css', STORESUIT_FILE ), array('storesuit'), (string) filemtime( STORESUIT_DIR . 'assets/demo-data.css' ) ); wp_enqueue_script( 'storesuit-demo-data', plugins_url( 'assets/demo-data.js', STORESUIT_FILE ), array('storesuit-i18n', 'storesuit'), (string) filemtime( STORESUIT_DIR . 'assets/demo-data.js' ), true ); } $deps = array_merge( array('storesuit-workflows', 'storesuit-dashboard', 'storesuit-merchant'), ( in_array( $page, array('storesuit-orders', 'storesuit-operations'), true ) ? array('storesuit-order-desk') : array() ), ( $page === 'storesuit-stock-alerts' ? array('storesuit-stock-alerts-admin') : (( $page === 'storesuit-mail-log' ? array('storesuit-mail-log') : array() )) ) ); if ( $page === 'storesuit-settings' ) { wp_enqueue_script( 'storesuit-email-setup', plugins_url( 'assets/email-setup.js', STORESUIT_FILE ), array('storesuit-i18n'), (string) filemtime( STORESUIT_DIR . 'assets/email-setup.js' ), true ); wp_enqueue_style( 'storesuit-email-setup', plugins_url( 'assets/email-setup.css', STORESUIT_FILE ), array('storesuit'), (string) filemtime( STORESUIT_DIR . 'assets/email-setup.css' ) ); $deps[] = 'storesuit-email-setup'; // Settings → License (LicensePanel): the plan, its dates and every license task in one tab. wp_enqueue_script( 'storesuit-license-panel', plugins_url( 'assets/license-panel.js', STORESUIT_FILE ), array('storesuit-i18n'), (string) filemtime( STORESUIT_DIR . 'assets/license-panel.js' ), true ); wp_enqueue_style( 'storesuit-license-panel', plugins_url( 'assets/license-panel.css', STORESUIT_FILE ), array('storesuit'), (string) filemtime( STORESUIT_DIR . 'assets/license-panel.css' ) ); $deps[] = 'storesuit-license-panel'; } // Marketing → SEO health (SeoHealth): the score and the top issues, read only. if ( $page === 'storesuit-marketing' && current_user_can( 'manage_woocommerce' ) ) { wp_enqueue_script( 'storesuit-seo-health', plugins_url( 'assets/seo-health.js', STORESUIT_FILE ), array('storesuit-i18n'), (string) filemtime( STORESUIT_DIR . 'assets/seo-health.js' ), true ); wp_enqueue_style( 'storesuit-seo-health', plugins_url( 'assets/seo-health.css', STORESUIT_FILE ), array('storesuit'), (string) filemtime( STORESUIT_DIR . 'assets/seo-health.css' ) ); $deps[] = 'storesuit-seo-health'; } // The product window's Content tab: a description and photo alt text with AI for one product (ProductAi). if ( $page === 'storesuit-products' ) { wp_enqueue_script( 'storesuit-product-ai', plugins_url( 'assets/product-ai.js', STORESUIT_FILE ), array('storesuit-i18n'), (string) filemtime( STORESUIT_DIR . 'assets/product-ai.js' ), true ); $deps[] = 'storesuit-product-ai'; } wp_enqueue_script( 'storesuit', plugins_url( 'assets/admin.js', STORESUIT_FILE ), $deps, STORESUIT_VERSION . '.' . filemtime( STORESUIT_DIR . 'assets/admin.js' ), true ); // Complete customer directory; explicit dependency prevents footer ordering races. if ( $page === 'storesuit-customers' ) { wp_enqueue_script( 'storesuit-customer-directory', plugins_url( 'assets/customer-directory.js', STORESUIT_FILE ), array('storesuit-i18n'), (string) filemtime( STORESUIT_DIR . 'assets/customer-directory.js' ), true ); wp_enqueue_style( 'storesuit-customer-directory', plugins_url( 'assets/customer-directory.css', STORESUIT_FILE ), array('storesuit'), (string) filemtime( STORESUIT_DIR . 'assets/customer-directory.css' ) ); wp_scripts()->registered['storesuit']->deps[] = 'storesuit-customer-directory'; } wp_localize_script( 'storesuit', 'StoreSuitNavigation', self::$navigation ); wp_localize_script( 'storesuit', 'StoreSuitJourneys', Navigation::journeys() ); $online_payment_ready = OrderMessages::online_payment_ready(); $countries = array_map( static fn( $name ) => html_entity_decode( $name, ENT_QUOTES, 'UTF-8' ), WC()->countries->get_countries() ); $config = array( 'url' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'storesuit' ), 'page' => $page, 'currency' => get_woocommerce_currency(), 'price_decimals' => wc_get_price_decimals(), 'today' => current_time( 'Y-m-d' ), 'country' => WC()->countries->get_base_country(), 'countries' => $countries, 'carriers' => Shipping::public_carriers(), 'carrier_default' => Shipping::default_carrier(), 'pick_list_url' => admin_url( 'admin-post.php?action=storesuit_pick_list' ), 'pick_list_nonce' => wp_create_nonce( 'storesuit_pick_list' ), 'packing_slip_url' => admin_url( 'admin-post.php?action=storesuit_packing_slips' ), 'packing_slip_nonce' => wp_create_nonce( 'storesuit_packing_slips' ), 'online_payment_ready' => $online_payment_ready, 'customer_email_ready' => OrderMessages::email_ready(), 'payment_settings' => admin_url( 'admin.php?page=wc-settings&tab=checkout' ), 'customer_email_settings' => admin_url( 'admin.php?page=wc-settings&tab=email§ion=wc_email_customer_note' ), 'fulfil' => current_user_can( 'storesuit_fulfil' ), 'finance' => current_user_can( 'storesuit_finance' ), 'collect' => current_user_can( 'storesuit_finance' ) || current_user_can( Roles::COLLECT ), 'approve' => current_user_can( 'storesuit_approve' ), 'refund' => current_user_can( Roles::REFUND ), 'manage' => current_user_can( 'manage_woocommerce' ), 'weight_unit' => get_option( 'woocommerce_weight_unit', 'kg' ), 'dimension_unit' => get_option( 'woocommerce_dimension_unit', 'cm' ), ); // Home is drawn without the order queue while the order index is ready (admin.js load()). if ( $page === 'storesuit' ) { $config['index_ready'] = get_option( 'storesuit_order_index_ready' ) === '1'; } $config = self::kit_config( $config, $page ); wp_localize_script( 'storesuit', 'StoreSuitConfig', $config ); } /** * The shared kit's settings (persona fixes B01), after the others: the store's decimal mark for typed amounts (KIT-1), the * store's clock (KIT-3), the states of each country on the screens with an address form (KIT-2) and, on the settings page, * what the store's details hold (KIT-5). */ public static function kit_config( array $config, string $page ) : array { $config['price_decimal_sep'] = wc_get_price_decimal_separator(); $config['tz_offset'] = (int) round( wp_timezone()->getOffset( new \DateTimeImmutable('now') ) / 60 ); $config['hour'] = (int) current_time( 'G' ); $config['email_setup_manage'] = current_user_can( 'manage_options' ); if ( in_array( $page, array('storesuit-customers', 'storesuit-orders', 'storesuit-operations'), true ) ) { $config['states'] = self::states(); } if ( $page === 'storesuit-settings' && current_user_can( 'manage_woocommerce' ) ) { $config['store_details'] = self::store_details(); // The seller details the checkout's contracts are written from (CheckoutLegal): their own panel, apart from the // store details, which name no tax or invoice data (owner decision OD-11). $config['legal_seller'] = CheckoutLegal::seller_form(); } return $config; } /** WooCommerce's states of each country that has them, as [code, name] pairs in WooCommerce's order (JSON keeps it). */ public static function states() : array { $out = array(); foreach ( (array) WC()->countries->get_states() as $country => $states ) { if ( !is_array( $states ) || !$states ) { continue; } foreach ( $states as $code => $name ) { $out[$country][] = array((string) $code, html_entity_decode( (string) $name, ENT_QUOTES, 'UTF-8' )); } } return $out; } /** * The store's details as they are kept, for the settings page's read-only summary: the WooCommerce store address, the bank * transfer accounts (and whether bank transfer is offered), and in StoreRuler Pro the till receipt header and the shipping * label sender. Each comes with the screen that changes it; nothing here is saved by StoreRuler. */ public static function store_details() : array { $countries = WC()->countries; $country = $countries->get_base_country(); $state = $countries->get_base_state(); $names = $countries->get_countries(); $states = $countries->get_states( $country ); $place = trim( implode( ' ', array_filter( array($countries->get_base_postcode(), $countries->get_base_city()) ) ) ); $region = ( is_array( $states ) && isset( $states[$state] ) ? $states[$state] : $state ); $street = array_filter( array($countries->get_base_address(), $countries->get_base_address_2(), $place), static fn( $line ) => trim( (string) $line ) !== '' ); $address = ( $street ? array_merge( $street, array(implode( ', ', array_filter( array($region, $names[$country] ?? $country) ) )) ) : array() ); $bacs = get_option( 'woocommerce_bacs_settings', array() ); $accounts = array(); foreach ( (array) get_option( 'woocommerce_bacs_accounts', array() ) as $account ) { if ( !is_array( $account ) ) { continue; } $bank = trim( (string) ($account['bank_name'] ?? '') ); $number = trim( (string) (( ($account['iban'] ?? '') !== '' ? $account['iban'] : $account['account_number'] ?? '' )) ); if ( $bank !== '' || $number !== '' ) { $accounts[] = array( 'bank' => $bank, 'iban' => $number, ); } } $details = array( 'address' => array( 'lines' => array_values( array_map( static fn( $line ) => html_entity_decode( (string) $line, ENT_QUOTES, 'UTF-8' ), $address ) ), 'url' => admin_url( 'admin.php?page=wc-settings&tab=general' ), ), 'bank' => array( 'enabled' => is_array( $bacs ) && ($bacs['enabled'] ?? 'no') === 'yes', 'accounts' => $accounts, 'url' => admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=bacs' ), ), ); return $details; } /** plugin_action_links_{basename}: "Open StoreRuler" on the Plugins screen, for those who may open it (P3-10). */ public static function action_links( $links ) { if ( !is_array( $links ) || !current_user_can( 'storesuit_orders' ) ) { return $links; } array_unshift( $links, '' . esc_html( I18n::t( 'Open StoreRuler' ) ) . '' ); return $links; } public static function render() : void { if ( !current_user_can( 'storesuit_orders' ) ) { return; } echo '

' . esc_html( I18n::t( 'Store data is loading…' ) ) . '

'; } public static function ajax() : void { check_ajax_referer( 'storesuit', 'nonce' ); $action = ''; try { Store::require_cap( 'storesuit_orders' ); // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- A JSON request (nonce checked above), decoded below; each action validates and sanitizes its own fields. $raw = ( isset( $_POST['payload'] ) && is_string( $_POST['payload'] ) ? wp_unslash( $_POST['payload'] ) : '{}' ); // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( strlen( $raw ) > 200000 ) { throw new \InvalidArgumentException(I18n::t( 'The request is too large.' )); } $data = json_decode( $raw, true ); if ( !is_array( $data ) ) { throw new \InvalidArgumentException(I18n::t( 'Invalid request.' )); } $action = Store::text( $data, 'action' ); $id = absint( $data['id'] ?? 0 ); $handled = true; $result = self::route( $action, $data, $id, $handled ); if ( !$handled ) { throw new \InvalidArgumentException(I18n::t( 'Action not found.' )); } wp_send_json_success( $result ); } catch ( \Throwable $e ) { // An exception carries a message for the person. A PHP error (a bug) is not shown, as its message names files; it // goes to WooCommerce's log for the store's support instead (WooCommerce → Status → Logs, source "storeruler"). if ( !$e instanceof \Exception ) { self::log_error( $e, $action ); } // A price of 0 (Products::zero_price()) is named, so the screen can ask whether the product is free and send it again. $code = ( $e instanceof \InvalidArgumentException && $e->getCode() === Products::ZERO_PRICE ? array( 'code' => 'zero_price', ) : array() ); // So is a phone another customer holds (Customers::guard_phone()): the screen offers that customer, or keeps the number on both. if ( $e instanceof \InvalidArgumentException && $e->getCode() === Customers::PHONE_TAKEN ) { $code = array( 'code' => 'phone_taken', 'customer' => Customers::phone_holder(), ); } // And a shipment that already has another tracking number (Operations::shipment_tracking()): the screen asks to correct it. if ( $e instanceof \InvalidArgumentException && $e->getCode() === Operations::TRACKING_SAVED ) { $code = array( 'code' => 'tracking_saved', ); } // And a request that needs a yes first (Store::CONFIRM): the screen asks with the message and sends it again. if ( $e instanceof \InvalidArgumentException && $e->getCode() === Store::CONFIRM ) { $code = array( 'code' => 'confirm', ); } wp_send_json_error( array( 'message' => ( $e instanceof \Exception ? $e->getMessage() : I18n::t( 'The action could not be completed.' ) ), ) + $code, 400 ); } } /** A PHP error of a router action, written to WooCommerce's log (never to the screen). */ private static function log_error( \Throwable $e, string $action ) : void { if ( !function_exists( 'wc_get_logger' ) ) { return; } wc_get_logger()->error( sprintf( '%s in the router action "%s": %s (%s:%d)', get_class( $e ), $action, $e->getMessage(), $e->getFile(), $e->getLine() ), array( 'source' => 'storeruler', 'action' => $action, ) ); } /** The free router actions (58 since 0.62.13). $handled becomes false for an action it does not know (StoreRuler Pro routes those). */ private static function route( string $action, array $data, int $id, bool &$handled ) { $handled = true; switch ( $action ) { case 'state': $result = self::state( $data ); break; case 'product_card': $result = Products::card( $id ); break; case 'product_list': $result = Products::listing( $data ); break; case 'product_family_price_preview': $result = Products::family_price_preview( $data ); break; case 'product_family_price_save': $result = Products::family_price_save( $data ); break; case 'product_publication': $result = Products::publication( $data ); break; case 'product_save': $result = Products::save( $data ); break; case 'product_create': $result = Products::create( $data ); break; case 'product_extras_read': $result = ProductExtras::read( $data ); break; case 'product_extras_save': $result = ProductExtras::save( $data ); break; case 'reports_overview': $result = Reports::overview( $data ); break; case 'dashboard': $result = Dashboard::read( $data ); break; case 'dashboard_stock': $result = Reports::stock_card(); break; case 'demo_status': $result = DemoData::status(); break; case 'demo_create': $result = DemoData::create( $data ); break; case 'demo_remove': $result = DemoData::remove( $data ); break; case 'today': $result = Today::read(); break; case 'home': $result = Home::read(); break; case 'restock_picks': $result = RestockPicks::read(); break; // Home: selling fast, worth buying again (31 Oct 2026). case 'review_prompt': $result = ReviewPrompt::respond( $data ); break; case 'global_search': $result = UniversalSearch::read( $data ); break; case 'health_read': $result = Health::read(); break; case 'health_storefront_known': $result = Health::storefront_known( $data ); break; // Plugins that add to the storefront: known ones, both builds. case 'health_lookup_rebuild': $result = Health::lookup_rebuild(); break; // WooCommerce's product lookup tables, rebuilt in the background (both builds). case 'health_variable_stock_sync': $result = Health::variable_stock_sync(); break; // Products with options take their stock status from their options (both builds). case 'health_jobs_fail_stuck': $result = Health::jobs_fail_stuck(); break; // Action Scheduler jobs stuck in progress marked failed (both builds). case 'health_timezone_set': $result = Health::timezone_set(); break; // The store country's time zone, while WordPress's default clock is on (both builds). case 'health_mail_subjects_fix': $result = Health::mail_subjects_fix( $data ); break; // A subject of its own for the "on hold" customer e-mail (denetim R3-5; both builds). case 'health_address_leftovers_clean': $result = Health::address_leftovers_clean( $data ); break; // Address rows of orders that are gone, deleted on confirmation (denetim R4-2; both builds). case 'health_leftover_page': $result = Health::leftover_page( $data ); break; // A page left empty by the previous theme, to draft or to draft and redirect (both builds). case 'health_template_default': $result = Health::template_default( $data ); break; // Pages naming a template the theme lacks: the default template (both builds). case 'activity_read': $result = Activity::read( $data ); break; case 'cart_signals_read': $result = CartSignals::read( $data ); break; case 'cart_signals_save': $result = CartSignals::save( $data ); break; case 'stock_alerts_read': $result = StockAlerts::read(); break; case 'license_panel': $result = LicensePanel::read(); break; case 'license_finish': $result = LicensePanel::finish(); break; case 'search_signals_read': $result = SearchSignals::read(); break; case 'search_signals_dismiss': $result = SearchSignals::dismiss( Store::text( $data, 'term' ) ); break; case 'mail_log_read': $result = MailLog::read( $data ); break; case 'email_setup_read': $result = EmailSetup::read(); break; case 'email_setup_save': $result = EmailSetup::save( $data ); break; case 'email_setup_test': $result = EmailSetup::test( $data ); break; case 'email_setup_confirm': $result = EmailSetup::confirm( $data ); break; case 'email_setup_enable': $result = EmailSetup::enable( $data ); break; case 'email_setup_off': $result = EmailSetup::off( $data ); break; case 'email_setup_forget': $result = EmailSetup::forget( $data ); break; case 'redirects_read': $result = Redirects::read( $data ); break; case 'redirect_save': $result = Redirects::save( $data ); break; case 'redirect_remove': $result = Redirects::remove( $data ); break; case 'redirects_import': $result = Redirects::import( $data ); break; case 'redirects_export': $result = Redirects::export(); break; case 'not_found_read': $result = Redirects::not_found( $data ); break; case 'not_found_clear': $result = Redirects::clear_not_found(); break; case 'seo_health_read': $result = SeoHealth::read( $data ); break; case 'product_ai_read': $result = ProductAi::read( $data ); break; case 'product_ai_connect': $result = ProductAi::connect( $data ); break; case 'product_ai_disconnect': $result = ProductAi::disconnect( $data ); break; case 'product_ai_draft': $result = ProductAi::draft( $data ); break; case 'product_ai_apply': $result = ProductAi::apply( $data ); break; case 'product_ai_undo': $result = ProductAi::undo( $data ); break; case 'product_ai_limit': $result = ProductAi::limit( $data ); break; case 'stock_alerts_save': $result = StockAlerts::save( $data ); break; case 'stock_alerts_send': $result = StockAlerts::send_now( $id ); break; case 'customers': $result = Customers::search( $data ); break; case 'customer_directory': $result = CustomerDirectory::listing( $data ); break; case 'legal_seller_save': $result = CheckoutLegal::seller_save( $data ); break; // The contracts' seller details, both builds. case 'legal_template_read': $result = CheckoutLegal::template_read( $data ); break; // The contracts' editable text, both builds. case 'legal_template_save': $result = CheckoutLegal::template_save( $data ); break; case 'legal_disclaimer_accept': $result = CheckoutLegal::disclaimer_accept(); break; case 'customer_recent': $result = Customers::recent(); break; case 'order_views_read': $result = OrderViews::read( $data ); break; case 'order_views_import': $result = OrderViews::import( $data ); break; case 'order_views_save': $result = OrderViews::save( $data ); break; case 'order_views_remove': $result = OrderViews::remove( $data ); break; case 'customer_card': $result = Customers::card( $id ); break; case 'customer_guest_card': $result = CustomerGuests::card( Store::text( $data, 'id' ) ); break; // A customer without an account (29 Oct 2026). case 'customer_notes': $result = Customers::notes( $id, $data ); break; case 'customer_profile_save': $result = Customers::save_profile( $id, $data ); break; case 'customer_details_save': $result = Customers::save_details( $id, $data ); break; case 'customer_note_add': $result = Customers::add_note( $id, $data ); break; case 'customer_create': $result = Customers::create( $data ); break; // OD-9, both builds (B03b) case 'order': $result = Orders::summary( Orders::get( $id ) ); break; case 'order_timeline': $result = Activity::order_timeline( $id ); break; case 'order_message_preview': $result = OrderMessages::preview( $id, $data ); break; case 'order_message_send': $result = OrderMessages::send( $id, $data ); break; case 'order_contact_email_save': $result = OrderContact::save_email( $id, $data ); break; case 'cancel': $result = Orders::cancel( $id, $data ); break; case 'fulfil': $result = Operations::fulfil( $id, $data ); break; case 'shipment_tracking': $result = Operations::shipment_tracking( $id, $data ); break; case 'carrier_profiles_read': $result = CarrierProfiles::read(); break; case 'carrier_profiles_save': $result = CarrierProfiles::save( $data ); break; case 'order_advance': $result = OrderFlow::advance( $id, $data ); break; case 'orders_flow_bulk': $result = OrderFlow::bulk( $data ); break; case 'order_mark_paid': $result = Orders::mark_paid( $id, $data ); break; // B03a (ORD-2, OD-3): the free build's "Mark as paid". default: $handled = false; return null; } return $result; } public static function state( array $data ) : array { $index = OrderIndex::sync(); if ( !$index['ready'] ) { return array( 'index' => $index, ); } $result = OrderIndex::query( $data ); $pro = array(); return array_merge( $result, array( 'index' => $index, ), $pro ); } }