Stats: show a pricing grid instead of the dashboard for new sites without a plan - #113366
Conversation
|
WordPress.com
Automattic for Agencies
|
|
Looks like one of the E2E tests has failed. You can fix them following these steps:
|
e49d13c to
f1e267d
Compare
d6c4a2e to
c8a4e1b
Compare
c8a4e1b to
25d01d2
Compare
25d01d2 to
ba5e9d2
Compare
…hout a plan In Odyssey, the traffic page controller is wrapped with a gate: a site that first connected on or after 2026-08-07 and holds no Stats plan sees a Free vs Paid choice in place of the dashboard. Picking either plan reveals the dashboard immediately and records a dismissal through the existing stats notices endpoint (new `pricing_grid` id), so the grid stays away on later visits. The id defaults to hidden until the server reports it — the same ship-ahead treatment `free_site_upgrade` gets — so the grid cannot render without a working dismissal round-trip. The grid replicates the Jetpack Search upsell's PricingTable rendering — DOM structure and styles ported from @automattic/jetpack-components, which Calypso does not ship — using @wordpress/components primitives, with colors mapped to the studio palette tokens the jetpack theme is built from. The connection date reads the `created_at` site option: for a Jetpack site that is the wpcom shadow blog's `wp_blogs.registered`, which matches the first-connection moment when registration created the row; a reused pre-existing row keeps its older date, so the check can only withhold the grid from a new connection, never show it to an established site. Eligibility defers to `useStatsPurchases`, so bundled plans (Complete, Growth, Business) count as having Stats. The date check is synchronous against site options, so established sites never wait on the purchase and notice lookups; the grid component itself stays in an async chunk. Calypso is untouched apart from the shared component directory.
ba5e9d2 to
b1f14b2
Compare
While the grid is undismissed it replaces the dashboard outright, so no other dashboard notice should fire alongside it.
The wp-admin shim intercepts anchor clicks inside #wpcom with a jQuery handler registered before React mounts, so an onClick on a link Button never ran and clicking Get Paid Stats never recorded the dismissal. The paid CTA now navigates programmatically after dismissing. The dismissal mutation also never touched the notices query cache, so returning from the purchase page via 'I will do it later' re-rendered the grid from the stale cached visibility. Dismissing now patches the cached notices in place, which the gate re-reads on SPA route changes. Verified end to end in Odyssey on a local Jetpack site: both CTAs POST the dismissal, 'I will do it later' lands on the dashboard, and the choice survives hard reloads.
Conflict suppression runs on the server-reported visibility, not on whether the grid actually displays, and the server reports `pricing_grid` as visible until a dismissal is recorded. Sites that never meet the grid — everything connected before launch, everything holding a plan — would therefore have every other dashboard notice (GDPR consent, purchase-success, the upsells, tier upgrade) permanently suppressed. The grid still trumps every notice, structurally: it replaces the whole dashboard, so StatsNotices never mounts alongside it.
Clicking Get Paid Stats no longer dismisses the grid — merely reaching the purchase page is not a plan choice, so an abandoned checkout brings the visitor back to the grid to choose again. The decisions that dismiss are 'Start for free' on the grid and 'I will do it later' on the purchase page (both the commercial and PWYW flows, keyed off the pricing-grid referrer). Completing a purchase needs no dismissal: holding a plan makes the site ineligible for the grid. The dismissal (mutation + notices-cache patch) moves into a shared useDismissPricingGrid hook so all call sites stay in sync.
…errer Anyone clicking the skip button has seen the full paid pitch and deferred, so the grid shouldn't take over the dashboard afterwards no matter how they reached the purchase page. On sites where the grid never shows the dismissal is a harmless no-op.
Nikschavan
left a comment
There was a problem hiding this comment.
Thank you, This tested well! I added some comments - Feel free to address in follow up PR the ones you agree
| isEligible: isNewConnection && ! hasAnyPlan, | ||
| isNewConnection, | ||
| // The date check needs no fetch, so only newly connected sites ever wait. | ||
| isLoading: isNewConnection && isLoadingPurchases, |
There was a problem hiding this comment.
PURCHASES_SITE_FETCH_FAILED sets hasLoadedSitePurchasesFromServer: true and leaves the purchase list empty, so a failed lookup reads here as isLoading: false with hasAnyPlan: false. A newly connected site that already holds a plan would then get the grid instead of its dashboard. Could this fall back to ineligible when the purchases request errors?
| const showPriceFraction = ! hidePriceFraction || ! fraction.endsWith( '00' ); | ||
| return ( | ||
| <p className="stats-pricing-grid__price"> | ||
| <sup className="stats-pricing-grid__price-symbol">{ symbol }</sup> |
There was a problem hiding this comment.
getCurrencyObject also returns symbolPosition, which PlanPrice reads to decide which side to render on. Locales like de-DE with EUR put the symbol after the amount, so fixing it before the integer will render incorrectly there.
| @media (min-width: 960px) { | ||
| padding-left: var(--padding); | ||
| padding-right: var(--padding); | ||
| white-space: nowrap; |
There was a problem hiding this comment.
nowrap combined with overflow: hidden means a locale whose sentence is wider than the container loses the end of it instead of wrapping. This is the Terms of Service and data-sync disclosure, and DE and PT both run longer than EN here.
| // page's "I will do it later" records the dismissal instead. Navigate | ||
| // programmatically rather than via href so the link also works under Odyssey's | ||
| // hashbang routing when the wp-admin click shim doesn't apply (e.g. middle-click). | ||
| const goToPurchase = () => { |
There was a problem hiding this comment.
There's no Tracks event for the grid being viewed or for either CTA — the referrer only travels in the from param. The other Stats upsells record a view and a click, and without them there's no way to measure how many new sites pick free versus paid.
| { createInterpolateElement( | ||
| String( | ||
| translate( | ||
| 'By clicking <strong>%(paid)s</strong> or <strong>%(free)s</strong>, you agree to our <tosLink>Terms of Service</tosLink> and to <shareDetailsLink>sync your site‘s data</shareDetailsLink> with us.', |
There was a problem hiding this comment.
site‘s uses U+2018, the opening single quote, where the apostrophe U+2019 belongs. Worth correcting before this goes for translation.
| ? feature.name | ||
| : translate( '%s not included', { args: [ feature.name ], comment: 'Feature name' } ) | ||
| ); | ||
| const text = label ?? defaultLabel; |
There was a problem hiding this comment.
Below the large breakpoint the feature-name column isn't rendered and defaultLabel switches to the feature name to compensate, but label is set unconditionally, so the four paid rows still read "Included" with nothing naming the feature. Same as the other pricing tables in the plugin so not new here — though the testing instructions describe the labels switching, which doesn't happen for those rows.
There was a problem hiding this comment.
| const freeLabel = String( translate( 'Start for free' ) ); | ||
|
|
||
| // Starting for free is a plan choice: record the dismissal and reveal the dashboard. | ||
| const startForFree = () => { |
There was a problem hiding this comment.
Starting for free records the dismissal but doesn't claim jetpack_stats_free_yearly, so hasAnyPlan stays false and the site remains eligible — only the notice record keeps the grid away. Jetpack Search Free and the type: 'free' path in stats-purchase-checkout-redirect both run it through a zero-cost checkout. Was leaving it unclaimed deliberate?
There was a problem hiding this comment.
Deliberate, per the STATS-366 direction mirrored from Automattic/jetpack#51103: "Start for free" goes straight to the dashboard with no checkout for the free product, and the pricing_grid notice record is the mechanism that keeps the grid away. Running it through the zero-cost checkout would add a bounce to the free path; if claiming jetpack_stats_free_yearly becomes desirable later, the type: 'free' path in stats-purchase-checkout-redirect is the ready-made hook.
| recordDismissal(); | ||
| queryClient.setQueryData( | ||
| [ 'stats', 'notices-visibility', 'raw', siteId ], | ||
| ( notices: Notices | undefined ) => notices && { ...notices, pricing_grid: false } |
There was a problem hiding this comment.
When the purchase page was reached without first rendering the pricing-grid gate, this raw notices query may not exist in the cache. In that case the updater returns undefined, so setQueryData does not create a local pricing_grid: false value. Both “I will do it later” handlers then navigate after a fixed 250 ms without awaiting the dismissal mutation; on a slow request, the newly mounted gate can issue its GET before the POST finishes and show the grid again. Could we either seed a normalized notices value when notices is undefined, or expose/await mutateAsync before navigating?
There was a problem hiding this comment.
Valid on both counts — fixed in 57d1978 on #113372. The dismiss hook now returns the mutation promise (mutateAsync), and both skip handlers await it before navigating (still navigating if the request fails), so the gate's GET on the destination route can never read the pre-dismissal state. That closes the empty-cache case and also a variant this comment didn't cover: with the cache present but stale, the gate's background refetch could overwrite the patch with the pre-POST server value.
Went with awaiting rather than seeding: the raw notices entry is the full object shared by every notices consumer, so fabricating a normalized value on miss would feed StatsNotices made-up server state (e.g. the default tier_upgrade: true) as fresh data. The grid's own "Start for free" stays unawaited since its reveal is same-route (gate local state + the cache patch, which is guaranteed present there).
- Eligibility fails closed when the purchases fetch errors: FETCH_FAILED marks the store loaded with an empty list, which read as 'no plan' and would have shown the grid to a site that holds one. An error now means 'plan state unknown' and falls back to the dashboard. - The price respects getCurrencyObject's symbolPosition, so locales that put the currency symbol after the amount (e.g. de-DE EUR) render correctly. - The Terms of Service line wraps instead of truncating: dropped the jetpack component's nowrap/overflow-hidden, which cut off the disclosure in locales that run longer than English. - Fixed the apostrophe in 'sync your site's data' (was U+2018, the opening quote) before the string goes out for translation. - Dropped the redundant 'Included' labels on the three boolean differentiator rows so the mobile fallback names the feature instead of showing a bare 'Included' with nothing identifying the row. Desktop rendering is unchanged (the default label is already 'Included', bolded via strong).
|
This Pull Request is now available for translation here: https://translate.wordpress.com/deliverables/34683189 Some locales (Hebrew, Japanese) have been temporarily machine-translated due to translator availability. All other translations are usually ready within a few days. Untranslated and machine-translated strings will be sent for translation next Monday and are expected to be completed by the following Friday. Thank you @kangzj for including a screenshot in the description! This is really helpful for our translators. |
- Eligibility fails closed when the purchases fetch errors: FETCH_FAILED marks the store loaded with an empty list, which read as 'no plan' and would have shown the grid to a site that holds one. An error now means 'plan state unknown' and falls back to the dashboard. - The price respects getCurrencyObject's symbolPosition, so locales that put the currency symbol after the amount (e.g. de-DE EUR) render correctly. - The Terms of Service line wraps instead of truncating: dropped the jetpack component's nowrap/overflow-hidden, which cut off the disclosure in locales that run longer than English. - Fixed the apostrophe in 'sync your site's data' (was U+2018, the opening quote) before the string goes out for translation. - Dropped the redundant 'Included' labels on the three boolean differentiator rows so the mobile fallback names the feature instead of showing a bare 'Included' with nothing identifying the row. Desktop rendering is unchanged (the default label is already 'Included', bolded via strong).
* Stats pricing grid: add view and CTA analytics events stats_pricing_grid_view fires on mount, stats_pricing_grid_paid_cta_clicked and stats_pricing_grid_free_cta_clicked on the two CTAs — all through trackStatsAnalyticsEvent (which prefixes jetpack_odyssey_/calypso_ by surface) and all carrying blog_id, following the pattern from #113364. The purchase page's skip button already has its own event. * Stats pricing grid: address review feedback from #113366 - Eligibility fails closed when the purchases fetch errors: FETCH_FAILED marks the store loaded with an empty list, which read as 'no plan' and would have shown the grid to a site that holds one. An error now means 'plan state unknown' and falls back to the dashboard. - The price respects getCurrencyObject's symbolPosition, so locales that put the currency symbol after the amount (e.g. de-DE EUR) render correctly. - The Terms of Service line wraps instead of truncating: dropped the jetpack component's nowrap/overflow-hidden, which cut off the disclosure in locales that run longer than English. - Fixed the apostrophe in 'sync your site's data' (was U+2018, the opening quote) before the string goes out for translation. - Dropped the redundant 'Included' labels on the three boolean differentiator rows so the mobile fallback names the feature instead of showing a bare 'Included' with nothing identifying the row. Desktop rendering is unchanged (the default label is already 'Included', bolded via strong). * Stats pricing grid: await the dismissal before navigating from the purchase page The dismissal was fire-and-forget with a fixed 250 ms before navigating, so on a slow request the gate mounted on the destination route could issue its notices GET before the POST landed and re-render the grid after 'I will do it later'. The cache patch couldn't cover this either: the raw notices entry is absent when the purchase page was reached directly (nothing on it queries notices) or when the dwell outlived the cache's gcTime, and setQueryData with an updater returning undefined is a no-op — while seeding a fabricated notices object would feed the other notice consumers made-up server state. The dismiss hook now returns the mutation promise (mutateAsync); both skip handlers await it before navigating, still navigating if the request fails. The grid's free CTA stays same-route and unawaited, just absorbing the rejection. * Stats pricing grid: cap the awaited dismissal so the skip button never stalls The dismissal mutation retries once after 3s, so awaiting it unbounded could leave 'I will do it later' visually dead for many seconds on a flaky network. Race it against a 2s cap: the healthy path (a fast POST) stays fully ordered before navigation, while past the cap the request continues in flight and navigation proceeds. * Stats: self-correct notice dismissals with query invalidation on success The mutation retries once after 3s, so any capped await on the dismissal was arithmetically guaranteed to lose to a failed first POST, leaving the stale pricing_grid state cached for up to 30s. Invalidating the notices query from the mutation (mutation-level, since per-call callbacks are skipped once the caller unmounts on navigation) self-corrects whenever the POST lands, for every notice using this mutation. That makes the awaited navigation redundant, so the skip handlers return to the plain dismiss-and-navigate shape and the rejection handling moves into the dismiss hook. * Stats purchase: carry the referrer on the skip events The dismissal now fires regardless of referrer, so without `from` a skip that came from a module upsell is indistinguishable from one off the pricing grid. * Stats pricing grid: cover the eligibility hook with tests Also widen the getPurchasesError comment: the field is shared across all purchases actions, not just the site fetch — fail-closed either way. * Stats pricing grid: record which CTA was clicked
Part of STATS-366 (Calypso/Odyssey side of the pricing grid; complements Automattic/jetpack#51103).
Proposed Changes
withPricingGridGate): a site that first connected on or after 2026-08-07 and holds no Stats plan sees a Free vs Paid comparison in place of the dashboard. There is no separate pricing route or URL — the grid takes over the default landing until a plan is chosen.@automattic/jetpack-components(PricingTable, ProductPrice, Button metrics, AdminSectionHero, Container), which Calypso does not ship — using@wordpress/componentsprimitives (Button,ExternalLink,Icon,useViewportMatch). Colors map to the studio palette tokens the jetpack theme is generated from (--studio-jetpack-green-40/50,--studio-red-50,--studio-gray-5/40); the two jetpack values with no studio equivalent are local custom properties. The samelargeviewport switch drives the desktop grid vs stacked mobile cards, including the Included/Not included ↔ feature-name label swap.pricing_gridid, with the cached notices patched in place so SPA route changes see the choice — via a shareduseDismissPricingGridhook):hrefso the link works under Odyssey's hashbang routing.)pricing_gridis deliberately kept out of the notices conflict group: suppression there keys off server-reported visibility, which would silence every other notice on all the sites that never see the grid.)/stats/purchase/:site?from=jetpack-stats-pricing-grid.pricing_gridid is reported by the wpcom notices endpoint (true until a dismissal is in effect); the client default stays hidden so a failed notices request cannot render the grid without a working dismissal round-trip.created_atsite option — verified against the wpcom codebase to be the shadow blog'swp_blogs.registered, which equals the first-connection moment when registration created the row. A reused pre-existing row keeps its older date and reconnects never update it, so the check can only withhold the grid from a genuinely new connection — never show it to an established site. (No dedicated connection-date field exists in the sites payload; the genuine timestamp lives only injetpack_tokens_mu.issuedand the audit log, neither exposed.)useStatsPurchaseshook, so bundled plans (Complete, Growth, Business) count as having Stats. Pricing comes from the Redux products list; the layout renders with the price block omitted if the product hasn't loaded.client/my-sites/stats/pricing-grid/is added.Why are these changes being made?
New Jetpack installations without a Stats plan have no in-product moment to choose between Free and Paid Stats. Automattic/jetpack#51103 adds that moment pre-connection in wp-admin; this PR adds the equivalent surface inside Odyssey for connected sites: the dashboard itself is replaced by the plan choice until one is picked, the shared notices endpoint means a choice made in either surface sticks, and reusing the Search upsell's exact pricing-table design keeps the two Jetpack upsell surfaces visually consistent.
Testing Instructions
✅ Verified end to end in Odyssey against a local Jetpack site (under the prior revision, where the paid CTA recorded the dismissal at click time): grid replaces the dashboard for an eligible site; the dismissal POST lands (200); I will do it later returns to the dashboard; Start for free reveals the dashboard in place; choices survive hard reloads (server-side persistence via the shipped
pricing_gridnotice support). The final revision moves the paid-path dismissal from the CTA click to the purchase page's I will do it later (same shared hook, same endpoints) — worth one more manual pass over that path.The wpcom notices endpoint (
/sites/:site/jetpack-stats-dashboard/notices) now supports thepricing_gridid, so the full flow — including dismissal persistence across reloads — is testable end to end. The client default for the id stays hidden, so a failed notices request falls through to the dashboard rather than rendering the grid without a working dismissal round-trip.cd apps/odyssey-stats && yarn dev) against a Jetpack site connected after 2026-08-07 with no Stats plan.admin.php?page=jetpack-search: title column with bold feature rows, primary column with green border, gradient header and shadow, 48px price with sup currency symbol and fraction, 'per month, from 10k monthly views, billed yearly' legend, full-width CTAs, and the Terms of Service line beneath.from=jetpack-stats-pricing-grid.cd apps/odyssey-stats && yarn test:size— the grid must stay in its async chunk.Pre-merge Checklist