true, 'page_slug' => self::MENU_PAGE_SLUG, 'can_view' => current_user_can( Capabilities::VIEW_ANALYTICS ), 'timezone' => self::site_timezone(), ); return $data; } /** * Prefers `timezone_string` over `gmt_offset`, matching the dashboard's own `siteTimeZone()`: * analytics links point at past dates, so a fixed offset applied to the far side of a * daylight-saving transition shifts the day. * * @return string An IANA timezone name, or a `+HH:MM` UTC offset. */ private static function site_timezone() { $timezone_string = get_option( 'timezone_string' ); if ( is_string( $timezone_string ) && $timezone_string !== '' ) { return $timezone_string; } return self::format_gmt_offset( (float) get_option( 'gmt_offset' ) ); } /** * Format a GMT offset in hours as `+HH:MM`. * * @param float $offset The offset in hours, e.g. 5.5 or -8. * @return string The formatted offset. */ private static function format_gmt_offset( $offset ) { $sign = $offset < 0 ? '-' : '+'; $absolute = abs( $offset ); $hours = (int) floor( $absolute ); $minutes = (int) round( ( $absolute - $hours ) * 60 ); return sprintf( '%s%02d:%02d', $sign, $hours, $minutes ); } /** * Register the sync services that feed the local data pipeline. * * @return void */ private static function register_sync_bootstrap() { // Keep the shared connection available when another connection-owning plugin is deactivated. Connection_Configuration::configure(); Sync_Status_Tracker::configure(); // Opts in to the shared woocommerce_analytics sync module so Sync_Status_Tracker has a full sync to observe. Sync_Configuration::register(); } /** * Register the site-served REST API: the WPCOM data proxy and notices. * * Both self-gate on their own rest_api_init hooks. * * @return void */ private static function register_local_api() { Api_Proxy_Controller::register(); Notices_Controller::register(); } /** * Load the dashboard components every platform renders with. * * Admin-only, via load_dashboard_surface(); boot_routes() requires these * again for REST. * * @return void */ private static function load_dashboard_components() { /* * Every include below is guarded on a symbol the target file declares. * * Two copies of this package can be loaded in one request — WPCOM Simple ships * one under jetpack-plugin and another under jetpack-mu-wpcom-plugin. The * autoloader dedupes classes by version, but these files declare functions and * constants at file scope, so they are absent from the classmap entirely and * reach us through `require_once`, which dedupes by path and not by symbol. * Once a class from one copy and a class from the other both run their * includes, PHP fatals on the redeclared functions. The guards make the second * copy's include a no-op, which also keeps the files' file-scope side effects * (add_filter() calls, registry bootstrapping) from running twice. */ // Widget modules for the client's dynamic import() map. if ( ! function_exists( __NAMESPACE__ . '\\register_widget_modules_rest_route' ) ) { require_once __DIR__ . '/widget-modules.php'; } // Default layout primitives and the bundled defaults' seed. if ( ! function_exists( __NAMESPACE__ . '\\get_dashboard_default_widget_instance' ) ) { require_once __DIR__ . '/dashboard-layout.php'; } // Dashboard section API, then the package's own sections registered through it. if ( ! function_exists( __NAMESPACE__ . '\\register_dashboard_section' ) ) { require_once __DIR__ . '/dashboard-sections.php'; } if ( ! function_exists( __NAMESPACE__ . '\\register_default_dashboard_sections' ) ) { require_once __DIR__ . '/default-dashboard-sections.php'; } // An older copy of the package may have loaded dashboard-sections.php under the previous name. if ( function_exists( __NAMESPACE__ . '\\configure_dashboard_sections_script_data' ) ) { configure_dashboard_sections_script_data(); } // Default-on CSV export settings and server-side disable filter. if ( ! function_exists( __NAMESPACE__ . '\\configure_csv_exports' ) ) { require_once __DIR__ . '/csv-exports.php'; } configure_csv_exports(); // VideoPress availability for the client's video routes. The widget layer // reads the same signal through widget-type-support.php. if ( ! function_exists( __NAMESPACE__ . '\\configure_videopress_availability' ) ) { require_once __DIR__ . '/videopress-availability.php'; } configure_videopress_availability(); // The composition flag's answer, read by the dashboard policy; the file is // already loaded by boot_shared_services(). configure_dashboard_policy(); } /** * Serve the dashboard support routes from the site. Simple skips this — * WPCOM calls Dashboard_Support_Routes::register() itself instead. * * @return void */ private static function register_dashboard_support_routes() { Dashboard_Support_Routes::register(); } /** * Load the wp-build output (interceptor, modules, routes, page render). * * Admin-only, via load_dashboard_surface(). REST does not need it: * boot_routes() and ensure_widget_registry_ready() load what they use. * * @return void */ private static function load_build() { $build_entry = self::$build_entry ?? __DIR__ . '/../build/build.php'; if ( file_exists( $build_entry ) ) { require_once $build_entry; require_once __DIR__ . '/sdk-module.php'; } } /** * Unhook wp-build's full-page render interceptor — security-relevant: it renders * `?page=jetpack-premium-analytics` from admin_init with no capability check, and only * renders_admin_chrome() gates the admin-post.php/admin-ajax.php paths that reach admin_init * without Core's own slug check. * * Because remove_action() no-ops on a callback name it can't find, a wp-build rename would * silently restore this entry point — hence the _doing_it_wrong() below when that happens. * * @return void */ private static function remove_full_page_interceptor() { if ( remove_action( 'admin_init', 'jpa_jetpack_premium_analytics_intercept_render' ) ) { return; } if ( function_exists( 'jpa_jetpack_premium_analytics_intercept_render' ) ) { _doing_it_wrong( __METHOD__, 'The Premium Analytics full-page interceptor could not be unhooked: wp-build changed the generated callback name or its admin_init priority.', '' ); } } /** * Absolute path to the generated widget manifest. * * On the class, not beside its readers in widget-modules.php: two copies of this package can * load in one request, and only classes get the autoloader's version dedupe (see load_dashboard_components()). * * @return string */ public static function widget_manifest_path() { /** * Filters the path to the generated widget manifest. * * @param string $path Absolute path to the generated widget manifest. */ return apply_filters( 'jetpack_premium_analytics_widgets_manifest_path', __DIR__ . '/../build/widgets.php' ); } /** * Register the admin-only render path: polyfills, menu, and page hooks. * * @return void */ private static function register_admin_page() { // Polyfills force-replace core handles (wp-private-apis) on wp_default_scripts; // scope to the dashboard page so no other admin page (e.g. block editor) is hit. if ( self::is_dashboard_request() ) { WP_Build_Polyfills::register( 'jetpack-premium-analytics', array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS ) ); add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_i18n_loader' ) ); add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_tracks_transport' ) ); add_filter( 'jetpack_admin_js_script_data', array( static::class, 'add_tracks_identity_script_data' ), 20 ); } add_action( 'admin_menu', array( static::class, 'register_admin_menu' ) ); } /** * The admin page slug the dashboard menu registers. Published in script data * so no caller has to hard-code it. */ const MENU_PAGE_SLUG = 'jetpack-premium-analytics-wp-admin'; /** * Whether the current request is rendering the Premium Analytics dashboard. * * Scopes the wp-build polyfill registration (which force-replaces core script handles) to * this dashboard; reads the menu slug directly, not current_screen, to stay safe at plugin-load time. * * @return bool True when serving the dashboard page in wp-admin. */ public static function is_dashboard_request() { if ( ! is_admin() ) { return false; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the menu page slug to scope asset loading; no state is changed. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; return self::MENU_PAGE_SLUG === $page; } /** * Register the admin menu page. * * Uses wp-build's `-wp-admin` variant so Core applies the menu capability check. Reports the * page and widget artifacts independently since the build loader includes each conditionally. * * Queued through Admin_Menu rather than registered here, so the entry is reachable by the * `jetpack_admin_menu_visibility` filter. * * @return void */ public static function register_admin_menu() { $can_render = function_exists( 'jpa_jetpack_premium_analytics_wp_admin_render_page' ); $has_widget_manifest = file_exists( self::widget_manifest_path() ); $missing = array(); if ( ! $can_render ) { // Named by symbol, not by file: build/pages.php is only a loader, and the // callback can also go missing to a renamed page slug or an absent build entry. $missing[] = 'the jpa_jetpack_premium_analytics_wp_admin_render_page() callback, generated under build/pages/'; } if ( ! $has_widget_manifest ) { $missing[] = 'build/widgets.php (the widget manifest)'; } if ( $missing ) { // Surfaced here rather than only on the page itself, so a partial deploy shows up on // the first admin request instead of waiting for someone to open the dashboard. _doing_it_wrong( __METHOD__, // esc_html() only to satisfy WordPress.Security.EscapeOutput, which treats // this argument as output; every entry is a literal from just above. 'The Premium Analytics build output is incomplete: ' . esc_html( implode( ', ', $missing ) ) . '. The package build did not run, or ran only partially, for this deploy.', '' ); } $render_callback = $can_render ? 'jpa_jetpack_premium_analytics_wp_admin_render_page' : array( __CLASS__, 'render_missing_build_notice' ); $menu_title = self::menu_title(); $menu_title = esc_html( $menu_title ); // An older admin-ui, loaded first by another plugin, may predate add_top_level_menu(). if ( ! method_exists( Admin_Menu::class, 'add_top_level_menu' ) ) { add_menu_page( $menu_title, $menu_title, Capabilities::VIEW_ANALYTICS, self::MENU_PAGE_SLUG, $render_callback, 'dashicons-chart-bar', 2 ); return; } // A fixed key rather than the slug, which carries a build-specific suffix. No gate: // the dashboard has no My Jetpack product class and no module to name. Admin_Menu::add_top_level_menu( $menu_title, $menu_title, Capabilities::VIEW_ANALYTICS, self::MENU_PAGE_SLUG, $render_callback, 'dashicons-chart-bar', 2, array( 'key' => 'jetpack-premium-analytics' ) ); } /** * Stand-in for the generated render callback when the build output is absent. * * The PHP classes come from Composer and the build output from pnpm, so a * partial deploy can leave the class loadable with nothing to render. * * @return void */ public static function render_missing_build_notice() { printf( '

%s

%s

', esc_html( self::menu_title() ), esc_html__( 'The Premium Analytics assets are missing. The package build did not run for this deploy.', 'jetpack-premium-analytics-pkg' ) ); } /** * The caller's menu label override, or the package's own translated label. * * Call only once translations can load — admin_menu or later — and memoize so every call site * agrees. Deliberately not is_callable(): PHP function names are case-insensitive, so a plain * label like "Analytics" could match a stray analytics() function and get called. * * @return string */ private static function menu_title() { if ( null !== self::$resolved_menu_title ) { return self::$resolved_menu_title; } $title = self::$menu_title instanceof \Closure ? ( self::$menu_title )() : self::$menu_title; // A positive check rather than a null coalesce: a closure may return an empty string, or // something that isn't a string at all, and either would reach esc_html() as a broken label. self::$resolved_menu_title = is_string( $title ) && '' !== $title ? $title : __( 'Stats v2', 'jetpack-premium-analytics-pkg' ); return self::$resolved_menu_title; } /** * Enqueue the i18n loader so the wp-build init module can download its JS * translation catalogs. It's registered on every admin page by jetpack-assets * but only enqueued when depended on; the esbuild bundles don't pull it in. * * @return void */ public static function enqueue_i18n_loader() { if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) { wp_enqueue_script( 'wp-jp-i18n-loader' ); } } /** * Load the Tracks transport for the dashboard. * * `@automattic/jetpack-analytics` only queues events into `window._tkq` — its own w.js * loader is disabled — so without this handle no `jetpack_premium_analytics_*` event * ever flushes. Simple is skipped because stats.php already prints the same script. * * @return void */ public static function enqueue_tracks_transport() { if ( ( new Host() )->is_wpcom_simple() ) { return; } wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true ); } /** * Publish the WPCOM identity the dashboard attributes its Tracks events to. * * Core's script data carries only the local user. Publicize is the one package that fills * `current_user.wpcom` in, and the standalone plugin does not bundle it, so without this * every event would land anonymous there. * * @param array $data The script data. * @return array The script data with the WPCOM identity added. */ public static function add_tracks_identity_script_data( $data ) { if ( ( new Host() )->is_wpcom_simple() ) { $wpcom_user = array( 'ID' => get_current_user_id(), 'login' => wp_get_current_user()->user_login, ); } else { $connected = ( new Connection_Manager() )->get_connected_user_data(); if ( empty( $connected['ID'] ) || empty( $connected['login'] ) ) { return $data; } // Only the two fields `identifyUser` needs: the rest of the connected-user payload // is profile data the dashboard never reads. $wpcom_user = array( 'ID' => $connected['ID'], 'login' => $connected['login'], ); } $data['user']['current_user']['wpcom'] = array_merge( $data['user']['current_user']['wpcom'] ?? array(), $wpcom_user ); return $data; } }