[ label, renderer class ]. * * @return array */ public static function screens() { return array( '' => array( __( 'Dashboard', 'agentic-commerce-for-woocommerce' ), 'Xpay_Screen_Dashboard' ), '-revenue' => array( __( 'AI Revenue', 'agentic-commerce-for-woocommerce' ), 'Xpay_Screen_Revenue' ), '-ads' => array( __( 'ChatGPT Ads', 'agentic-commerce-for-woocommerce' ), 'Xpay_Screen_Ads' ), '-feed' => array( __( 'Product Feed', 'agentic-commerce-for-woocommerce' ), 'Xpay_Screen_Feed' ), '-tracking' => array( __( 'Tracking', 'agentic-commerce-for-woocommerce' ), 'Xpay_Screen_Tracking' ), '-storefront' => array( __( 'Storefront', 'agentic-commerce-for-woocommerce' ), 'Xpay_Screen_Storefront' ), ); } /** * Boot. * * @return void */ public static function register() { add_action( 'admin_menu', array( __CLASS__, 'menu' ), 9 ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'assets' ) ); } /** * Register the top-level menu and its submenus. * * Position 56 puts us directly above Product Images (58) so the xpay entries * cluster in the sidebar. * * @return void */ public static function menu() { add_menu_page( __( 'xpay Commerce', 'agentic-commerce-for-woocommerce' ), __( 'xpay✦ Commerce', 'agentic-commerce-for-woocommerce' ), 'manage_woocommerce', self::SLUG, array( __CLASS__, 'render' ), 'dashicons-cart', 56 ); foreach ( self::screens() as $suffix => $screen ) { add_submenu_page( self::SLUG, $screen[0], $screen[0], 'manage_woocommerce', self::SLUG . $suffix, array( __CLASS__, 'render' ) ); } // Settings is LINKED, not re-hosted. // // ⛔ Do not give this its own slug and renderer. The settings screen and // everything around it — Xpay_Settings::tab_url(), ten admin-post // handler redirects, the shop-assist and content-pages notices, and the // post-activation redirect — all build `options-general.php` URLs. Point // a second copy at the same renderer and the merchant is silently // ejected back to Settings → xpay the moment they click any tab, Connect, // Disconnect or Save, and never returns to this menu. // // Passing the existing page's URL as the slug adds a menu entry that // links straight there, so the merchant gets one settings screen that // behaves exactly as it always has, and every bookmark, docs link and // support reply keeps working untouched. Relocating settings is its own // change and does not belong in the same release as six new screens. add_submenu_page( self::SLUG, __( 'Settings', 'agentic-commerce-for-woocommerce' ), __( 'Settings', 'agentic-commerce-for-woocommerce' ), 'manage_woocommerce', 'options-general.php?page=' . self::SETTINGS_PAGE ); } /** * Whether the current screen belongs to us. * * @param string $hook Hook suffix. * @return bool */ private static function is_ours( $hook ) { return false !== strpos( (string) $hook, self::SLUG ); } /** * Enqueue on our screens only. * * Versioned by filemtime so a changed stylesheet is always refetched — the * "reinstalled but the browser served a stale admin.css" trap. * * @param string $hook Hook suffix. * @return void */ public static function assets( $hook ) { if ( ! self::is_ours( $hook ) ) { return; } $rel = 'assets/css/admin.css'; $path = XPAY_WC_PATH . $rel; $ver = file_exists( $path ) ? (string) filemtime( $path ) : XPAY_WC_VERSION; wp_enqueue_style( 'xpay-admin', XPAY_WC_URL . $rel, array(), $ver ); } /** * Route to the active screen. * * A screen that throws must not take the whole admin page down — the merchant * gets a readable message and every other screen keeps working. Same posture * as the module registry. * * @return void */ public static function render() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : self::SLUG; $suffix = str_replace( self::SLUG, '', $page ); $screens = self::screens(); $class = isset( $screens[ $suffix ] ) ? $screens[ $suffix ][1] : $screens[''][1]; $title = isset( $screens[ $suffix ] ) ? $screens[ $suffix ][0] : $screens[''][0]; if ( ! class_exists( $class ) || ! method_exists( $class, 'render' ) ) { return; } try { call_user_func( array( $class, 'render' ) ); } catch ( Throwable $e ) { Xpay_UI::open( $title ); Xpay_UI::note( 'bad', '' . esc_html__( 'This screen could not load.', 'agentic-commerce-for-woocommerce' ) . ' ' . esc_html__( 'Your store is unaffected and the rest of the plugin is running normally. If it keeps happening, email merchants@xpay.sh.', 'agentic-commerce-for-woocommerce' ) ); Xpay_UI::close(); if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( '[xpay] screen ' . $class . ' failed: ' . $e->getMessage() ); } } } /** * URL for one of our screens. * * @param string $suffix Screen suffix, '' for the dashboard. * @param array $args Extra query args. * @return string */ public static function settings_url( $args = array() ) { return add_query_arg( array_merge( array( 'page' => self::SETTINGS_PAGE ), $args ), admin_url( 'options-general.php' ) ); } /** * URL for one of our screens. * * @param string $suffix Screen suffix, '' for the dashboard. * @param array $args Extra query args. * @return string */ public static function url( $suffix = '', $args = array() ) { return add_query_arg( array_merge( array( 'page' => self::SLUG . $suffix ), $args ), admin_url( 'admin.php' ) ); } /** * The merchant dashboard's route map — the ONLY place these paths are written. * * ⛔⛔ EVERY ONE OF THESE MUST MATCH A REAL ROUTE in xpay-app * (`src/app/dashboard/commerce/…`). They were previously guessed from the * feature names and every single one was wrong, sending merchants to a 404 * from inside their own admin. Verify against the app's route tree before * changing a value here — a plausible-looking path is not evidence. * * ⛔ Trailing slashes are REQUIRED. The app 404s a dashboard path without one. * * @param string $key Route key. * @return string Absolute URL. */ public static function app_url( $key ) { $routes = array( 'bot-traffic' => '/dashboard/commerce/analytics/bot-traffic/', 'ai-surface' => '/dashboard/commerce/ai-surface/', 'agent-feed' => '/dashboard/commerce/agent-feed/', 'product-faqs' => '/dashboard/commerce/product-faqs/', 'storefront-assistant' => '/dashboard/commerce/storefront-assistant/', 'plan' => '/dashboard/commerce/plan/', 'chatgpt-ads' => '/dashboard/commerce/chatgpt-ads/', ); $path = isset( $routes[ $key ] ) ? $routes[ $key ] : '/dashboard/commerce/'; return 'https://app.xpay.sh' . $path; } /** * Deep links into the merchant's authenticated xpay dashboard. * * ⛔ Shown only when the store is connected — an unconnected merchant sent to * an auth-guarded page lands on a login wall with no context, which is the * "stranded on app.xpay.sh" complaint we already have on file. * ⛔ No token is ever placed in these URLs; the merchant authenticates on * app.xpay.sh exactly as they normally would. * * @return array */ public static function account_shortcuts() { return array( array( 'label' => __( 'Agent traffic & orders', 'agentic-commerce-for-woocommerce' ), 'desc' => __( 'Full history and revenue', 'agentic-commerce-for-woocommerce' ), 'key' => 'bot-traffic', 'icon' => 'dashicons-chart-line', ), array( 'label' => __( 'AI visibility', 'agentic-commerce-for-woocommerce' ), 'desc' => __( 'Where you appear in answers', 'agentic-commerce-for-woocommerce' ), 'key' => 'ai-surface', 'icon' => 'dashicons-visibility', ), array( 'label' => __( 'Catalog health', 'agentic-commerce-for-woocommerce' ), 'desc' => __( 'Feed and product issues', 'agentic-commerce-for-woocommerce' ), 'key' => 'agent-feed', 'icon' => 'dashicons-products', ), array( 'label' => __( 'Product FAQs', 'agentic-commerce-for-woocommerce' ), 'desc' => __( 'Approve what shoppers see', 'agentic-commerce-for-woocommerce' ), 'key' => 'product-faqs', 'icon' => 'dashicons-editor-help', ), array( 'label' => __( 'Storefront assistant', 'agentic-commerce-for-woocommerce' ), 'desc' => __( 'Configure the on-site chat', 'agentic-commerce-for-woocommerce' ), 'key' => 'storefront-assistant', 'icon' => 'dashicons-format-chat', ), array( 'label' => __( 'Billing & plan', 'agentic-commerce-for-woocommerce' ), 'desc' => __( 'Invoices and upgrades', 'agentic-commerce-for-woocommerce' ), 'key' => 'plan', 'icon' => 'dashicons-money-alt', ), ); } /** * Render the account-shortcuts block, when connected. * * @return void */ public static function render_account_shortcuts() { if ( ! Xpay_Plugin::is_connected() ) { return; } Xpay_UI::section( __( 'Your xpay account', 'agentic-commerce-for-woocommerce' ), __( 'The parts of your dashboard you probably came here for. Opens in a new tab.', 'agentic-commerce-for-woocommerce' ) ); echo '
'; foreach ( self::account_shortcuts() as $s ) { // Dashicons ship with WordPress and are already enqueued in wp-admin, // so this costs no request and inherits the admin colour scheme. // aria-hidden: the label beside it already names the destination. printf( '' . '' . '%s%s', esc_url( self::app_url( $s['key'] ) ), esc_attr( isset( $s['icon'] ) ? $s['icon'] : 'dashicons-external' ), esc_html( $s['label'] ), esc_html( $s['desc'] ) ); } echo '
'; } }