label) and render it on * `bylt_settings_tab_`. * * @package BYLTCommerce */ defined( 'ABSPATH' ) || exit; class Bylt_Admin { const PAGE = 'bylt'; const SETTINGS_PAGE = 'bylt-settings'; const AUDIT_PAGE = 'bylt-audit'; /** @var Bylt_Plugin */ private $plugin; /** @var string[] Screen IDs of Toolkit pages. */ private static $screens = array(); /** @var array section slug => admin page slug, for modules with their own menu. */ private static $own_pages = array(); /** @var array Top-level menus added by modules: menu slug => list of submenu slugs. */ private static $apps = array(); public function __construct( Bylt_Plugin $plugin ) { $this->plugin = $plugin; add_action( 'admin_menu', array( $this, 'menu' ), 9 ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'assets' ) ); add_action( 'admin_post_bylt_save_modules', array( $this, 'save' ) ); add_action( 'admin_post_bylt_save_general', array( $this, 'save_general' ) ); add_action( 'bylt_settings_tab_general', array( $this, 'render_general' ) ); add_action( 'admin_notices', array( $this, 'failure_notice' ) ); add_filter( 'plugin_action_links_' . plugin_basename( BYLT_FILE ), array( $this, 'action_links' ) ); add_filter( 'woocommerce_screen_ids', array( $this, 'screen_ids' ) ); add_filter( 'submenu_file', array( __CLASS__, 'highlight' ), 20, 2 ); add_action( 'admin_init', array( __CLASS__, 'redirect_old_urls' ) ); } /** A module section that now has its own page (e.g. 'wholesale' → 'bylt-wholesale'). */ public static function register_page( $section, $page ) { self::$own_pages[ $section ] = $page; } /** * Add a top-level menu for a module that's a daily work area. * * @param string $slug Page slug of the first screen, or a URL such as edit.php?post_type=shop_coupon. * @param string $title Menu title. * @param callable|null $callback Renders $slug (null when $slug is a URL). * @param string $icon Dashicon class or data URI. * @param float $position Menu position. * @param array $items Submenu: URL or slug => label (or array( label, callback ) for another page). The first entry is the top item itself. * @param string $cap Capability (default: Toolkit's). * @param int $badge Count shown on the menu, 0 for none. * @return string|false Hook suffix of the top page. */ public static function app_menu( $slug, $title, $callback, $icon, $position, array $items = array(), $cap = '', $badge = 0 ) { // Work areas stay visible to store staff on white-labeled sites; only the Toolkit menu is restricted. $cap = $cap ? $cap : self::cap(); $label = $title . ( $badge ? ' ' . (int) $badge . '' : '' ); $hook = add_menu_page( $title, $label, $cap, $slug, $callback, $icon, $position ); self::$screens[] = $hook; $items = $items ? $items : array( $slug => $title ); foreach ( $items as $item => $item_label ) { // A label, or array( label, callback ) for a screen with its own page slug. $cb = is_array( $item_label ) ? $item_label[1] : ( $item === $slug ? $callback : null ); $item_label = is_array( $item_label ) ? $item_label[0] : $item_label; $h = add_submenu_page( $slug, $item_label, $item_label, $cap, $item, $cb ); if ( $h ) { self::$screens[] = $h; } } self::$apps[ $slug ] = array_keys( $items ); return $hook; } /** Move a submenu item to just after another one (e.g. Subscriptions after Orders under WooCommerce). */ public static function move_after( $parent, $slug, array $after ) { global $submenu; if ( empty( $submenu[ $parent ] ) ) { return; } $items = array_values( $submenu[ $parent ] ); $mine = null; foreach ( $items as $i => $it ) { if ( $it[2] === $slug ) { $mine = $it; unset( $items[ $i ] ); } } if ( ! $mine ) { return; } $items = array_values( $items ); $pos = count( $items ); foreach ( $items as $i => $it ) { foreach ( $after as $a ) { if ( 0 === strpos( $it[2], $a ) ) { $pos = $i + 1; } } } array_splice( $items, $pos, 0, array( $mine ) ); $submenu[ $parent ] = $items; // phpcs:ignore WordPress.WP.GlobalVariablesOverride } /** Highlight the submenu item that matches the current screen (most specific query wins). */ public static function highlight( $submenu_file, $parent_file = '' ) { if ( ! isset( self::$apps[ $parent_file ] ) ) { return $submenu_file; } // phpcs:disable WordPress.Security.NonceVerification -- display only. $best = null; $score = -1; foreach ( self::$apps[ $parent_file ] as $item ) { $args = array(); if ( false !== strpos( $item, '?' ) ) { wp_parse_str( (string) wp_parse_url( $item, PHP_URL_QUERY ), $args ); } else { $args = array( 'page' => $item ); } $match = true; foreach ( $args as $k => $v ) { if ( ! isset( $_GET[ $k ] ) || (string) wp_unslash( $_GET[ $k ] ) !== (string) $v ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput $match = false; break; } } if ( $match && count( $args ) > $score ) { $best = $item; $score = count( $args ); } } // phpcs:enable return null === $best ? $submenu_file : $best; } /** Old Toolkit → Settings links for sections that moved to their own page. */ public static function redirect_old_urls() { // phpcs:disable WordPress.Security.NonceVerification -- redirect only. if ( isset( $_GET['page'], $_GET['section'] ) && self::SETTINGS_PAGE === $_GET['page'] ) { $section = sanitize_key( wp_unslash( $_GET['section'] ) ); if ( isset( self::$own_pages[ $section ] ) ) { $args = array_map( 'sanitize_text_field', wp_unslash( $_GET ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput unset( $args['page'], $args['section'] ); wp_safe_redirect( self::url( $section, $args ) ); exit; } } // phpcs:enable } /** Who can use Toolkit: shop managers when WooCommerce is active, otherwise admins. */ public static function cap() { return class_exists( 'WooCommerce' ) ? 'manage_woocommerce' : 'manage_options'; } /** * URL of a Toolkit screen. 'modules' and 'audit' are their own pages; * anything else is a section of the Settings page. */ public static function url( $tab = 'modules', $args = array() ) { if ( 'modules' === $tab ) { $base = array( 'page' => self::PAGE ); } elseif ( 'audit' === $tab ) { $base = array( 'page' => self::AUDIT_PAGE ); } elseif ( isset( self::$own_pages[ $tab ] ) ) { $base = array( 'page' => self::$own_pages[ $tab ] ); } else { $base = array( 'page' => self::SETTINGS_PAGE, 'section' => $tab, ); } return add_query_arg( array_merge( $base, $args ), admin_url( 'admin.php' ) ); } /** Name shown for the plugin in the admin ("Toolkit" unless white-labeled). */ public static function brand() { return (string) apply_filters( 'bylt_brand', __( 'Toolkit', 'byltcommerce-toolkit' ) ); } /** Show plan badges, upgrade buttons and locked Pro modules? */ public static function show_licensing() { return (bool) apply_filters( 'bylt_show_licensing', true ); } /** Can the current user see the Toolkit screens at all? */ public static function visible_to_user() { return (bool) apply_filters( 'bylt_admin_visible', true ); } /** Settings sections from enabled modules: slug => label. */ private function sections() { $sections = apply_filters( 'bylt_settings_tabs', array() ); unset( $sections['modules'], $sections['audit'], $sections['general'] ); $sections['general'] = __( 'General', 'byltcommerce-toolkit' ); return $sections; } public function menu() { if ( ! self::visible_to_user() ) { return; // White label: the Toolkit screens are only for chosen users. Modules keep working. } $cap = self::cap(); $brand = self::brand(); $icon = 'data:image/svg+xml;base64,' . base64_encode( // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- admin menu icons must be a base64 data URI. '' ); $icon = (string) apply_filters( 'bylt_menu_icon', $icon ); self::$screens[] = add_menu_page( $brand, $brand, $cap, self::PAGE, array( $this, 'render_modules_page' ), $icon, 58.7 ); /* translators: %s: plugin name in the menu, e.g. Toolkit */ self::$screens[] = add_submenu_page( self::PAGE, sprintf( __( '%s modules', 'byltcommerce-toolkit' ), $brand ), __( 'Modules', 'byltcommerce-toolkit' ), $cap, self::PAGE, array( $this, 'render_modules_page' ) ); if ( $this->sections() ) { /* translators: %s: plugin name in the menu */ self::$screens[] = add_submenu_page( self::PAGE, sprintf( __( '%s settings', 'byltcommerce-toolkit' ), $brand ), __( 'Settings', 'byltcommerce-toolkit' ), $cap, self::SETTINGS_PAGE, array( $this, 'render_settings_page' ) ); } /* translators: %s: plugin name in the menu */ self::$screens[] = add_submenu_page( self::PAGE, sprintf( __( '%s audit log', 'byltcommerce-toolkit' ), $brand ), __( 'Audit log', 'byltcommerce-toolkit' ), $cap, self::AUDIT_PAGE, array( $this, 'render_audit_page' ) ); } /** Load WooCommerce's admin CSS (status badges etc.) on Toolkit screens. */ public function screen_ids( $ids ) { return apply_filters( 'bylt_screen_ids', array_merge( $ids, array_filter( self::$screens ) ) ); } public function action_links( $links ) { array_unshift( $links, '' . esc_html__( 'Modules', 'byltcommerce-toolkit' ) . '' ); return $links; } /* --------------------------------------------------------------------- * Shared header * ------------------------------------------------------------------- */ private function header( $title ) { $lic = Bylt_License::status(); if ( ! self::show_licensing() ) { echo '

' . esc_html( $title ) . '

'; return; } ?>

id, array( 'woocommerce_page_wc-orders', 'shop_order', 'edit-shop_order', 'product', 'edit-product', 'user-edit', 'profile', 'users' ), true ); if ( 0 !== strpos( $page, 'bylt' ) && ! $woo ) { return; } self::enqueue(); if ( 0 === strpos( $page, 'bylt' ) ) { wp_enqueue_script( 'bylt-admin', BYLT_URL . 'assets/js/admin.js', array(), BYLT_VERSION, true ); } } /** * An image URL field with a "Choose from Media Library" button and a preview. * * @param string $name Field name. * @param string $value Current URL. * @param string $id Field id. */ public static function media_field( $name, $value, $id = '' ) { wp_enqueue_media(); wp_enqueue_script( 'bylt-admin', BYLT_URL . 'assets/js/admin.js', array(), BYLT_VERSION, true ); $id = $id ? $id : 'bylt-media-' . sanitize_key( $name ); echo '
'; echo ''; echo '
'; echo ' '; echo '
'; } /** Load the shared stylesheet. Modules call this for boxes they add to WooCommerce screens. */ public static function enqueue() { wp_enqueue_style( 'bylt-admin', BYLT_URL . 'assets/css/admin.css', array(), BYLT_VERSION ); } /** * A small inline SVG icon (stroke icons, inherit the text color). * * @param string $name Icon name. * @return string SVG markup (trusted, built from the fixed paths below). */ public static function icon( $name ) { $paths = array( 'lock' => '', 'inbox' => '', 'list' => '', 'users' => '', 'card' => '', 'pin' => '', 'repeat' => '', 'tag' => '', 'check' => '', 'store' => '', ); if ( ! isset( $paths[ $name ] ) ) { return ''; } return ''; } /** Allowed tags for echoing icon(). */ public static function svg_kses() { $a = array( 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true ); return array( 'svg' => array_merge( $a, array( 'viewbox' => true, 'width' => true, 'height' => true, 'aria-hidden' => true, 'focusable' => true ) ), 'path' => array_merge( $a, array( 'd' => true ) ), 'rect' => array_merge( $a, array( 'x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true ) ), 'circle' => array_merge( $a, array( 'cx' => true, 'cy' => true, 'r' => true ) ), ); } /** * An empty-state box with an icon, a message and an optional button. * * @param string $icon Icon name. * @param string $text Message (plain text). * @param string $label Button label. * @param string $url Button URL. */ public static function empty_state( $icon, $text, $label = '', $url = '' ) { echo '
' . wp_kses( self::icon( $icon ), self::svg_kses() ) . '

' . esc_html( $text ) . '

'; if ( $label && $url ) { echo '' . esc_html( $label ) . ''; } echo '
'; } /* --------------------------------------------------------------------- * Modules * ------------------------------------------------------------------- */ public function render_modules_page() { $registry = $this->plugin->registry(); $enabled = $this->plugin->enabled_slugs(); $failures = $this->plugin->failures(); $can_edit = current_user_can( 'manage_options' ); $sections = $this->sections(); $links = apply_filters( 'bylt_module_settings_urls', array() ); $grouped = array(); foreach ( $registry as $slug => $meta ) { $grouped[ $meta['category'] ][ $slug ] = $meta; } ?>
header( self::brand() ); ?>

$label ) : if ( empty( $grouped[ $cat ] ) ) { continue; } ?>

    $meta ) : $is_on = in_array( $slug, $enabled, true ); $failed = isset( $failures[ $slug ] ); $locked = ! empty( $meta['locked'] ); if ( $locked && ! self::show_licensing() ) { continue; // White label: don't advertise modules this license doesn't include. } $unavailable = ! $locked && ! empty( $meta['unavailable'] ); $active = $this->plugin->is_active( $slug ); $settings = ''; if ( $active && isset( $links[ $slug ] ) ) { $settings = $links[ $slug ]; } elseif ( $active && isset( $sections[ $slug ] ) ) { $settings = self::url( $slug ); } $id = 'bylt-mod-' . $slug; ?>
  • />
    ' . esc_html__( 'Pro', 'byltcommerce-toolkit' ) . '' : ''; ?>
plugin->enabled_slugs(); $slugs = isset( $_POST['modules'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['modules'] ) ) : array(); $this->plugin->save_enabled( $slugs ); $after = $this->plugin->enabled_slugs(); $on = array_diff( $after, $before ); $off = array_diff( $before, $after ); if ( $on || $off ) { Bylt_Audit_Log::log( 'core', 'modules_changed', trim( ( $on ? 'Enabled: ' . implode( ', ', $on ) . '. ' : '' ) . ( $off ? 'Disabled: ' . implode( ', ', $off ) . '.' : '' ) ) ); } wp_safe_redirect( self::url( 'modules', array( 'updated' => 1 ) ) ); exit; } /* --------------------------------------------------------------------- * Settings * ------------------------------------------------------------------- */ public function render_settings_page() { $sections = $this->sections(); // phpcs:ignore WordPress.Security.NonceVerification $current = isset( $_GET['section'] ) ? sanitize_key( wp_unslash( $_GET['section'] ) ) : ''; if ( ! isset( $sections[ $current ] ) ) { $current = (string) key( $sections ); } ?>
header( sprintf( __( '%s settings', 'byltcommerce-toolkit' ), self::brand() ) ); ?>
    $label ) : ?>

' . esc_html__( 'Settings saved.', 'byltcommerce-toolkit' ) . '

'; } ?>
header( __( 'Audit log', 'byltcommerce-toolkit' ) ); ?>
plugin->failures(); if ( ! $failures || ! current_user_can( self::cap() ) ) { return; } $registry = $this->plugin->registry(); echo '

' . esc_html__( 'Toolkit skipped modules that failed to load:', 'byltcommerce-toolkit' ) . '

    '; foreach ( $failures as $slug => $msg ) { echo '
  • ' . esc_html( $registry[ $slug ]['name'] . ' — ' . $msg ) . '
  • '; } echo '

' . esc_html__( 'Manage modules', 'byltcommerce-toolkit' ) . '

'; } }