Skip to content

Stats admin: Add pricing grid for new installations without a plan - #51103

Draft
kangzj wants to merge 14 commits into
trunkfrom
jetpack/stats-366-pricing-grid
Draft

Stats admin: Add pricing grid for new installations without a plan#51103
kangzj wants to merge 14 commits into
trunkfrom
jetpack/stats-366-pricing-grid

Conversation

@kangzj

@kangzj kangzj commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes https://linear.app/a8c/issue/STATS-366/rework-the-stats-pricing-grid-jetpackcomstats-and-purchase-flow

Why this matters

New Jetpack installations without a Stats plan had no upgrade path in wp-admin — and before this PR, an unconnected site had no Stats menu at all (the stats module only loads once the site is connected). New users now land on a clear Free vs Paid choice the moment they install, before connecting.

Proposed changes

Menu registration before connection

  • Register the Stats dashboard from the stats-admin package's Main (initialized on every admin request via Config) for unconnected eligible sites, so the Stats menu exists before connecting. Connected sites go exclusively through the stats module's own menu decision, and noheader chart-image requests (adminbar sparkline, dashboard widget) always keep the legacy raw-image path.
  • WordPress.com Simple/WoA sites are never eligible — Stats ships with their plans.
  • Keep the menu capability at view_stats — the stats package maps it for administrators on every admin request, so it works pre-connection and non-admin stats users keep their access.
  • Skip the old stats interface when the pricing grid should show for connected sites that have Odyssey disabled.

Eligibility (class-eligibility.php)

  • New site = not connected, OR first connected on/after the launch date (2026-08-07). The first successful registration time is recorded via the jetpack_site_registered action (jetpack_stats_first_connected_at option); reconnects keep the original value, and sites already connected before the feature shipped have no recorded time, so neither qualifies. (Connection date, not the assumed site creation date — an old WordPress install connecting Jetpack today is a new Stats installation.)
  • Has no Stats plan = no plan with the stats-paid feature and no Stats product.
  • A jetpack_stats_pricing_grid_show filter can force the grid on for testing/demos.
  • Covered by unit tests (Eligibility_Test).

Pricing grid (Search-style)

  • Reuses the same PricingTable layout as the Jetpack Search upsell page: paid column leads with the real differentiators (UTM tracking, device stats, region & city locations, priority support), the view tier sits next to the price ("per month, from 10k monthly views, billed yearly"), shared features are compared per row, and there is no classifier language.
  • Localized pricing is fetched server-side from the public WordPress.com products endpoint (works unconnected, transient-cached 12h), so visitors see their real currency instead of a hardcoded USD price.
  • "Start for free" goes straight to the Stats dashboard (admin.php?page=stats&view=traffic) — no checkout for the free product.
  • "Get Paid Stats" stays in wp-admin: it links to the Odyssey purchase route admin.php?page=stats&view=purchase#!/stats/purchase/{blogId} (with from=jetpack-stats-pricing-grid and a redirect_uri back to the Stats page). Sites that never connected (no blog ID) fall back to the Calypso purchase page.
  • The view query flag exists because PHP can't see the hash fragment: it makes the dashboard render Odyssey instead of the pricing grid at those URLs.

Dismissal

  • Landing on either Odyssey view (i.e. picking a plan) marks the grid dismissed, so it doesn't reappear — including after Odyssey's "I will do it later" button on the purchase page.
  • Dismissal is recorded via the wpcom stats notices endpoint (pricing_grid_dismissed — needs the matching wpcom-side change) for connected sites, with a local option (jetpack_stats_pricing_grid_dismissed) covering unconnected sites, where that endpoint is unreachable.
  • The Stats menu registration is keyed off site eligibility alone, so the menu survives dismissal.

Screenshots

Before — unconnected site, admin.php?page=stats:

Before: access denied

After — same unconnected site:

Pricing grid on an unconnected site

Feature rows and terms of service

"Start for free" — straight to the Stats dashboard (view=traffic), grid dismissed:

Free CTA lands on the dashboard

"Get Paid Stats" — the in-admin Odyssey purchase page at admin.php?page=stats&view=purchase#!/stats/purchase/{blogId}:

Odyssey purchase page in wp-admin

Tier picker

After dismissal — plain admin.php?page=stats renders the dashboard, not the grid:

Dashboard after dismissal

Related product discussion/links

Does this pull request change what data or activity we track or use?

No tracking changes. The paid CTA carries the from=jetpack-stats-pricing-grid referrer Calypso already records; the free CTA performs no checkout at all.

Testing instructions

Setup

  • jp build packages/stats-admin plugins/jetpack
  • Use a Jetpack site without a Stats plan. To test pre-connection, disconnect the site or use a fresh install.
  • To force the grid regardless of eligibility/dismissal (handy for testing): add_filter( 'jetpack_stats_pricing_grid_show', '__return_true' );

Test 1: Unconnected site → pricing grid

  1. With the site disconnected, confirm the Stats menu item appears in wp-admin.
  2. Open admin.php?page=stats — the Search-style pricing table renders: paid column highlighted with localized monthly price, free column at 0.
  3. Click Start for free → the Stats dashboard loads (view=traffic); returning to admin.php?page=stats keeps showing the dashboard — the grid is dismissed.
  4. Reset with wp option delete jetpack_stats_pricing_grid_dismissed, then click Get Paid Stats → the Odyssey purchase page loads in wp-admin with the tier slider; after "I will do it later" the grid likewise stays dismissed.

Test 2: Site connected before launch → Odyssey dashboard

  1. Use a site that was already connected before this feature shipped (no jetpack_stats_first_connected_at option).
  2. Open Stats — the full Odyssey dashboard renders, not the pricing grid.
  3. Conversely, connecting a site after launch records the option and keeps the grid until a plan is chosen.

Test 3: Site with a Stats plan → Odyssey dashboard

  1. On a site holding any Stats product, open Stats — the Odyssey dashboard renders regardless of creation date.

Regression checks

  • The adminbar 48-hour sparkline and the Stats dashboard widget chart still render on connected sites (noheader requests keep the legacy raw-image path).
  • Non-admin roles granted stats access still see the Stats menu on connected sites (view_stats capability).
  • jp test php packages/stats-admin — 49 tests pass (Eligibility_Test, Dashboard_Test).

- Add PHP eligibility check for new sites (not connected OR created after launch date)
- Build pricing grid React component with free/paid CTAs
- Free CTA: Checkout for free product, returns to dashboard
- Paid CTA: Links to Calypso purchase page for tier selection
- Support unlinked site flows (auto-connect-after-checkout)
- Add webpack build scaffolding for stats-admin pricing grid
- Add changelog entry for feature

Fixes: STATS-366
@kangzj kangzj added Enhancement Changes to an existing feature — removing, adding, or changing parts of it [Status] Needs Review This PR is ready for review. labels Aug 6, 2026
@kangzj kangzj self-assigned this Aug 6, 2026
… control

- Allows pricing grid to be accessible to more admin users
- Properly gates the Odyssey dashboard behind connection checks
- Supports showing pricing grid on new installations

This ensures the pricing grid is accessible and can be shown based on the eligibility check (new site + no plan), while the full dashboard remains gated behind connection status.
@kangzj
kangzj marked this pull request as draft August 6, 2026 23:04
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!


Jetpack plugin:

No scheduled milestone found for this plugin.

If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack.


Mu Wpcom plugin:

  • Next scheduled release: WordPress.com Simple releases happen semi-continuously (PCYsg-Jjm-p2)

If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack.


Wpcomsh plugin:

  • Next scheduled release: Atomic deploys happen twice daily on weekdays (p9o2xV-2EN-p2)

If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack or WordPress.com Site Helper), and enable the jetpack/stats-366-pricing-grid branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack jetpack/stats-366-pricing-grid
bin/jetpack-downloader test jetpack-mu-wpcom-plugin jetpack/stats-366-pricing-grid

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@jp-launch-control

jp-launch-control Bot commented Aug 6, 2026

Copy link
Copy Markdown

Code Coverage Summary

Cannot generate coverage summary while tests are failing. 🤐

Please fix the tests, or re-run the Code coverage job if it was something being flaky.

Full summary · PHP report · JS report

kangzj added 3 commits August 7, 2026 11:42
The stats module only loads once the site is connected, so new
installations never got the Stats menu and the pricing grid was
unreachable. Register the dashboard from the stats-admin package's Main
(which runs on every admin request) when the pricing grid is eligible,
and keep the module path for connected sites.

Also:
- Revert the menu capability to view_stats: the stats package maps it
  for administrators on every admin request, so manage_options was both
  unnecessary pre-connection and a regression for non-admin stats users.
- Fix the eligibility check referencing a nonexistent
  Automattic\Jetpack\Plans\Current_Plan class (the real class is
  Automattic\Jetpack\Current_Plan), which fataled every admin request.
- Skip the old stats interface when the pricing grid should show for
  connected sites that have Odyssey disabled.
Reuse the same PricingTable layout as the Jetpack Search upsell page
instead of a bespoke card grid, with copy following the STATS-366 grid
spec: the paid column leads with its real differentiators (UTM
tracking, device stats, region and city locations, priority support),
the view tier sits next to the price, and shared features are compared
per row.

Also:
- Fetch localized pricing for the paid product from the public
  WordPress.com products endpoint server-side (transient-cached) so the
  grid shows the viewer's real currency instead of a hardcoded USD
  price.
- Fix the script registration path (it resolved a directory too high,
  404ing the bundle and skipping its asset dependencies) and inject the
  connection initial state via the connection package instead of a
  nonexistent plugin file.
Cover the four eligibility scenarios: unconnected site, connected site
created before/after the launch date, and sites holding a Stats product
or a plan with the stats-paid feature.

Seed the assumed-site-creation-date transient in the base test case so
the connection manager doesn't run a user query WorDBless can't
satisfy, and ignore webpack build output.
@kangzj
kangzj marked this pull request as ready for review August 6, 2026 23:45
@github-actions github-actions Bot added [Plugin] Jetpack Issues about the Jetpack plugin. https://wordpress.org/plugins/jetpack/ [Status] In Progress [Tests] Includes Tests labels Aug 6, 2026
kangzj added 3 commits August 7, 2026 11:55
Point Get Paid Stats at admin.php?page=stats#!/stats/purchase/{blogId}
whenever the site has a blog ID, instead of leaving wp-admin for
Calypso. The URL carries a view=purchase query flag so the dashboard
renders Odyssey there rather than the pricing grid (PHP can't see the
hash fragment). Sites that never connected fall back to Calypso.
Build cache accidentally committed with the initial draft; .cache/ is
now gitignored.
Both CTAs now land on Odyssey views flagged with a view query param
(traffic for free, purchase for paid); loading either view marks the
grid dismissed so it doesn't reappear — including after Odyssey's 'I
will do it later'. Dismissal is recorded in the wpcom stats notices
(pricing_grid_dismissed) for connected sites, with a local option
covering unconnected sites where that endpoint is unreachable.

'Start for free' no longer runs a checkout for the free product — it
goes straight to the Stats dashboard, so the checkout workflow hook,
the connection initial state, and the unused initial-state fields are
removed. Also reword the paid price legend to 'from 10k monthly views'.
@kangzj

kangzj commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Code review

Found 1 issue:

  1. The noheader legacy carve-out is lost for pricing-grid-eligible connected sites, breaking the admin-bar 48-hour sparkline and the dashboard-widget chart. stats_admin_menu() now requires ! $should_show_pricing_grid before taking the legacy branch, so an admin.php?page=stats&noheader&chart=admin-bar-hours-scale request (emitted as an <img> src on every wp-admin page for view_stats users, and by the Stats dashboard widget) routes to Stats_Dashboard::render() HTML instead of stats_reports_page()'s raw-image passthrough. Main::__construct() registering the top-level stats page for eligible connected sites compounds this by claiming WP's page routing before stats_admin_menu() decides. This hits every connected site the grid targets (created on/after the launch date, no Stats plan), since the stats module auto-activates and admin_bar defaults to true.

$should_show_pricing_grid = Stats_Pricing_Grid_Eligibility::should_show_pricing_grid();
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ( ! Stats_Options::get_option( 'enable_odyssey_stats' ) || isset( $_GET['noheader'] ) ) && ! $should_show_pricing_grid ) {
// Show old Jetpack Stats interface for:
// - When the "enable_odyssey_stats" option is disabled.
// - When being shown in the adminbar outside of wp-admin.
// - But not when the pricing grid should be shown (new installations without a plan).
$hook = Admin_Menu::add_menu( __( 'Stats', 'jetpack' ), __( 'Stats', 'jetpack' ), 'view_stats', 'stats', 'jetpack_admin_ui_stats_report_page_wrapper' );
add_action( "load-$hook", 'stats_reports_load' );
} else {
// Enable the new Odyssey Stats experience or pricing grid.
Stats_Dashboard::init();
}

// The stats module normally registers the Stats menu, but modules only
// load once the site is connected. Register the dashboard here too so
// eligible new sites see the pricing grid before connecting.
if ( is_admin() && Pricing_Grid\Eligibility::is_eligible_site() ) {
Dashboard::init();
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Address code-review feedback:
- noheader requests (the adminbar sparkline and dashboard-widget chart
  images) always take the legacy raw-image path again; the pricing grid
  only overrides the Odyssey-disabled case for normal page views.
- Only register the dashboard from the package for unconnected sites,
  restoring mutual exclusivity with the stats module's own menu
  decision on connected sites.
- Never treat WordPress.com Simple/WoA sites as eligible: Stats ships
  with their plans, and connection-based eligibility doesn't apply.
- Fix the get_paid_pricing() return docblock.
@kangzj

kangzj commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 84a089a:

  • noheader requests now always take the legacy raw-image path in stats_admin_menu(); the pricing grid only overrides the Odyssey-disabled case for normal page views.
  • Main::__construct() now registers the dashboard only for unconnected sites, so connected sites go exclusively through the stats module's own menu decision (restoring mutual exclusivity on the stats slug).
  • Bonus from the same review pass: WPCOM Simple/WoA sites are never eligible for the grid, and the stale get_paid_pricing() docblock is fixed.

kangzj added 4 commits August 7, 2026 12:54
The assumed site creation date was the wrong signal: an old WordPress
install that connects Jetpack after launch is a new Stats installation,
while its earliest admin/post predates the launch. Record the first
successful registration time (jetpack_site_registered) in an option and
treat connected sites as new only when that recorded time is on/after
the launch date. Reconnects keep the original value, and sites already
connected before the feature shipped have no recorded time, so neither
qualifies.
The draft's pnpm-lock.yaml was regenerated through a proxying registry,
which rewrote every resolution entry and stripped dependency snapshots
(postcss lost picocolors, crashing ESLint, Stylelint, and Storybook in
CI). Regenerate it from trunk against the default registry — the diff
is now just the stats-admin importer.

Also drop dependencies the reworked pricing grid no longer uses
(connection/checkout, wp-components, testing-library, TypeScript
preset) and align sass-loader with the version used monorepo-wide.
Without the production-include attribute the gitignored build/ directory
would be missing from the mirrored release package.
The stats-admin composer.json gained real build scripts; the jetpack,
mu-wpcom-plugin, and wpcomsh lock files embed that package metadata and
were flagged stale by CI.
@github-actions github-actions Bot added [Plugin] mu wpcom jetpack-mu-wpcom plugin [Plugin] Wpcomsh labels Aug 7, 2026
@kangzj

kangzj commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

The wp-admin pricing grid implementation has been removed in e1d2be3 — the grid UI now lives in Odyssey (Automattic/wp-calypso#113366, route #!/stats/pricing/:site, with dismissal via the stats notices endpoint and localized pricing from the public products API).

This PR is now PHP-only and keeps the part Odyssey depends on: the Stats dashboard registers as the Stats menu for eligible new sites before the site is connected (first-connection-date eligibility recorded on jetpack_site_registered), and modules/stats.php routes eligible sites to Odyssey. PR description updated accordingly.

@kangzj kangzj added [Type] Janitorial and removed Enhancement Changes to an existing feature — removing, adding, or changing parts of it labels Aug 7, 2026
@kangzj

kangzj commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Per discussion, pre-connection support has been dropped in a7161d8 — the Stats menu registers only for connected sites, exactly as on trunk. The pricing grid ships entirely via Automattic/wp-calypso#113366, reachable through the existing Stats menu on connected sites. This PR is now a small janitorial change: the Odyssey-vs-legacy registration decision is consolidated in Stats_Admin\Main::maybe_register_dashboard(), with modules/stats.php falling back to the legacy interface when it declines.

@kangzj
kangzj force-pushed the jetpack/stats-366-pricing-grid branch from a7161d8 to 4c47c40 Compare August 7, 2026 03:26
@kangzj kangzj added Enhancement Changes to an existing feature — removing, adding, or changing parts of it and removed [Type] Janitorial labels Aug 7, 2026
@kangzj

kangzj commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Reverted to 4c47c40 per discussion: pre-connection support is back — Main::maybe_register_dashboard() registers the Stats dashboard for unconnected sites so Odyssey (and its pricing grid, Automattic/wp-calypso#113366) is reachable before connecting, and owns the connected-site decision (module active + Odyssey enabled + not a noheader chart image) with modules/stats.php falling back to the legacy interface. The follow-up commit that dropped pre-connection support has been removed from the branch.

@kangzj
kangzj force-pushed the jetpack/stats-366-pricing-grid branch from 4c47c40 to 66a3451 Compare August 7, 2026 03:28
add_filter( 'jetpack_stats_pricing_grid_show', '__return_true' ) shows
the grid regardless of eligibility or dismissal — useful for testing
and demos.
@kangzj

kangzj commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Branch restored to the full wp-admin pricing grid implementation (reverted to 66a3451, i.e. the state before the frontend removal), plus 02ef652 adding a jetpack_stats_pricing_grid_show filter to force the grid for testing/demos. PR description restored to match.

@kangzj
kangzj marked this pull request as draft August 7, 2026 05:01
@kangzj kangzj added the DO NOT MERGE don't merge it! label Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

DO NOT MERGE don't merge it! Enhancement Changes to an existing feature — removing, adding, or changing parts of it [Package] Stats Admin [Plugin] Jetpack Issues about the Jetpack plugin. https://wordpress.org/plugins/jetpack/ [Plugin] mu wpcom jetpack-mu-wpcom plugin [Plugin] Wpcomsh [Status] In Progress [Status] Needs Review This PR is ready for review. [Tests] Includes Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant