* * @see http://wcpos.com * @package WCPOS\WooCommercePOS */ namespace WCPOS\WooCommercePOS\Admin; use WCPOS\WooCommercePOS\Logger; use WCPOS\WooCommercePOS\Services\Analytics; use WCPOS\WooCommercePOS\Services\Analytics_Profile; use WCPOS\WooCommercePOS\Services\Landing_Profile; use WCPOS\WooCommercePOS\Services\Lifecycle_Events; use WCPOS\WooCommercePOS\Services\Settings; use const WCPOS\WooCommercePOS\PLUGIN_NAME; use const WCPOS\WooCommercePOS\PLUGIN_URL; use const WCPOS\WooCommercePOS\TRANSLATION_VERSION; use const WCPOS\WooCommercePOS\VERSION as PLUGIN_VERSION; /** * Menu class. */ class Menu { /** * Unique top level menu identifier. * * @var string */ public $toplevel_screen_id; /** * Unique top level menu identifier. * * @var string */ public $settings_screen_id; /** * Gallery submenu page hook suffix. * * @var string */ public $gallery_screen_id; /** * View POS submenu page hook suffix. * * @var string */ public $view_pos_screen_id; /** * Constructor. */ public function __construct() { if ( current_user_can( 'manage_woocommerce_pos' ) ) { $this->register_pos_admin(); add_filter( 'custom_menu_order', '__return_true' ); add_filter( 'menu_order', array( $this, 'menu_order' ), 9, 1 ); add_filter( 'parent_file', array( $this, 'highlight_templates_menu' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_landing_scripts_and_styles' ) ); add_action( 'admin_footer', array( $this, 'print_upgrade_click_tracking_script' ) ); add_action( 'admin_init', array( $this, 'redirect_template_list_page' ) ); } // add_filter( 'woocommerce_analytics_report_menu_items', array( $this, 'analytics_menu_items' ) );. } /** * Filters the order of administration menu items. * * A truthy value must first be passed to the {@see 'custom_menu_order'} filter * for this filter to work. Use the following to enable custom menu ordering: * * add_filter( 'custom_menu_order', '__return_true' ); * * @param array $menu_order An ordered array of menu items. * * @return array */ public function menu_order( array $menu_order ): array { $woo = array_search( 'woocommerce', $menu_order, true ); $pos = array_search( PLUGIN_NAME, $menu_order, true ); if ( false !== $woo && false !== $pos ) { // rearrange menu. unset( $menu_order[ $pos ] ); array_splice( $menu_order, ++$woo, 0, PLUGIN_NAME ); // rearrange submenu. global $submenu; $pos_submenu = &$submenu[ PLUGIN_NAME ]; $pos_submenu[500] = $pos_submenu[1]; unset( $pos_submenu[1] ); } return $menu_order; } /** * Render the upgrade page. */ public function display_upgrade_page(): void { include_once 'views/upgrade.php'; } /** * Add POS submenu to WooCommerce Analytics menu. * * @param array $report_pages The analytics report pages. */ public function analytics_menu_items( array $report_pages ): array { // Find the position of the 'Orders' item. $position = array_search( 'Orders', array_column( $report_pages, 'title' ), true ); // Use array_splice to add the new item. array_splice( $report_pages, $position + 1, 0, array( array( 'id' => 'woocommerce-analytics-pos', 'title' => /* translators: WordPress admin menu label for WCPOS. */ __( 'POS', 'woocommerce-pos' ), 'parent' => 'woocommerce-analytics', 'path' => '/analytics/pos', 'nav_args' => array( 'order' => 45, 'parent' => 'woocommerce-analytics', ), ), ) ); return $report_pages; } /** * Add POS to Admin sidebar. */ private function register_pos_admin(): void { $this->toplevel_screen_id = add_menu_page( /* translators: WordPress admin menu label for WCPOS. */ __( 'POS', 'woocommerce-pos' ), /* translators: WordPress admin menu label for WCPOS. */ __( 'POS', 'woocommerce-pos' ), 'manage_woocommerce_pos', PLUGIN_NAME, array( $this, 'display_upgrade_page' ), 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjYwIDEyNjAiPgo8cGF0aCBmaWxsPSIjYTdhYWFkIiBkPSJNMTE3MCwwaC05MEg5MDBINzIwSDU0MEgzNjBIMTgwSDkwQzMwLDAsMCwzMCwwLDkwdjE4MGMwLDQ5LjcsNDAuMyw5MCw5MCw5MHM5MC00MC4zLDkwLTkwVjkwaDE4MHYxODAKCWMwLDQ5LjcsNDAuMyw5MCw5MCw5MHM5MC00MC4zLDkwLTkwVjkwaDE4MHYxODBjMCw0OS43LDQwLjMsOTAsOTAsOTBzOTAtNDAuMyw5MC05MFY5MGgxODB2MTgwYzAsNDkuNyw0MC4zLDkwLDkwLDkwczkwLTQwLjMsOTAtOTAKCVY5MEMxMjYwLDMwLDEyMzAsMCwxMTcwLDB6Ii8+CjxwYXRoIGZpbGw9IiNhN2FhYWQiIGQ9Ik0xMDgwLDM2MGMtNDUsNDUtMTM1LDQ1LTE4MCwwYy00NSw0NS0xMzUsNDUtMTgwLDBjLTQ1LDQ1LTEzNSw0NS0xODAsMGMtNDUsNDUtMTM1LDQ1LTE4MCwwYy00NSw0NS0xMzUsNDUtMTgwLDAKCWMtNDUsNDUtMTM1LDQ1LTE4MCwwdjkwMGwzNjAtMjcwaDgxMGM2MCwwLDkwLTMwLDkwLTkwVjM2MEMxMjE1LDQwNSwxMTI1LDQwNSwxMDgwLDM2MHogTTI2MC41LDgyOGMtMzUuNSwwLTY4LjUtMTEuMy05NS41LTMwLjQKCXYxMjUuOWMwLDE5LjMtMTUuNywzNS0zNSwzNXMtMzUtMTUuNy0zNS0zNVY1MzJjMC0xOS4zLDE1LjctMzUsMzUtMzVjMTcuOCwwLDMyLjYsMTMuMywzNC43LDMwLjZjMjcuMS0xOS4zLDYwLjEtMzAuNiw5NS44LTMwLjYKCWM5MS4zLDAsMTY1LjUsNzQuMiwxNjUuNSwxNjUuNVMzNTEuOCw4MjgsMjYwLjUsODI4eiBNNjMwLDgyOGMtOTEuNSwwLTE2Ni03NC41LTE2Ni0xNjZjMC05MS41LDc0LjUtMTY2LDE2Ni0xNjYKCWM5MS41LDAsMTY2LDc0LjUsMTY2LDE2NkM3OTYsNzUzLjUsNzIxLjUsODI4LDYzMCw4Mjh6IE05MTguMyw2MTMuOWMxMS41LDUuOCwzNS4xLDEyLjYsODIuMiwxMi42YzQ5LjUsMCw4Ni42LDYuNSwxMTMuNSwyMAoJYzMzLjUsMTYuOCw1Miw0NS4zLDUyLDgwLjJzLTE4LjUsNjMuNS01Miw4MC4yYy0yNi45LDEzLjUtNjQuMSwyMC0xMTMuNSwyMEM5NDYsODI3LDg5Niw4MTMuNiw4NTIsNzg3LjJjLTE2LjYtOS45LTIyLTMxLjQtMTItNDgKCWM5LjktMTYuNiwzMS40LTIyLDQ4LTEyYzMzLDE5LjgsNzAuOCwyOS44LDExMi41LDI5LjhjNDcuMSwwLDcwLjctNi45LDgyLjItMTIuNmM3LjMtMy42LDEzLjMtNy41LDEzLjMtMTcuNnMtNi0xNC0xMy4zLTE3LjYKCWMtMTEuNS01LjgtMzUuMS0xMi42LTgyLjItMTIuNmMtNDkuNSwwLTg2LjYtNi41LTExMy41LTIwYy0zMy41LTE2LjgtNTItNDUuMy01Mi04MC4yYzAtMzUsMTguNS02My41LDUyLTgwLjIKCWMyNi45LTEzLjUsNjQuMS0yMCwxMTMuNS0yMGM1NC41LDAsMTA0LjUsMTMuNCwxNDguNSwzOS44YzE2LjYsOS45LDIyLDMxLjQsMTIsNDhjLTkuOSwxNi42LTMxLjQsMjEuOS00OCwxMgoJYy0zMy0xOS44LTcwLjgtMjkuOC0xMTIuNS0yOS44Yy00Ny4xLDAtNzAuNyw2LjktODIuMiwxMi42Yy03LjMsMy42LTEzLjMsNy41LTEzLjMsMTcuNlM5MTEsNjEwLjMsOTE4LjMsNjEzLjl6Ii8+CjxjaXJjbGUgZmlsbD0iI2E3YWFhZCIgY3g9IjYzMCIgY3k9IjY2MiIgcj0iOTYiLz4KPGNpcmNsZSBmaWxsPSIjYTdhYWFkIiBjeD0iMjYwLjUiIGN5PSI2NjIuNSIgcj0iOTUuNSIvPgo8L3N2Zz4K' ); $this->view_pos_screen_id = add_submenu_page( PLUGIN_NAME, /* translators: WordPress admin menu label for WCPOS. */ __( 'View POS', 'woocommerce-pos' ), /* translators: WordPress admin menu label for WCPOS. */ __( 'View POS', 'woocommerce-pos' ), 'manage_woocommerce_pos', PLUGIN_NAME . '-view-pos', ); $this->settings_screen_id = add_submenu_page( PLUGIN_NAME, // translators: WordPress admin submenu label that opens WCPOS settings. __( 'Settings', 'woocommerce-pos' ), // translators: WordPress admin submenu label that opens WCPOS settings. __( 'Settings', 'woocommerce-pos' ), 'manage_woocommerce_pos', PLUGIN_NAME . '-settings', array( '\WCPOS\WooCommercePOS\Admin\Settings', 'display_settings_page' ) ); // Template Gallery SPA page. $this->gallery_screen_id = add_submenu_page( PLUGIN_NAME, /* translators: WordPress admin menu label for WCPOS. */ __( 'Templates', 'woocommerce-pos' ), /* translators: WordPress admin menu label for WCPOS. */ __( 'Templates', 'woocommerce-pos' ), 'manage_woocommerce_pos', 'wcpos-templates', array( $this, 'render_gallery_page' ) ); add_action( 'load-' . $this->gallery_screen_id, array( $this, 'enqueue_gallery_assets' ) ); // adjust submenu. global $submenu; $pos_submenu = &$submenu[ PLUGIN_NAME ]; $pos_submenu[0][0] = /* translators: WordPress admin menu label for WCPOS. */ __( 'Upgrade to Pro', 'woocommerce-pos' ); $pos_submenu[0][2] = self::get_upgrade_tracking_url( 'menu_submenu', admin_url( 'admin.php?page=' . PLUGIN_NAME ) ); $pos_submenu[1][2] = woocommerce_pos_url(); // The "Upgrade to Pro" submenu link is persistent navigation rendered // on every wp-admin page, so we deliberately do NOT emit an // `upgrade_cta_viewed` impression here โ€” doing so fired the event on // every admin request and buried the rest of the funnel. Clicks are // still tracked via the tracking URL set on $pos_submenu[0][2] above. /* * Fires after POS admin menus are registered. * * The array arguments, `$this->toplevel_screen_id` and * `$this->settings_screen_id`, refers to the top-level POS menu ID and * settings submenu ID respectively. * * @since 1.0.0 * * @param array $menus { * An array of admin menu IDs. * * @type string $toplevel The top-level POS menu ID. * @type string $settings The settings submenu ID. * } */ do_action( 'woocommerce_pos_register_pos_admin', array( 'toplevel' => $this->toplevel_screen_id, 'settings' => $this->settings_screen_id, ) ); } /** * Enqueue landing page scripts and styles. * * @param string $hook_suffix The current admin page hook suffix. */ public function enqueue_landing_scripts_and_styles( $hook_suffix ): void { if ( $hook_suffix === $this->toplevel_screen_id ) { $analytics = Analytics::instance(); $analytics->capture_once( 'upgrade_cta_viewed', array( 'placement' => 'admin_landing_banner', ), 'admin_landing_banner' ); // Bind this install to its `site` group and refresh the profile that // hangs off it. Sending the properties matters: a bare group() call // creates the group with an empty property set, which is what made // every environment and store-size breakdown unqueryable (#793). ( new Lifecycle_Events() )->maybe_refresh_group_properties(); // The activation funnel's middle step, carrying the feature-adoption // snapshot. One snapshot per view answers "what share of stores // enable X" without an event per settings toggle. $analytics->capture( 'admin_landing_viewed', array( 'settings_summary' => ( new Analytics_Profile() )->get_settings_summary() ) ); $is_development = isset( $_ENV['DEVELOPMENT'] ) && wp_validate_boolean( sanitize_text_field( wp_unslash( $_ENV['DEVELOPMENT'] ) ) ); $url = $is_development ? 'http://localhost:9000/' : 'https://cdn.jsdelivr.net/gh/wcpos/wp-admin-landing@2/assets/'; // Enqueue the landing page CSS from CDN. wp_enqueue_style( 'wcpos-welcome', $url . 'css/welcome.css', array(), PLUGIN_VERSION ); // Ensure WordPress bundled React and lodash are loaded as dependencies. wp_enqueue_script( 'react' ); wp_enqueue_script( 'lodash' ); // Enqueue the landing page JS from CDN, with React and lodash as dependencies. wp_enqueue_script( 'wcpos-welcome', $url . 'js/welcome.js', array( 'react', 'react-dom', 'wp-element', 'lodash', ), PLUGIN_VERSION, true ); // Inject functional data (locale, version, anon_id, experiment // bootstrap flags). The landing bundle owns PostHog initialisation, // flag resolution, and identify ordering (flag-before-identify, spec // ยง5.1); the plugin must NOT init PostHog or identify here โ€” doing so // shares the bundle's localStorage identity, breaks the anon-bucket // exposure, and double-fires CTA tracking. wp_add_inline_script( 'wcpos-welcome', $this->landing_inline_script(), 'before' ); } } /** * Generate the inline script that exposes the analytics client. * * When the user has explicitly allowed tracking, loads the PostHog * async SDK, initializes it with the configured token/host, and * identifies the current user + site. * * When consent has not been granted, exposes a no-op stub at * `window.wcpos.posthog` so that future UI event helpers can call * `.capture()` etc. unconditionally without throwing โ€” and without * any network traffic leaving the browser. */ public static function get_posthog_inline_script(): string { $analytics = Analytics::instance(); $noop_stub = '(function(){var w=window.wcpos=window.wcpos||{};w.posthog={capture:function(){},identify:function(){},group:function(){},register:function(){},reset:function(){},opt_in_capturing:function(){},opt_out_capturing:function(){}};})();'; if ( ! $analytics->is_enabled() ) { return $noop_stub; } $token = wp_json_encode( $analytics->get_token() ); $host = wp_json_encode( $analytics->get_host() ); $site_id = wp_json_encode( $analytics->get_site_id() ); $user_id = wp_json_encode( $analytics->get_distinct_id() ); // If any value fails to encode (e.g. malformed UTF-8 coming // from a filter override), fall back to the no-op stub rather // than emitting `posthog.init(, { api_host: , ... })`. if ( false === $token || false === $host || false === $site_id || false === $user_id ) { return $noop_stub; } // phpcs:disable Generic.Files.LineLength.TooLong -- PostHog snippet is a single line by design. $snippet = << 'wcpos_track_upgrade_click', 'placement' => $placement, 'destination' => $destination, '_wpnonce' => wp_create_nonce( 'wcpos_track_upgrade_click' ), ), admin_url( 'admin-post.php' ) ); } /** * Track an upgrade click and return a safe redirect destination. * * @param string $placement Stable CTA placement identifier. * @param string $destination Final redirect URL. * * @return string */ public static function track_upgrade_click( string $placement, string $destination ): string { $safe_destination = self::sanitize_upgrade_destination( $destination ); Analytics::instance()->capture( 'pro_link_clicked', array( 'placement' => sanitize_key( $placement ), 'destination' => $safe_destination, ) ); return $safe_destination; } /** * Handle admin-post upgrade click tracking and redirect. */ public static function handle_upgrade_click_redirect(): void { check_admin_referer( 'wcpos_track_upgrade_click' ); if ( ! current_user_can( 'manage_woocommerce_pos' ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'woocommerce-pos' ) ); } $placement = isset( $_GET['placement'] ) ? sanitize_text_field( wp_unslash( $_GET['placement'] ) ) : ''; $destination = isset( $_GET['destination'] ) ? sanitize_text_field( wp_unslash( $_GET['destination'] ) ) : ''; $redirect_to = self::track_upgrade_click( $placement, $destination ); wp_safe_redirect( $redirect_to ); exit; } /** * Generate the inline script for landing page data. * * Always emits functional data (locale, version, pro status). * Merges in store profile and updates-server config only when * the user has explicitly allowed tracking. * * @return string */ private function landing_inline_script(): string { $json_flags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT; $profile = new Landing_Profile(); $data = $profile->get_functional_data(); // Send gate: the persisted answer, not the filtered read view. $consent = Settings::instance()->raw_tracking_consent(); if ( 'allowed' === $consent ) { $data = array_merge( $data, $profile->get_consented_data() ); } $encoded = wp_json_encode( $data, $json_flags ); if ( false === $encoded ) { Logger::error( 'Landing data JSON encoding failed: ' . json_last_error_msg() ); $encoded = '{}'; } return \sprintf( 'var wcpos = wcpos || {}; wcpos.landing = %s;', $encoded ); } /** * Print a tiny global click tracker for PHP-rendered admin upsell links. */ public function print_upgrade_click_tracking_script(): void { $nonce = wp_create_nonce( 'wcpos_track_upgrade_click' ); ?> '; } /** * Enqueue the Template Gallery SPA assets. */ public function enqueue_gallery_assets(): void { $is_development = isset( $_ENV['DEVELOPMENT'] ) && wp_validate_boolean( sanitize_text_field( wp_unslash( $_ENV['DEVELOPMENT'] ) ) ); $dir = $is_development ? 'build' : 'assets'; wp_enqueue_style( 'wcpos-template-gallery-styles', PLUGIN_URL . $dir . '/css/template-gallery.css', array(), PLUGIN_VERSION ); wp_enqueue_script( 'wcpos-template-gallery', PLUGIN_URL . $dir . '/js/template-gallery.js', array( 'react', 'react-dom', 'wp-api-fetch', \WCPOS\WooCommercePOS\Admin::API_FETCH_METHOD_PARAM_HANDLE, 'wp-url' ), PLUGIN_VERSION, true ); wp_add_inline_script( 'wcpos-template-gallery', $this->gallery_inline_script(), 'before' ); } /** * Generate the inline script for gallery data. */ private function gallery_inline_script(): string { $json_encode_flags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT; return \sprintf( 'var wcpos = wcpos || {}; wcpos.templateGallery = { isProActive: %s, adminUrl: %s, hasPosOrders: %s, previewBaseUrl: %s }; wcpos.translationVersion = %s;', wp_json_encode( wcpos_is_pro_active(), $json_encode_flags ), wp_json_encode( untrailingslashit( admin_url() ), $json_encode_flags ), wp_json_encode( (bool) wc_get_orders( array( 'limit' => 1, 'return' => 'ids', 'status' => array( 'completed', 'processing', 'on-hold', 'pending' ), 'created_via' => 'woocommerce-pos', ) ), $json_encode_flags ), wp_json_encode( PLUGIN_URL . 'assets/img/template-gallery/previews', $json_encode_flags ), wp_json_encode( TRANSLATION_VERSION, $json_encode_flags ) ); } /** * Redirect the old CPT list page (edit.php?post_type=wcpos_template) to the Gallery SPA. * * Only redirects the list view, not the individual post editor. */ public function redirect_template_list_page(): void { global $pagenow; if ( 'edit.php' === $pagenow && isset( $_GET['post_type'] ) && 'wcpos_template' === $_GET['post_type'] && ! isset( $_GET['post_status'] ) ) { wp_safe_redirect( admin_url( 'admin.php?page=wcpos-templates' ) ); exit; } } /** * Keep the POS menu expanded and Templates submenu highlighted when editing a template. * * @param string $parent_file The parent file. * * @return string */ public function highlight_templates_menu( $parent_file ) { global $current_screen, $submenu_file; if ( isset( $current_screen->post_type ) && 'wcpos_template' === $current_screen->post_type ) { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited $submenu_file = 'wcpos-templates'; $parent_file = PLUGIN_NAME; } return $parent_file; } /** * Sanitize an upgrade redirect destination to trusted hosts only. * * @param string $destination Candidate destination URL. * * @return string */ private static function sanitize_upgrade_destination( string $destination ): string { $fallback = 'https://wcpos.com/pro'; $destination = rawurldecode( $destination ); $parsed_url = wp_parse_url( $destination ); $parsed_admin = wp_parse_url( admin_url() ); $allowed_hosts = array_filter( array( $parsed_admin['host'] ?? '', 'wcpos.com', 'www.wcpos.com', ) ); if ( empty( $parsed_url['host'] ) || ! \in_array( $parsed_url['host'], $allowed_hosts, true ) ) { return $fallback; } return esc_url_raw( $destination ); } }