From b1f14b206dd22b196da7adbf2f864de644f5d93f Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Fri, 7 Aug 2026 14:01:41 +1200 Subject: [PATCH 1/6] Stats: show a pricing grid instead of the dashboard for new sites without a plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/odyssey-stats/src/routes.ts | 7 +- .../hooks/use-notice-visibility-query.ts | 4 + client/my-sites/stats/pricing-grid/gate.tsx | 80 +++++ .../my-sites/stats/pricing-grid/gradient.svg | 17 + .../pricing-grid/hooks/use-eligibility.ts | 40 +++ .../stats/pricing-grid/pricing-grid.tsx | 317 ++++++++++++++++++ client/my-sites/stats/pricing-grid/style.scss | 295 ++++++++++++++++ 7 files changed, 758 insertions(+), 2 deletions(-) create mode 100644 client/my-sites/stats/pricing-grid/gate.tsx create mode 100644 client/my-sites/stats/pricing-grid/gradient.svg create mode 100644 client/my-sites/stats/pricing-grid/hooks/use-eligibility.ts create mode 100644 client/my-sites/stats/pricing-grid/pricing-grid.tsx create mode 100644 client/my-sites/stats/pricing-grid/style.scss diff --git a/apps/odyssey-stats/src/routes.ts b/apps/odyssey-stats/src/routes.ts index 39f534c13f14..8890eeb576e0 100644 --- a/apps/odyssey-stats/src/routes.ts +++ b/apps/odyssey-stats/src/routes.ts @@ -15,6 +15,7 @@ import { emailSummary, redirectToDaySummary, } from 'calypso/my-sites/stats/controller'; +import { withPricingGridGate } from 'calypso/my-sites/stats/pricing-grid/gate'; import config from './lib/config-api'; import { makeLayout, render as clientRender } from './page-middleware/layout'; import 'calypso/my-sites/stats/style.scss'; @@ -61,8 +62,10 @@ export default function ( pageBase = '/' ) { statsPage( '/stats/subscribers/:site', subscribers ); statsPage( `/stats/subscribers/:period(${ validPeriods })/:site`, subscribers ); - // Stat Site Pages - statsPage( `/stats/:period(${ validTrafficPagePeriods })/:site`, site ); + // Stat Site Pages. The traffic page doubles as the landing page, so it carries + // the pricing grid gate: eligible new sites see the plan choice instead of the + // dashboard until they pick one. + statsPage( `/stats/:period(${ validTrafficPagePeriods })/:site`, withPricingGridGate( site ) ); // Redirect this to default /stats/day/:module/:site view to // keep the paths and page view reporting consistent. diff --git a/client/my-sites/stats/hooks/use-notice-visibility-query.ts b/client/my-sites/stats/hooks/use-notice-visibility-query.ts index 7d12329df395..bb1c4479e77c 100644 --- a/client/my-sites/stats/hooks/use-notice-visibility-query.ts +++ b/client/my-sites/stats/hooks/use-notice-visibility-query.ts @@ -12,6 +12,10 @@ const DEFAULT_SERVER_NOTICES_VISIBILITY = { // Defaults to hidden until the server includes it in the notices response, // so the client can ship ahead of the WPCOM allow-list change. free_site_upgrade: false, + // The server reports this id (true until a dismissal is in effect), so the + // default only covers request failures: the grid stays hidden rather than + // rendering without a working dismissal round-trip. + pricing_grid: false, // TODO: Check if the site needs to be upgraded to a higher tier on the back end. tier_upgrade: true, gdpr_cookie_consent: false, diff --git a/client/my-sites/stats/pricing-grid/gate.tsx b/client/my-sites/stats/pricing-grid/gate.tsx new file mode 100644 index 000000000000..053611c7e049 --- /dev/null +++ b/client/my-sites/stats/pricing-grid/gate.tsx @@ -0,0 +1,80 @@ +import { useState } from 'react'; +import AsyncLoad from 'calypso/components/async-load'; +import QueryProductsList from 'calypso/components/data/query-products-list'; +import QuerySitePurchases from 'calypso/components/data/query-site-purchases'; +import { useNoticeVisibilityQuery } from 'calypso/my-sites/stats/hooks/use-notice-visibility-query'; +import { useSelector } from 'calypso/state'; +import { getSelectedSiteId } from 'calypso/state/ui/selectors'; +import PageLoading from '../pages/shared/page-loading'; +import useIsPricingGridEligible from './hooks/use-eligibility'; +import type { Callback } from '@automattic/calypso-router'; +import type { ReactNode } from 'react'; + +const loadPricingGrid = () => + import( + /* webpackChunkName: "async-load-calypso-my-sites-stats-pricing-grid" */ './pricing-grid' + ); + +/** + * Replaces the Stats dashboard with the pricing grid for newly connected sites + * that haven't picked a plan yet. Everyone else falls straight through to the + * dashboard: the connection-date check is synchronous against site options, so + * established sites never wait on the purchase and notice lookups this gate + * needs before it can decide. + */ +function PricingGridGate( { children }: { children: ReactNode } ) { + const siteId = useSelector( getSelectedSiteId ); + // Choosing a plan swaps the dashboard in immediately; the server-side dismissal + // catches up in the background and keeps the grid away on later visits. + const [ hasChosen, setHasChosen ] = useState( false ); + + const { isEligible, isNewConnection, isLoading } = useIsPricingGridEligible( siteId ); + const { data: isVisible, isLoading: isLoadingVisibility } = useNoticeVisibilityQuery( + siteId, + 'pricing_grid', + isNewConnection + ); + + if ( ! isNewConnection || hasChosen ) { + return <>{ children }; + } + + return ( + <> + + { ( () => { + if ( isLoading || isLoadingVisibility ) { + return PageLoading; + } + if ( ! isEligible || ! isVisible ) { + return children; + } + return ( + <> + + setHasChosen( true ) } + /> + + ); + } )() } + + ); +} + +/** + * Route-controller wrapper: lets the Odyssey routes gate the traffic page + * without pulling JSX into `routes.ts`. + */ +export function withPricingGridGate( controller: Callback ): Callback { + return ( context, next ) => { + controller( context, () => { + context.primary = { context.primary }; + next(); + } ); + }; +} + +export default PricingGridGate; diff --git a/client/my-sites/stats/pricing-grid/gradient.svg b/client/my-sites/stats/pricing-grid/gradient.svg new file mode 100644 index 000000000000..c58f1168a3a9 --- /dev/null +++ b/client/my-sites/stats/pricing-grid/gradient.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/client/my-sites/stats/pricing-grid/hooks/use-eligibility.ts b/client/my-sites/stats/pricing-grid/hooks/use-eligibility.ts new file mode 100644 index 000000000000..79024056bdee --- /dev/null +++ b/client/my-sites/stats/pricing-grid/hooks/use-eligibility.ts @@ -0,0 +1,40 @@ +import { useSelector } from 'calypso/state'; +import { getSiteOption } from 'calypso/state/sites/selectors'; +import useStatsPurchases from '../../hooks/use-stats-purchases'; + +/** + * Sites connected before the pricing grid shipped were never offered this choice, so + * showing it to them now would be a regression rather than onboarding. + */ +const LAUNCH_DATE = Date.parse( '2026-08-07T00:00:00Z' ); + +/** + * Whether the pricing grid applies to this site: a newly connected site that hasn't + * picked a Stats plan yet. Bundled plans (Complete, Growth, Business) count as having + * one, which is why this defers to `useStatsPurchases` rather than scanning products. + */ +export default function useIsPricingGridEligible( siteId: number | null ) { + const { hasAnyPlan, isLoading: isLoadingPurchases } = useStatsPurchases( siteId ); + + // `created_at` is the wpcom shadow blog's `wp_blogs.registered` — the closest thing + // to a first-connection date the sites payload exposes. It matches the connection + // moment when registration created the row, but a reused pre-existing row keeps its + // older date and reconnects never update it, so this check can only withhold the + // grid from a genuinely new connection — never show it to an established site. + const connectedAt = useSelector( ( state ) => getSiteOption( state, siteId, 'created_at' ) ); + + // The API serves dates both as unix seconds and as ISO strings depending on the + // field; accept either rather than betting on one and silently never matching. + const connectedAtMs = + typeof connectedAt === 'number' + ? connectedAt * 1000 + : Date.parse( String( connectedAt ?? '' ) ); + const isNewConnection = Number.isFinite( connectedAtMs ) && connectedAtMs >= LAUNCH_DATE; + + return { + isEligible: isNewConnection && ! hasAnyPlan, + isNewConnection, + // The date check needs no fetch, so only newly connected sites ever wait. + isLoading: isNewConnection && isLoadingPurchases, + }; +} diff --git a/client/my-sites/stats/pricing-grid/pricing-grid.tsx b/client/my-sites/stats/pricing-grid/pricing-grid.tsx new file mode 100644 index 000000000000..395eb6fb4ec5 --- /dev/null +++ b/client/my-sites/stats/pricing-grid/pricing-grid.tsx @@ -0,0 +1,317 @@ +import { PRODUCT_JETPACK_STATS_YEARLY } from '@automattic/calypso-products'; +import { ProductsList } from '@automattic/data-stores'; +import { getCurrencyObject } from '@automattic/number-formatters'; +import { Button, ExternalLink } from '@wordpress/components'; +import { useViewportMatch } from '@wordpress/compose'; +import { createInterpolateElement } from '@wordpress/element'; +import { Icon, check, closeSmall } from '@wordpress/icons'; +import clsx from 'clsx'; +import { useTranslate } from 'i18n-calypso'; +import DocumentHead from 'calypso/components/data/document-head'; +import Main from 'calypso/my-sites/stats/components/stats-main'; +import { STATS_PRODUCT_NAME } from 'calypso/my-sites/stats/constants'; +import useNoticeVisibilityMutation from 'calypso/my-sites/stats/hooks/use-notice-visibility-mutation'; +import { useSelector } from 'calypso/state'; +import { getProductBySlug } from 'calypso/state/products-list/selectors'; +import { getSiteSlug } from 'calypso/state/sites/selectors'; +import { getSelectedSiteId } from 'calypso/state/ui/selectors'; +import './style.scss'; + +const TRACKS_REFERRER = 'jetpack-stats-pricing-grid'; + +interface PlanValue { + isIncluded: boolean; + /** Overrides the default Included / Not included label on every viewport. */ + label?: string; + strong?: boolean; +} + +interface Feature { + name: string; + paid: PlanValue; + free: PlanValue; +} + +interface PricingGridProps { + /** Called when the visitor picks a plan, so the host can reveal the dashboard. */ + onDismiss?: () => void; +} + +/** + * 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, so the Stats plan choice looks identical + * to the Search one. Gating lives in `gate.tsx`; by the time this renders the site + * is known to be eligible and undismissed. + */ +export default function PricingGrid( { onDismiss }: PricingGridProps ) { + const translate = useTranslate(); + // Same breakpoint the jetpack-components PricingTable uses via useViewportMatch. + const isLg = useViewportMatch( 'large' ); + const siteId = useSelector( getSelectedSiteId ); + const siteSlug = useSelector( ( state ) => getSiteSlug( state, siteId ) ); + const { mutate: recordDismissal } = useNoticeVisibilityMutation( + siteId, + 'pricing_grid', + 'dismissed' + ); + + const product = useSelector( ( state ) => + getProductBySlug( state, PRODUCT_JETPACK_STATS_YEARLY ) + ) as ProductsList.RawAPIProduct | null; + + const includedLabel = String( translate( 'Included' ) ); + // The four paid differentiators lead; everything below them is shared by both plans. + const features: Feature[] = [ + { + name: String( translate( 'UTM tracking' ) ), + paid: { isIncluded: true, label: includedLabel, strong: true }, + free: { isIncluded: false }, + }, + { + name: String( translate( 'Device stats' ) ), + paid: { isIncluded: true, label: includedLabel, strong: true }, + free: { isIncluded: false }, + }, + { + name: String( translate( 'Locations' ) ), + paid: { isIncluded: true, label: String( translate( 'Region and city' ) ), strong: true }, + free: { isIncluded: true, label: String( translate( 'Country-level' ) ) }, + }, + { + name: String( translate( 'Priority support' ) ), + paid: { isIncluded: true, label: includedLabel, strong: true }, + free: { isIncluded: false }, + }, + { + name: String( translate( 'Views and visitors' ) ), + paid: { isIncluded: true }, + free: { isIncluded: true }, + }, + { + name: String( translate( 'Top posts and pages' ) ), + paid: { isIncluded: true }, + free: { isIncluded: true }, + }, + { + name: String( translate( 'Referrers and clicks' ) ), + paid: { isIncluded: true }, + free: { isIncluded: true }, + }, + { + name: String( translate( 'Search terms' ) ), + paid: { isIncluded: true }, + free: { isIncluded: true }, + }, + { + name: String( translate( 'Authors' ) ), + paid: { isIncluded: true }, + free: { isIncluded: true }, + }, + { + name: String( translate( 'Downloads and video plays' ) ), + paid: { isIncluded: true }, + free: { isIncluded: true }, + }, + { + name: String( translate( 'Insights and subscribers' ) ), + paid: { isIncluded: true }, + free: { isIncluded: true }, + }, + { + name: String( translate( 'Full history' ) ), + paid: { isIncluded: true }, + free: { isIncluded: true }, + }, + { + name: String( translate( 'GDPR-compliant' ) ), + paid: { isIncluded: true }, + free: { isIncluded: true }, + }, + ]; + + const paidLabel = String( translate( 'Get Paid Stats' ) ); + const freeLabel = String( translate( 'Start for free' ) ); + + const dismiss = () => { + recordDismissal(); + onDismiss?.(); + }; + + const renderPrice = ( value: number, currency: string, hidePriceFraction: boolean ) => { + const { symbol, integer, fraction } = getCurrencyObject( value, currency ); + const showPriceFraction = ! hidePriceFraction || ! fraction.endsWith( '00' ); + return ( +

+ { symbol } + { integer } + { showPriceFraction && ( + + { fraction } + + ) } +

+ ); + }; + + const renderItem = ( feature: Feature, plan: 'paid' | 'free' ) => { + const { isIncluded, label, strong } = feature[ plan ]; + const defaultLabel = isLg + ? String( isIncluded ? translate( 'Included' ) : translate( 'Not included' ) ) + : String( + isIncluded + ? feature.name + : translate( '%s not included', { args: [ feature.name ], comment: 'Feature name' } ) + ); + const text = label ?? defaultLabel; + return ( +
+ + + { strong ? { text } : text } + +
+ ); + }; + + const renderHeader = ( children: React.ReactNode ) => ( +
+
{ children }
+
+ ); + + // Prices are yearly; shown per month like the Search grid. The layout renders + // with the price block omitted if the product hasn't loaded — CTAs still work. + const monthlyPrice = product?.cost ? product.cost / 12 : null; + const currencyCode = product?.currency_code ?? 'USD'; + + return ( +
+ +
+
+
+
+
+

+ { translate( 'Choose your Stats plan' ) } +

+
+ { isLg && + features.map( ( feature, i ) => ( +
+ + { feature.name } + +
+ ) ) } + + { /* Paid column */ } +
+ { renderHeader( + <> +
+ { monthlyPrice !== null && renderPrice( monthlyPrice, currencyCode, false ) } +
+
+ { translate( 'per month, from 10k monthly views, billed yearly' ) } +
+ + + ) } + { features.map( ( feature ) => renderItem( feature, 'paid' ) ) } +
+ + { /* Free column */ } +
+ { renderHeader( + <> +
+ { renderPrice( 0, currencyCode, true ) } +
+ { /* The legend's ::after zero-width space keeps the row height. */ } +
+ + + ) } + { features.map( ( feature ) => renderItem( feature, 'free' ) ) } +
+
+
+ +
+
+ { createInterpolateElement( + String( + translate( + 'By clicking %(paid)s or %(free)s, you agree to our Terms of Service and to sync your site‘s data with us.', + { args: { paid: paidLabel, free: freeLabel } } + ) + ), + { + strong: , + tosLink: ( + + ), + shareDetailsLink: ( + + ), + } + ) } +
+
+
+
+
+ ); +} diff --git a/client/my-sites/stats/pricing-grid/style.scss b/client/my-sites/stats/pricing-grid/style.scss new file mode 100644 index 000000000000..504a9b946e9d --- /dev/null +++ b/client/my-sites/stats/pricing-grid/style.scss @@ -0,0 +1,295 @@ +/* + * Ported 1:1 from @automattic/jetpack-components (PricingTable, ProductPrice, + * Button, AdminSectionHero, Container) so the grid renders identically to the + * Jetpack Search upsell without depending on that package. Values map to the + * studio palette tokens the jetpack theme is built from; the two values with + * no studio equivalent are declared as local custom properties below. + */ +.stats-pricing-grid { + // jetpack-components ThemeProvider values without a studio token. + --stats-pricing-grid-white-off: #f9f9f6; + --stats-pricing-grid-item-border: #f0f0f0; + + // PricingTable container tokens (spacing-base is 8px in the jetpack theme). + --spacing-base: 8px; + --padding: calc(var(--spacing-base) * 4); + --padding-horizontal: calc(var(--spacing-base) * 3); + --padding-vertical: var(--spacing-base); + --gap: calc(var(--spacing-base) * 3); + + // AdminSectionHero + background: var(--stats-pricing-grid-white-off); + overflow: hidden; + padding-top: 1px; + color: var(--studio-black); + + :where(&) * { + box-sizing: border-box; + } +} + +// Container (lg 1040px max width; per-breakpoint gutters; horizontalSpacing 8). +.stats-pricing-grid__container { + margin: 0 auto; + width: 100%; + padding: calc(var(--spacing-base) * 8) 24px; + max-width: calc(1040px + 2 * 24px); + + @media (min-width: 600px) and (max-width: 959px) { + padding-inline: 18px; + max-width: calc(1040px + 2 * 18px); + } + + @media (max-width: 599px) { + padding-inline: 16px; + max-width: calc(1040px + 2 * 16px); + } +} + +.stats-pricing-grid__table { + position: relative; + padding: var(--padding) 0; + + .is-viewport-large & { + display: grid; + grid-template-columns: repeat(var(--columns), 1fr); + grid-auto-flow: column; + grid-template-rows: repeat(var(--rows), minmax(min-content, max-content)); + column-gap: var(--gap); + } +} + +.stats-pricing-grid__table-title { + margin: 0; + padding: 0; + font-size: 32px; + font-weight: 500; + line-height: 40px; + overflow-wrap: break-word; +} + +.stats-pricing-grid__card { + margin-top: var(--padding); + + .is-viewport-large & { + display: contents; + } + + > :first-child { + border-style: solid; + border-color: var(--stats-pricing-grid-item-border); + border-width: 1.5px 1.5px 0; + border-start-start-radius: 8px; + border-start-end-radius: 8px; + } + + > :last-child { + border-style: solid; + border-color: var(--stats-pricing-grid-item-border); + border-width: 0 1.5px 1.5px; + border-end-start-radius: 8px; + border-end-end-radius: 8px; + } + + > :not(:first-child):not(:last-child) { + border-inline: 1.5px solid var(--stats-pricing-grid-item-border); + } + + > * { + background: var(--studio-white); + } + + &--primary { + + > * { + position: relative; + + &::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: -1; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.05); + } + } + + > :first-child { + border-style: solid; + border-color: var(--studio-jetpack-green-50); + border-width: 1.5px 1.5px 0; + border-start-start-radius: 8px; + border-start-end-radius: 8px; + background-image: url(./gradient.svg); + background-repeat: no-repeat; + background-size: 450px; + background-position-x: right; + } + + > :last-child { + border-style: solid; + border-color: var(--studio-jetpack-green-50); + border-width: 0 1.5px 1.5px; + border-end-start-radius: 8px; + border-end-end-radius: 8px; + } + + > :not(:first-child):not(:last-child) { + border-inline: 1.5px solid var(--studio-jetpack-green-50); + } + } +} + +.stats-pricing-grid__header-container { + padding: var(--padding); + display: flex; + flex-direction: column; +} + +.stats-pricing-grid__header { + display: flex; + flex-direction: column; + justify-content: space-between; + flex-basis: 100%; +} + +.stats-pricing-grid__item { + display: flex; + align-items: center; + padding-bottom: var(--padding-vertical); + position: relative; + line-height: 20px; + + &:not(:nth-child(2)) { + padding-top: var(--padding-vertical); + + &::before { + content: ""; + position: absolute; + top: 0; + inset-inline-start: var(--padding-horizontal); + inset-inline-end: var(--padding-horizontal); + height: 1px; + z-index: 5; + background-color: var(--studio-gray-5); + } + } + + &--feature:not(:nth-child(2))::before { + inset-inline-start: 0; + inset-inline-end: calc(var(--padding) - var(--gap)); + } + + &--last-feature { + padding-bottom: var(--padding); + } + + &--value { + padding-left: var(--padding-horizontal); + padding-right: var(--padding-horizontal); + } +} + +.stats-pricing-grid__item-icon { + margin: 0 var(--spacing-base); + fill: var(--studio-gray-5); + flex-shrink: 0; + + &--check { + fill: var(--studio-jetpack-green-40); + } + + &--cross { + fill: var(--studio-red-50); + } +} + +// Text variant body-small. +.stats-pricing-grid__item-text { + font-size: 14px; + font-weight: 400; + line-height: 24px; +} + +// ProductPrice container: price, legend, and CTA are direct flex children of the +// header, so extra height in the shorter column distributes the same way it does +// in the jetpack component. +.stats-pricing-grid__price-row { + display: flex; + flex-wrap: wrap; + align-items: flex-start; +} + +// ProductPrice: headline-medium price with sup symbol and fraction. +.stats-pricing-grid__price { + margin: 0; + padding: 0; + display: inline-flex; + align-items: flex-start; + font-size: 48px; + font-weight: 700; + line-height: 52px; +} + +.stats-pricing-grid__price-symbol { + margin: 0; + padding: 0; + font-size: 24px; + font-weight: 400; + line-height: 32px; +} + +.stats-pricing-grid__price-fraction { + margin: 0; + padding: 0; + font-size: 14px; + line-height: 24px; +} + +.stats-pricing-grid__price-legend { + color: var(--studio-gray-40); + font-size: 14px; + line-height: 20px; + margin-bottom: calc(var(--spacing-base) * 3); + + &::after { + content: "\200B"; // Pseudo element to keep height + } +} + +// jetpack-components Button "normal" size, full width. Anchored under the block +// root so the override outweighs wp-components' own variant rules regardless of +// stylesheet load order. +.stats-pricing-grid .stats-pricing-grid__cta.components-button { + height: auto; + width: 100%; + min-width: 100%; + justify-content: center; + font-size: 16px; + font-weight: 600; + line-height: 24px; + padding: var(--spacing-base) calc(var(--spacing-base) * 3); +} + +.stats-pricing-grid__tos-container { + display: flex; + justify-content: right; + margin: 0 calc(var(--spacing-base) * 4); +} + +.stats-pricing-grid__tos { + text-align: center; + width: fit-content; + font-size: 16px; + line-height: 24px; + + // Same threshold the is-viewport-large class flips at (WP `large` breakpoint). + @media (min-width: 960px) { + padding-left: var(--padding); + padding-right: var(--padding); + white-space: nowrap; + overflow: hidden; + } +} From 038d6f54a8796aa963eaff23f997633ba1f70401 Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Fri, 7 Aug 2026 16:06:04 +1200 Subject: [PATCH 2/6] Stats: give the pricing grid top priority among dashboard notices While the grid is undismissed it replaces the dashboard outright, so no other dashboard notice should fire alongside it. --- client/my-sites/stats/hooks/use-notice-visibility-query.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/my-sites/stats/hooks/use-notice-visibility-query.ts b/client/my-sites/stats/hooks/use-notice-visibility-query.ts index bb1c4479e77c..0b0d799be9b2 100644 --- a/client/my-sites/stats/hooks/use-notice-visibility-query.ts +++ b/client/my-sites/stats/hooks/use-notice-visibility-query.ts @@ -37,7 +37,10 @@ export type NoticeIdType = keyof Notices; // The IDs are sorted by priory from high to low. const CONFLICT_NOTICE_ID_GROUPS: Record< string, Array< NoticeIdType > > = { dashboard_notices: [ - // Set the highest priority to prevent blocking Stats under any circumstances. + // Highest priority: while the pricing grid is undismissed it replaces the + // dashboard outright, so no other dashboard notice should fire alongside it. + 'pricing_grid', + // Set the highest priority among banners to prevent blocking Stats under any circumstances. 'gdpr_cookie_consent', 'client_paid_plan_purchase_success', 'client_free_plan_purchase_success', From 78f1ccf6b9a4850a90fc9042d358e869abdd4b3f Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Fri, 7 Aug 2026 16:15:20 +1200 Subject: [PATCH 3/6] Stats pricing grid: record dismissal reliably from both CTAs 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. --- .../stats/pricing-grid/pricing-grid.tsx | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/client/my-sites/stats/pricing-grid/pricing-grid.tsx b/client/my-sites/stats/pricing-grid/pricing-grid.tsx index 395eb6fb4ec5..f3736ab3687f 100644 --- a/client/my-sites/stats/pricing-grid/pricing-grid.tsx +++ b/client/my-sites/stats/pricing-grid/pricing-grid.tsx @@ -1,6 +1,8 @@ import { PRODUCT_JETPACK_STATS_YEARLY } from '@automattic/calypso-products'; +import page from '@automattic/calypso-router'; import { ProductsList } from '@automattic/data-stores'; import { getCurrencyObject } from '@automattic/number-formatters'; +import { useQueryClient } from '@tanstack/react-query'; import { Button, ExternalLink } from '@wordpress/components'; import { useViewportMatch } from '@wordpress/compose'; import { createInterpolateElement } from '@wordpress/element'; @@ -15,6 +17,7 @@ import { useSelector } from 'calypso/state'; import { getProductBySlug } from 'calypso/state/products-list/selectors'; import { getSiteSlug } from 'calypso/state/sites/selectors'; import { getSelectedSiteId } from 'calypso/state/ui/selectors'; +import type { Notices } from 'calypso/my-sites/stats/hooks/use-notice-visibility-query'; import './style.scss'; const TRACKS_REFERRER = 'jetpack-stats-pricing-grid'; @@ -50,6 +53,7 @@ export default function PricingGrid( { onDismiss }: PricingGridProps ) { const isLg = useViewportMatch( 'large' ); const siteId = useSelector( getSelectedSiteId ); const siteSlug = useSelector( ( state ) => getSiteSlug( state, siteId ) ); + const queryClient = useQueryClient(); const { mutate: recordDismissal } = useNoticeVisibilityMutation( siteId, 'pricing_grid', @@ -135,9 +139,24 @@ export default function PricingGrid( { onDismiss }: PricingGridProps ) { const dismiss = () => { recordDismissal(); + // The mutation doesn't touch the notices query cache, so update it in place: + // the gate re-reads it on SPA route changes (e.g. returning from the purchase + // page via "I will do it later") and must see the grid as already dismissed. + queryClient.setQueryData( + [ 'stats', 'notices-visibility', 'raw', siteId ], + ( notices: Notices | undefined ) => notices && { ...notices, pricing_grid: false } + ); onDismiss?.(); }; + // Navigate programmatically rather than via href: 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 runs and the dismissal is lost. + const goToPurchase = () => { + dismiss(); + page( `/stats/purchase/${ siteSlug }?from=${ TRACKS_REFERRER }` ); + }; + const renderPrice = ( value: number, currency: string, hidePriceFraction: boolean ) => { const { symbol, integer, fraction } = getCurrencyObject( value, currency ); const showPriceFraction = ! hidePriceFraction || ! fraction.endsWith( '00' ); @@ -250,8 +269,7 @@ export default function PricingGrid( { onDismiss }: PricingGridProps ) { From c8d1acf677397cb9b80a1c87f951a4b77d03497d Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Fri, 7 Aug 2026 16:21:42 +1200 Subject: [PATCH 4/6] Stats: keep the pricing grid out of the notices conflict group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../stats/hooks/use-notice-visibility-query.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/client/my-sites/stats/hooks/use-notice-visibility-query.ts b/client/my-sites/stats/hooks/use-notice-visibility-query.ts index 0b0d799be9b2..b8c5b8ab56c7 100644 --- a/client/my-sites/stats/hooks/use-notice-visibility-query.ts +++ b/client/my-sites/stats/hooks/use-notice-visibility-query.ts @@ -35,12 +35,14 @@ export type NoticeIdType = keyof Notices; // These notices are mutually exclusive, so if one is active, the other should be hidden. // The IDs are sorted by priory from high to low. +// `pricing_grid` is deliberately NOT in this group even though the grid trumps every +// notice: it replaces the whole dashboard, so StatsNotices never mounts alongside it +// and no suppression is needed. Listing it here would instead suppress every other +// notice on all the sites that never see the grid (pre-launch sites, sites with +// plans), since the server reports the id as visible until a dismissal is recorded. const CONFLICT_NOTICE_ID_GROUPS: Record< string, Array< NoticeIdType > > = { dashboard_notices: [ - // Highest priority: while the pricing grid is undismissed it replaces the - // dashboard outright, so no other dashboard notice should fire alongside it. - 'pricing_grid', - // Set the highest priority among banners to prevent blocking Stats under any circumstances. + // Set the highest priority to prevent blocking Stats under any circumstances. 'gdpr_cookie_consent', 'client_paid_plan_purchase_success', 'client_free_plan_purchase_success', From 48016668d74d6ba01ceb55e531ef5579b1007338 Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Fri, 7 Aug 2026 17:24:47 +1200 Subject: [PATCH 5/6] Stats pricing grid: dismiss on a plan decision, not on reaching checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../hooks/use-dismiss-pricing-grid.ts | 33 ++++++++++++++++ .../stats/pricing-grid/pricing-grid.tsx | 38 ++++++------------- .../stats-purchase-personal.tsx | 11 ++++++ .../stats-purchase-single-item.tsx | 11 ++++++ 4 files changed, 67 insertions(+), 26 deletions(-) create mode 100644 client/my-sites/stats/pricing-grid/hooks/use-dismiss-pricing-grid.ts diff --git a/client/my-sites/stats/pricing-grid/hooks/use-dismiss-pricing-grid.ts b/client/my-sites/stats/pricing-grid/hooks/use-dismiss-pricing-grid.ts new file mode 100644 index 000000000000..2a898e3e3eed --- /dev/null +++ b/client/my-sites/stats/pricing-grid/hooks/use-dismiss-pricing-grid.ts @@ -0,0 +1,33 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import useNoticeVisibilityMutation from 'calypso/my-sites/stats/hooks/use-notice-visibility-mutation'; +import type { Notices } from 'calypso/my-sites/stats/hooks/use-notice-visibility-query'; + +/** The `from` value the pricing grid's paid CTA sends to the purchase page. */ +export const PRICING_GRID_REFERRER = 'jetpack-stats-pricing-grid'; + +/** + * Returns a function that records the pricing grid dismissal server-side and + * patches the cached notices in place, so the gate sees the choice on SPA route + * changes without waiting for a refetch. + * + * Only a plan decision dismisses: "Start for free" on the grid, or "I will do + * it later" on the purchase page. Merely reaching the purchase page does not — + * an abandoned checkout brings the visitor back to the grid. + */ +export default function useDismissPricingGrid( siteId: number | null ) { + const queryClient = useQueryClient(); + const { mutate: recordDismissal } = useNoticeVisibilityMutation( + siteId, + 'pricing_grid', + 'dismissed' + ); + + return useCallback( () => { + recordDismissal(); + queryClient.setQueryData( + [ 'stats', 'notices-visibility', 'raw', siteId ], + ( notices: Notices | undefined ) => notices && { ...notices, pricing_grid: false } + ); + }, [ recordDismissal, queryClient, siteId ] ); +} diff --git a/client/my-sites/stats/pricing-grid/pricing-grid.tsx b/client/my-sites/stats/pricing-grid/pricing-grid.tsx index f3736ab3687f..4eaaf218d12a 100644 --- a/client/my-sites/stats/pricing-grid/pricing-grid.tsx +++ b/client/my-sites/stats/pricing-grid/pricing-grid.tsx @@ -2,7 +2,6 @@ import { PRODUCT_JETPACK_STATS_YEARLY } from '@automattic/calypso-products'; import page from '@automattic/calypso-router'; import { ProductsList } from '@automattic/data-stores'; import { getCurrencyObject } from '@automattic/number-formatters'; -import { useQueryClient } from '@tanstack/react-query'; import { Button, ExternalLink } from '@wordpress/components'; import { useViewportMatch } from '@wordpress/compose'; import { createInterpolateElement } from '@wordpress/element'; @@ -12,16 +11,13 @@ import { useTranslate } from 'i18n-calypso'; import DocumentHead from 'calypso/components/data/document-head'; import Main from 'calypso/my-sites/stats/components/stats-main'; import { STATS_PRODUCT_NAME } from 'calypso/my-sites/stats/constants'; -import useNoticeVisibilityMutation from 'calypso/my-sites/stats/hooks/use-notice-visibility-mutation'; import { useSelector } from 'calypso/state'; import { getProductBySlug } from 'calypso/state/products-list/selectors'; import { getSiteSlug } from 'calypso/state/sites/selectors'; import { getSelectedSiteId } from 'calypso/state/ui/selectors'; -import type { Notices } from 'calypso/my-sites/stats/hooks/use-notice-visibility-query'; +import useDismissPricingGrid, { PRICING_GRID_REFERRER } from './hooks/use-dismiss-pricing-grid'; import './style.scss'; -const TRACKS_REFERRER = 'jetpack-stats-pricing-grid'; - interface PlanValue { isIncluded: boolean; /** Overrides the default Included / Not included label on every viewport. */ @@ -53,12 +49,7 @@ export default function PricingGrid( { onDismiss }: PricingGridProps ) { const isLg = useViewportMatch( 'large' ); const siteId = useSelector( getSelectedSiteId ); const siteSlug = useSelector( ( state ) => getSiteSlug( state, siteId ) ); - const queryClient = useQueryClient(); - const { mutate: recordDismissal } = useNoticeVisibilityMutation( - siteId, - 'pricing_grid', - 'dismissed' - ); + const dismissPricingGrid = useDismissPricingGrid( siteId ); const product = useSelector( ( state ) => getProductBySlug( state, PRODUCT_JETPACK_STATS_YEARLY ) @@ -137,24 +128,19 @@ export default function PricingGrid( { onDismiss }: PricingGridProps ) { const paidLabel = String( translate( 'Get Paid Stats' ) ); const freeLabel = String( translate( 'Start for free' ) ); - const dismiss = () => { - recordDismissal(); - // The mutation doesn't touch the notices query cache, so update it in place: - // the gate re-reads it on SPA route changes (e.g. returning from the purchase - // page via "I will do it later") and must see the grid as already dismissed. - queryClient.setQueryData( - [ 'stats', 'notices-visibility', 'raw', siteId ], - ( notices: Notices | undefined ) => notices && { ...notices, pricing_grid: false } - ); + // Starting for free is a plan choice: record the dismissal and reveal the dashboard. + const startForFree = () => { + dismissPricingGrid(); onDismiss?.(); }; - // Navigate programmatically rather than via href: 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 runs and the dismissal is lost. + // Deliberately not a dismissal: heading to the purchase page is not a plan choice + // yet, so an abandoned checkout brings the visitor back to the grid; the purchase + // 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 = () => { - dismiss(); - page( `/stats/purchase/${ siteSlug }?from=${ TRACKS_REFERRER }` ); + page( `/stats/purchase/${ siteSlug }?from=${ PRICING_GRID_REFERRER }` ); }; const renderPrice = ( value: number, currency: string, hidePriceFraction: boolean ) => { @@ -290,7 +276,7 @@ export default function PricingGrid( { onDismiss }: PricingGridProps ) { diff --git a/client/my-sites/stats/stats-purchase/stats-purchase-personal.tsx b/client/my-sites/stats/stats-purchase/stats-purchase-personal.tsx index 538f68609450..6cdfea8ffb15 100644 --- a/client/my-sites/stats/stats-purchase/stats-purchase-personal.tsx +++ b/client/my-sites/stats/stats-purchase/stats-purchase-personal.tsx @@ -8,6 +8,9 @@ import { recordTracksEvent } from 'calypso/lib/analytics/tracks'; import { STATS_PRODUCT_NAME } from 'calypso/my-sites/stats/constants'; import { useJetpackConnectionStatus } from 'calypso/my-sites/stats/hooks/use-jetpack-connection-status'; import useStatsPurchases from 'calypso/my-sites/stats/hooks/use-stats-purchases'; +import useDismissPricingGrid, { + PRICING_GRID_REFERRER, +} from 'calypso/my-sites/stats/pricing-grid/hooks/use-dismiss-pricing-grid'; import { useSelector } from 'calypso/state'; import getIsSiteWPCOM from 'calypso/state/selectors/is-site-wpcom'; import getIsSimpleSite from 'calypso/state/sites/selectors/is-simple-site'; @@ -85,10 +88,18 @@ const PersonalPurchase = ( { } ); }; + const dismissPricingGrid = useDismissPricingGrid( siteId ); + const handleCheckoutPostponed = () => { const event_from = isOdysseyStats ? 'jetpack_odyssey' : 'calypso'; recordTracksEvent( `${ event_from }_stats_purchase_flow_skip_button_clicked` ); + // Skipping is the visitor's plan decision: the pricing grid that sent them here + // (undismissed while checkout was merely in progress) mustn't take over again. + if ( from === PRICING_GRID_REFERRER ) { + dismissPricingGrid(); + } + // redirect to the Traffic page setTimeout( () => { page( `/stats/day/${ siteSlug }` ); diff --git a/client/my-sites/stats/stats-purchase/stats-purchase-single-item.tsx b/client/my-sites/stats/stats-purchase/stats-purchase-single-item.tsx index 47709226f53e..a40ccf5039cc 100644 --- a/client/my-sites/stats/stats-purchase/stats-purchase-single-item.tsx +++ b/client/my-sites/stats/stats-purchase/stats-purchase-single-item.tsx @@ -29,6 +29,9 @@ import useOnDemandCommercialClassificationMutation from '../hooks/use-on-demand- import usePlanUsageQuery, { getUsageLimitStatus } from '../hooks/use-plan-usage-query'; import useSiteCompulsoryPlanSelectionQualifiedCheck from '../hooks/use-site-compulsory-plan-selection-qualified-check'; import useStatsPurchases from '../hooks/use-stats-purchases'; +import useDismissPricingGrid, { + PRICING_GRID_REFERRER, +} from '../pricing-grid/hooks/use-dismiss-pricing-grid'; import { StatsCommercialUpgradeSlider, getTierQuantity } from './stats-commercial-upgrade-slider'; import gotoCheckoutPage from './stats-purchase-checkout-redirect'; import { @@ -279,10 +282,18 @@ const StatsCommercialPurchase = ( { setPurchaseTierQuantity( value ); }, [] ); + const dismissPricingGrid = useDismissPricingGrid( siteId ); + const handleCheckoutPostponed = () => { const event_from = isOdysseyStats ? 'jetpack_odyssey' : 'calypso'; recordTracksEvent( `${ event_from }_stats_purchase_commercial_skip_button_clicked` ); + // Skipping is the visitor's plan decision: the pricing grid that sent them here + // (undismissed while checkout was merely in progress) mustn't take over again. + if ( from === PRICING_GRID_REFERRER ) { + dismissPricingGrid(); + } + setTimeout( () => { page( `/stats/day/${ siteSlug }` ); }, 250 ); From 88be92fb91442e51a177b7e830e0736d4501da7f Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Fri, 7 Aug 2026 17:39:19 +1200 Subject: [PATCH 6/6] Stats pricing grid: dismiss on 'I will do it later' regardless of referrer 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. --- .../stats-purchase/stats-purchase-personal.tsx | 14 ++++++-------- .../stats-purchase/stats-purchase-single-item.tsx | 14 ++++++-------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/client/my-sites/stats/stats-purchase/stats-purchase-personal.tsx b/client/my-sites/stats/stats-purchase/stats-purchase-personal.tsx index 6cdfea8ffb15..2864b70d1923 100644 --- a/client/my-sites/stats/stats-purchase/stats-purchase-personal.tsx +++ b/client/my-sites/stats/stats-purchase/stats-purchase-personal.tsx @@ -8,9 +8,7 @@ import { recordTracksEvent } from 'calypso/lib/analytics/tracks'; import { STATS_PRODUCT_NAME } from 'calypso/my-sites/stats/constants'; import { useJetpackConnectionStatus } from 'calypso/my-sites/stats/hooks/use-jetpack-connection-status'; import useStatsPurchases from 'calypso/my-sites/stats/hooks/use-stats-purchases'; -import useDismissPricingGrid, { - PRICING_GRID_REFERRER, -} from 'calypso/my-sites/stats/pricing-grid/hooks/use-dismiss-pricing-grid'; +import useDismissPricingGrid from 'calypso/my-sites/stats/pricing-grid/hooks/use-dismiss-pricing-grid'; import { useSelector } from 'calypso/state'; import getIsSiteWPCOM from 'calypso/state/selectors/is-site-wpcom'; import getIsSimpleSite from 'calypso/state/sites/selectors/is-simple-site'; @@ -94,11 +92,11 @@ const PersonalPurchase = ( { const event_from = isOdysseyStats ? 'jetpack_odyssey' : 'calypso'; recordTracksEvent( `${ event_from }_stats_purchase_flow_skip_button_clicked` ); - // Skipping is the visitor's plan decision: the pricing grid that sent them here - // (undismissed while checkout was merely in progress) mustn't take over again. - if ( from === PRICING_GRID_REFERRER ) { - dismissPricingGrid(); - } + // Skipping is the visitor's plan decision — made on a page that shows the full + // paid pitch — so the pricing grid mustn't take over the dashboard afterwards, + // regardless of how they got here. On sites where the grid never shows this is + // a harmless no-op. + dismissPricingGrid(); // redirect to the Traffic page setTimeout( () => { diff --git a/client/my-sites/stats/stats-purchase/stats-purchase-single-item.tsx b/client/my-sites/stats/stats-purchase/stats-purchase-single-item.tsx index a40ccf5039cc..ae6f8e5c59f3 100644 --- a/client/my-sites/stats/stats-purchase/stats-purchase-single-item.tsx +++ b/client/my-sites/stats/stats-purchase/stats-purchase-single-item.tsx @@ -29,9 +29,7 @@ import useOnDemandCommercialClassificationMutation from '../hooks/use-on-demand- import usePlanUsageQuery, { getUsageLimitStatus } from '../hooks/use-plan-usage-query'; import useSiteCompulsoryPlanSelectionQualifiedCheck from '../hooks/use-site-compulsory-plan-selection-qualified-check'; import useStatsPurchases from '../hooks/use-stats-purchases'; -import useDismissPricingGrid, { - PRICING_GRID_REFERRER, -} from '../pricing-grid/hooks/use-dismiss-pricing-grid'; +import useDismissPricingGrid from '../pricing-grid/hooks/use-dismiss-pricing-grid'; import { StatsCommercialUpgradeSlider, getTierQuantity } from './stats-commercial-upgrade-slider'; import gotoCheckoutPage from './stats-purchase-checkout-redirect'; import { @@ -288,11 +286,11 @@ const StatsCommercialPurchase = ( { const event_from = isOdysseyStats ? 'jetpack_odyssey' : 'calypso'; recordTracksEvent( `${ event_from }_stats_purchase_commercial_skip_button_clicked` ); - // Skipping is the visitor's plan decision: the pricing grid that sent them here - // (undismissed while checkout was merely in progress) mustn't take over again. - if ( from === PRICING_GRID_REFERRER ) { - dismissPricingGrid(); - } + // Skipping is the visitor's plan decision — made on a page that shows the full + // paid pitch — so the pricing grid mustn't take over the dashboard afterwards, + // regardless of how they got here. On sites where the grid never shows this is + // a harmless no-op. + dismissPricingGrid(); setTimeout( () => { page( `/stats/day/${ siteSlug }` );