From d74d4a5d70462689bb0c89784d999037882ae824 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Fri, 9 Oct 2026 15:30:45 +0530 Subject: [PATCH 01/11] Premium Analytics: Show a notice when the site nears or passes its views limit Carries over the Stats tier upgrade notice: an info notice at 90% of the plan's billable views limit, which the reader can postpone for a week (shared with Stats through the tier_upgrade notice), and a warning past the limit that cannot be dismissed. The plan-usage widget and the notice now share one Stats purchase URL helper. --- .../changelog/wooa7s-2038-plan-limit-notices | 4 + .../widgets-toolkit/src/helpers/index.ts | 1 + .../src/helpers/stats-upgrade-url.ts | 25 +++ .../packages/widgets-toolkit/src/index.ts | 1 + .../routes/dashboard/components/index.ts | 1 + .../components/plan-limit-notice/index.ts | 1 + .../plan-limit-notice.module.scss | 4 + .../plan-limit-notice.test.tsx | 158 ++++++++++++++++++ .../plan-limit-notice/plan-limit-notice.tsx | 153 +++++++++++++++++ .../routes/dashboard/stage.test.tsx | 11 ++ .../routes/dashboard/stage.tsx | 3 + .../changelog/wooa7s-2038-plan-limit-notices | 4 + .../changelog/wooa7s-2038-plan-limit-notices | 4 + 13 files changed, 370 insertions(+) create mode 100644 projects/packages/premium-analytics/changelog/wooa7s-2038-plan-limit-notices create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/stats-upgrade-url.ts create mode 100644 projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/index.ts create mode 100644 projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.module.scss create mode 100644 projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx create mode 100644 projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx create mode 100644 projects/plugins/jetpack/changelog/wooa7s-2038-plan-limit-notices create mode 100644 projects/plugins/premium-analytics/changelog/wooa7s-2038-plan-limit-notices diff --git a/projects/packages/premium-analytics/changelog/wooa7s-2038-plan-limit-notices b/projects/packages/premium-analytics/changelog/wooa7s-2038-plan-limit-notices new file mode 100644 index 000000000000..08f84448c098 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-2038-plan-limit-notices @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Dashboard: Show a notice when the site nears or passes its plan's monthly views limit. diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts index 6c65d7391580..99de0e4c2db2 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts @@ -21,6 +21,7 @@ export { type Region, } from './build-visitors-by-location-data'; export { flagUrl } from './flag-url'; +export { statsUpgradeUrl } from './stats-upgrade-url'; export { isEmptyChartData, isEmptyPieChartData, getEmptyChartDomain } from './chart-empty-state'; export { getFixedYAxis, diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/stats-upgrade-url.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/stats-upgrade-url.ts new file mode 100644 index 000000000000..7573cce2a1c6 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/stats-upgrade-url.ts @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import { getScriptData } from '@automattic/jetpack-script-data'; + +/** + * The Stats tier-upgrade purchase screen for this site, returning to this + * dashboard after checkout. `undefined` where script data is absent (e.g. + * Storybook without a seeded `window.JetpackScriptData`). + * + * @param from - Where the purchase started, reported to the checkout as `from`. + * @return The purchase URL, or `undefined`. + */ +export function statsUpgradeUrl( from: string ): string | undefined { + const site = getScriptData()?.site; + const blogId = site?.wpcom?.blog_id; + if ( ! site?.admin_url || ! blogId ) { + return undefined; + } + + const backTo = encodeURIComponent( 'admin.php?page=jetpack-premium-analytics-wp-admin' ); + return `${ site.admin_url }admin.php?page=stats#!/stats/purchase/${ blogId }?from=${ encodeURIComponent( + from + ) }&productType=commercial&redirect_uri=${ backTo }`; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts index 679a5c2176b5..63517406d6b5 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts @@ -283,6 +283,7 @@ export type { ReportParamsFieldAttributes } from './fields'; export { calculateDelta, flagUrl, + statsUpgradeUrl, BOOKINGS_FILTER, formatLegendLabels, formatDisplayLabel, diff --git a/projects/packages/premium-analytics/routes/dashboard/components/index.ts b/projects/packages/premium-analytics/routes/dashboard/components/index.ts index f4c15e8f69d8..2b719403ad10 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/index.ts +++ b/projects/packages/premium-analytics/routes/dashboard/components/index.ts @@ -1,5 +1,6 @@ export { DashboardSections } from './dashboard-sections/dashboard-sections'; export { FeedbackBanner } from './feedback-banner'; +export { PlanLimitNotice } from './plan-limit-notice'; export { OnboardingTour, onboardingTourSteps } from './onboarding-tour'; export { RefreshFailureNotice } from './refresh-failure-notice/refresh-failure-notice'; export { SectionSyncNotice } from './section-sync-notice/section-sync-notice'; diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/index.ts b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/index.ts new file mode 100644 index 000000000000..267d6a454a7d --- /dev/null +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/index.ts @@ -0,0 +1 @@ +export { PlanLimitNotice } from './plan-limit-notice'; diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.module.scss b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.module.scss new file mode 100644 index 000000000000..c55a9837f321 --- /dev/null +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.module.scss @@ -0,0 +1,4 @@ +.notice { + margin-block-start: var(--wpds-dimension-gap-lg); + margin-inline: var(--wpds-dimension-padding-2xl); +} diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx new file mode 100644 index 000000000000..9eedeb9faacf --- /dev/null +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx @@ -0,0 +1,158 @@ +/** + * External dependencies + */ +import { queryClient } from '@jetpack-premium-analytics/data'; +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import apiFetch from '@wordpress/api-fetch'; +/** + * Internal dependencies + */ +import { PlanLimitNotice } from './plan-limit-notice'; + +jest.mock( '@wordpress/api-fetch', () => jest.fn() ); + +const mockApiFetch = apiFetch as unknown as jest.Mock; + +type Usage = { views_count: number; views_limit: number | null }; +type Notices = { tier_upgrade?: boolean }; + +/** + * Answers the usage and notices requests the notice makes. + * + * @param usage - This cycle's views and the plan's limit. + * @param notices - The Stats notice visibility state. + */ +function mockEndpoints( usage: Usage, notices: Notices = {} ) { + mockApiFetch.mockImplementation( ( { path, method }: { path: string; method?: string } ) => { + if ( path.includes( 'jetpack-stats/usage' ) ) { + return Promise.resolve( { + current_usage: { views_count: usage.views_count }, + views_limit: usage.views_limit, + } ); + } + if ( path.includes( 'notices' ) ) { + return Promise.resolve( method === 'POST' ? { tier_upgrade: false } : notices ); + } + return Promise.reject( new Error( `Unexpected request: ${ path }` ) ); + } ); +} + +/** + * Seeds the script data the notice reads the host and purchase URL from. + * + * @param host - The site's host, as script data reports it. + */ +function setHost( host: string ) { + window.JetpackScriptData = { + site: { + admin_url: 'https://example.com/wp-admin/', + host, + wpcom: { blog_id: 123456789 }, + }, + } as typeof window.JetpackScriptData; +} + +/** + * Renders the notice and settles its requests. + * + * @return The render result. + */ +async function renderNotice() { + const view = render( ); + // The notices request waits on the usage reply, so settle both rounds. + await act( () => jest.runAllTimersAsync() ); + await act( () => jest.runAllTimersAsync() ); + return view; +} + +/* + * The notice speaks its message through `@wordpress/a11y`, which mirrors the + * text into a live region on `document.body`. Assertions read the rendered + * container so they don't match that copy too. + */ +describe( 'PlanLimitNotice', () => { + beforeEach( () => { + jest.useFakeTimers(); + queryClient.clear(); + mockApiFetch.mockReset(); + setHost( 'jetpack' ); + } ); + + afterEach( () => { + jest.useRealTimers(); + } ); + + it( 'warns a site at 90% of its limit and links to the upgrade', async () => { + mockEndpoints( { views_count: 9120, views_limit: 10000 } ); + + const { container } = await renderNotice(); + + expect( container ).toHaveTextContent( 'You are approaching your plan’s view limit' ); + expect( container ).toHaveTextContent( '9,120 of 10,000 billable views used this cycle.' ); + expect( screen.getByRole( 'link', { name: 'Upgrade plan' } ) ).toHaveAttribute( + 'href', + expect.stringContaining( + 'admin.php?page=stats#!/stats/purchase/123456789?from=jetpack-premium-analytics-plan-limit-notice' + ) + ); + } ); + + it( 'says nothing below 90% of the limit', async () => { + mockEndpoints( { views_count: 8999, views_limit: 10000 } ); + + const { container } = await renderNotice(); + + expect( container ).toBeEmptyDOMElement(); + } ); + + it( 'says nothing to a site without a views limit', async () => { + mockEndpoints( { views_count: 50000, views_limit: null } ); + + const { container } = await renderNotice(); + + expect( container ).toBeEmptyDOMElement(); + } ); + + it( 'cannot be dismissed once the site is over its limit', async () => { + mockEndpoints( { views_count: 11480, views_limit: 10000 }, { tier_upgrade: false } ); + + const { container } = await renderNotice(); + + expect( container ).toHaveTextContent( 'You have gone over your plan’s view limit' ); + expect( screen.queryByRole( 'button', { name: 'Dismiss' } ) ).not.toBeInTheDocument(); + } ); + + it( 'stays away near the limit while Stats has it postponed', async () => { + mockEndpoints( { views_count: 9120, views_limit: 10000 }, { tier_upgrade: false } ); + + const { container } = await renderNotice(); + + expect( container ).toBeEmptyDOMElement(); + } ); + + it( 'postpones the near-limit notice for a week when dismissed', async () => { + mockEndpoints( { views_count: 9120, views_limit: 10000 } ); + const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } ); + const { container } = await renderNotice(); + + await user.click( screen.getByRole( 'button', { name: 'Dismiss' } ) ); + + expect( container ).toBeEmptyDOMElement(); + expect( mockApiFetch ).toHaveBeenCalledWith( + expect.objectContaining( { + method: 'POST', + data: { id: 'tier_upgrade', status: 'postponed', postponed_for: 604800 }, + } ) + ); + } ); + + it.each( [ 'wpcom', 'vip' ] )( 'says nothing on a %s site', async host => { + setHost( host ); + mockEndpoints( { views_count: 11480, views_limit: 10000 } ); + + const { container } = await renderNotice(); + + expect( container ).toBeEmptyDOMElement(); + } ); +} ); diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx new file mode 100644 index 000000000000..cbee93fce157 --- /dev/null +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx @@ -0,0 +1,153 @@ +/** + * External dependencies + */ +import { getScriptData, isSimpleSite } from '@automattic/jetpack-script-data'; +import { + AnalyticsQueryClientProvider, + useStatsAppNoticeMutation, + useStatsAppNotices, + useStatsAppPlanUsage, + type StatsAppPlanUsage, +} from '@jetpack-premium-analytics/data'; +import { Notice } from '@jetpack-premium-analytics/externals'; +import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; +import { statsUpgradeUrl, useTrackEvent } from '@jetpack-premium-analytics/widgets-toolkit'; +import { useCallback, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import styles from './plan-limit-notice.module.scss'; +import type { JSX } from 'react'; + +// The share of the limit at which Stats starts warning, matching Calypso. +const NEAR_LIMIT_USAGE_RATIO = 0.9; + +// Stats postpones this notice for a week, and the state is shared with it. +const POSTPONE_SECONDS = 7 * 24 * 60 * 60; + +type PlanLimitStatus = { + isOver: boolean; + used: number; + limit: number; +}; + +type PlanLimitNoticeProps = { + /** + * Whether the surface the notice belongs to is ready; nothing shows until then. + */ + enabled: boolean; +}; + +/** + * Where this cycle's views stand against the plan's limit. A missing or zero + * limit means there is nothing to meter against. + * + * @param usage - The plan usage report. + * @return The status, or `null` while the site is clear of the limit. + */ +function getPlanLimitStatus( usage?: StatsAppPlanUsage ): PlanLimitStatus | null { + const limit = usage?.views_limit; + if ( ! limit ) { + return null; + } + + const used = usage.current_usage?.views_count ?? 0; + if ( used < limit * NEAR_LIMIT_USAGE_RATIO ) { + return null; + } + + return { isOver: used >= limit, used, limit }; +} + +/** + * Reads the plan usage and renders the notice when the site nears or passes its limit. + * + * @param {PlanLimitNoticeProps} props - Component props. + * @return The notice, or nothing. + */ +function ConnectedNotice( { enabled }: PlanLimitNoticeProps ): JSX.Element | null { + // Stats shows this notice on Jetpack-connected sites only, and VIP sites are exempt from the limit. + const isEligible = enabled && ! isSimpleSite() && getScriptData()?.site?.host !== 'vip'; + + const { data: usage } = useStatsAppPlanUsage( { enabled: isEligible } ); + const status = isEligible ? getPlanLimitStatus( usage ) : null; + + // Only the near-limit notice can be put off; past the limit it stays for the cycle. + const isNear = !! status && ! status.isOver; + const { data: notices } = useStatsAppNotices( undefined, { enabled: isNear } ); + const { mutate: updateNotice } = useStatsAppNoticeMutation(); + const [ isPostponed, setIsPostponed ] = useState( false ); + const trackEvent = useTrackEvent(); + + const postpone = useCallback( () => { + setIsPostponed( true ); + updateNotice( { id: 'tier_upgrade', status: 'postponed', postponed_for: POSTPONE_SECONDS } ); + trackEvent( 'jetpack_premium_analytics_plan_limit_notice_dismiss' ); + }, [ updateNotice, trackEvent ] ); + + if ( ! status || ( isNear && ( ! notices || notices.tier_upgrade === false || isPostponed ) ) ) { + return null; + } + + const upgradeHref = statsUpgradeUrl( 'jetpack-premium-analytics-plan-limit-notice' ); + + const usageSentence = sprintf( + /* translators: 1: views used in the current billing cycle, 2: the plan's views limit. */ + __( '%1$s of %2$s billable views used this cycle.', 'jetpack-premium-analytics-pkg' ), + formatMetricValue( status.used, 'number', { decimals: 0 } ), + formatMetricValue( status.limit, 'number', { decimals: 0 } ) + ); + + const { isOver } = status; + const title = isOver + ? __( 'You have gone over your plan’s view limit', 'jetpack-premium-analytics-pkg' ) + : __( 'You are approaching your plan’s view limit', 'jetpack-premium-analytics-pkg' ); + const consequence = isOver + ? __( + 'Views past the limit are not being counted, so these totals are short until the cycle restarts.', + 'jetpack-premium-analytics-pkg' + ) + : __( + 'Views past the limit will not be counted until it restarts.', + 'jetpack-premium-analytics-pkg' + ); + + return ( + + { title } + { `${ usageSentence } ${ consequence }` } + + { upgradeHref && ( + + + { __( 'Upgrade plan', 'jetpack-premium-analytics-pkg' ) } + + + ) } + + { ! isOver && ( + + ) } + + ); +} + +/** + * Tells the reader their site is close to, or past, the views its plan allows + * this billing cycle, and offers the upgrade. Brings its own query provider, + * since the dashboard stage has none. + * + * @param {PlanLimitNoticeProps} props - Component props. + * @return The notice, or nothing. + */ +export function PlanLimitNotice( { enabled }: PlanLimitNoticeProps ): JSX.Element { + return ( + + + + ); +} diff --git a/projects/packages/premium-analytics/routes/dashboard/stage.test.tsx b/projects/packages/premium-analytics/routes/dashboard/stage.test.tsx index be4c8d4d16d9..3d5e8010686f 100644 --- a/projects/packages/premium-analytics/routes/dashboard/stage.test.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/stage.test.tsx @@ -376,6 +376,8 @@ jest.mock( './components', () => ( { enabled ?
: null, OnboardingTour: () =>
, onboardingTourSteps: () => [], + PlanLimitNotice: ( { enabled }: { enabled: boolean } ) => + enabled ?
: null, // A marker, not the real notice, which reads a query cache these tests do not // stand up. Covered here: where the stage puts it. RefreshFailureNotice: () =>
, @@ -755,6 +757,15 @@ describe( 'Dashboard feedback banner', () => { expect( screen.queryByTestId( 'feedback-banner' ) ).not.toBeInTheDocument(); } ); + + it( 'puts the plan limit notice away while the reader arranges the layout', async () => { + render( ); + expect( screen.getByTestId( 'plan-limit-notice' ) ).toBeInTheDocument(); + + await userEvent.click( screen.getByRole( 'button', { name: 'Customize' } ) ); + + expect( screen.queryByTestId( 'plan-limit-notice' ) ).not.toBeInTheDocument(); + } ); } ); describe( 'Dashboard options menu', () => { diff --git a/projects/packages/premium-analytics/routes/dashboard/stage.tsx b/projects/packages/premium-analytics/routes/dashboard/stage.tsx index f55126839852..a25f8357d444 100644 --- a/projects/packages/premium-analytics/routes/dashboard/stage.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/stage.tsx @@ -38,6 +38,7 @@ import { FeedbackBanner, OnboardingTour, onboardingTourSteps, + PlanLimitNotice, RefreshFailureNotice, SectionSyncNotice, StatsSettingsPanel, @@ -423,6 +424,8 @@ function Dashboard(): JSX.Element { value={ section.slug } className={ styles.content } > + + Date: Fri, 9 Oct 2026 15:46:53 +0530 Subject: [PATCH 02/11] Premium Analytics: Track views, upgrade clicks and dismissals of the plan limit notice The view event fires once per page load and status, since every section tab mounts its own copy of the notice. Upgrade clicks are recorded here because the Stats purchase page only tracks the from values it knows. --- .../plan-limit-notice.test.tsx | 49 ++++++++++++++++++- .../plan-limit-notice/plan-limit-notice.tsx | 39 +++++++++++++-- .../routes/dashboard/package.json | 1 + 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx index 9eedeb9faacf..a49c7055d66b 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx @@ -1,6 +1,7 @@ /** * External dependencies */ +import analytics from '@automattic/jetpack-analytics'; import { queryClient } from '@jetpack-premium-analytics/data'; import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -8,11 +9,16 @@ import apiFetch from '@wordpress/api-fetch'; /** * Internal dependencies */ -import { PlanLimitNotice } from './plan-limit-notice'; +import { PlanLimitNotice, resetPlanLimitNoticeForTesting } from './plan-limit-notice'; +jest.mock( + '@automattic/jetpack-analytics', + () => jest.requireActual( '../../../../tests/js/analytics-test-utils' ).mockJetpackAnalytics +); jest.mock( '@wordpress/api-fetch', () => jest.fn() ); const mockApiFetch = apiFetch as unknown as jest.Mock; +const recordEvent = jest.mocked( analytics.tracks.recordEvent ); type Usage = { views_count: number; views_limit: number | null }; type Notices = { tier_upgrade?: boolean }; @@ -75,6 +81,8 @@ describe( 'PlanLimitNotice', () => { beforeEach( () => { jest.useFakeTimers(); queryClient.clear(); + resetPlanLimitNoticeForTesting(); + recordEvent.mockClear(); mockApiFetch.mockReset(); setHost( 'jetpack' ); } ); @@ -145,6 +153,45 @@ describe( 'PlanLimitNotice', () => { data: { id: 'tier_upgrade', status: 'postponed', postponed_for: 604800 }, } ) ); + expect( recordEvent ).toHaveBeenCalledWith( + 'jetpack_premium_analytics_plan_limit_notice_dismiss', + undefined + ); + } ); + + it( 'records one view per page load, though every section tab mounts the notice', async () => { + mockEndpoints( { views_count: 11480, views_limit: 10000 } ); + + const { unmount } = await renderNotice(); + unmount(); + await renderNotice(); + + const views = recordEvent.mock.calls.filter( + ( [ name ] ) => name === 'jetpack_premium_analytics_plan_limit_notice_view' + ); + expect( views ).toEqual( [ + [ 'jetpack_premium_analytics_plan_limit_notice_view', { status: 'over' } ], + ] ); + } ); + + it.each( [ + [ 'near', 9120 ], + [ 'over', 11480 ], + ] )( 'records an upgrade click from the %s notice', async ( status, views_count ) => { + mockEndpoints( { views_count, views_limit: 10000 } ); + const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } ); + await renderNotice(); + // jsdom cannot follow the link to another page. + const stopNavigation = ( event: Event ) => event.preventDefault(); + document.addEventListener( 'click', stopNavigation ); + + await user.click( screen.getByRole( 'link', { name: 'Upgrade plan' } ) ); + + document.removeEventListener( 'click', stopNavigation ); + expect( recordEvent ).toHaveBeenCalledWith( + 'jetpack_premium_analytics_plan_limit_notice_upgrade_click', + { status } + ); } ); it.each( [ 'wpcom', 'vip' ] )( 'says nothing on a %s site', async host => { diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx index cbee93fce157..cf83135b44af 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx @@ -12,7 +12,7 @@ import { import { Notice } from '@jetpack-premium-analytics/externals'; import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; import { statsUpgradeUrl, useTrackEvent } from '@jetpack-premium-analytics/widgets-toolkit'; -import { useCallback, useState } from '@wordpress/element'; +import { useCallback, useEffect, useMemo, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies @@ -32,6 +32,16 @@ type PlanLimitStatus = { limit: number; }; +// Once per page load, not per mount: every section tab mounts its own copy. +const viewedThisLoad = new Set< string >(); + +/** + * Reset the once-per-load view latch. Test-only. + */ +export function resetPlanLimitNoticeForTesting() { + viewedThisLoad.clear(); +} + type PlanLimitNoticeProps = { /** * Whether the surface the notice belongs to is ready; nothing shows until then. @@ -71,7 +81,10 @@ function ConnectedNotice( { enabled }: PlanLimitNoticeProps ): JSX.Element | nul const isEligible = enabled && ! isSimpleSite() && getScriptData()?.site?.host !== 'vip'; const { data: usage } = useStatsAppPlanUsage( { enabled: isEligible } ); - const status = isEligible ? getPlanLimitStatus( usage ) : null; + const status = useMemo( + () => ( isEligible ? getPlanLimitStatus( usage ) : null ), + [ isEligible, usage ] + ); // Only the near-limit notice can be put off; past the limit it stays for the cycle. const isNear = !! status && ! status.isOver; @@ -80,13 +93,31 @@ function ConnectedNotice( { enabled }: PlanLimitNoticeProps ): JSX.Element | nul const [ isPostponed, setIsPostponed ] = useState( false ); const trackEvent = useTrackEvent(); + const isHeld = isNear && ( ! notices || notices.tier_upgrade === false || isPostponed ); + const statusName = status?.isOver ? 'over' : 'near'; + + useEffect( () => { + if ( ! status || isHeld || viewedThisLoad.has( statusName ) ) { + return; + } + + viewedThisLoad.add( statusName ); + trackEvent( 'jetpack_premium_analytics_plan_limit_notice_view', { status: statusName } ); + }, [ status, isHeld, statusName, trackEvent ] ); + const postpone = useCallback( () => { setIsPostponed( true ); updateNotice( { id: 'tier_upgrade', status: 'postponed', postponed_for: POSTPONE_SECONDS } ); trackEvent( 'jetpack_premium_analytics_plan_limit_notice_dismiss' ); }, [ updateNotice, trackEvent ] ); - if ( ! status || ( isNear && ( ! notices || notices.tier_upgrade === false || isPostponed ) ) ) { + const recordUpgradeClick = useCallback( () => { + trackEvent( 'jetpack_premium_analytics_plan_limit_notice_upgrade_click', { + status: statusName, + } ); + }, [ trackEvent, statusName ] ); + + if ( ! status || isHeld ) { return null; } @@ -120,7 +151,7 @@ function ConnectedNotice( { enabled }: PlanLimitNoticeProps ): JSX.Element | nul { upgradeHref && ( - + { __( 'Upgrade plan', 'jetpack-premium-analytics-pkg' ) } diff --git a/projects/packages/premium-analytics/routes/dashboard/package.json b/projects/packages/premium-analytics/routes/dashboard/package.json index cbb77fca6327..7b26437fcc6f 100644 --- a/projects/packages/premium-analytics/routes/dashboard/package.json +++ b/projects/packages/premium-analytics/routes/dashboard/package.json @@ -32,6 +32,7 @@ "react": "18.3.1" }, "devDependencies": { + "@automattic/jetpack-analytics": "workspace:*", "@tanstack/react-query": "5.102.8", "@testing-library/react": "16.3.3", "@testing-library/user-event": "14.6.7" From 1e86469a492926c94ad1312dd06c9150cb0d78be Mon Sep 17 00:00:00 2001 From: Nikhil Date: Fri, 9 Oct 2026 16:13:23 +0530 Subject: [PATCH 03/11] Premium Analytics: Keep a dismissed plan limit notice dismissed across sections The dismissal now lands in the shared notices cache before the request finishes, so a section that mounts during the save reads it as dismissed. A failed save restores the cache and shows an error snackbar, so the notice is not left hidden although nothing was saved. The tests now sit on the exact 90% and 100% boundaries. --- .../data/src/hooks/use-stats-app-notices.ts | 27 ++++++- .../plan-limit-notice.test.tsx | 74 ++++++++++++++++--- .../plan-limit-notice/plan-limit-notice.tsx | 21 ++++-- 3 files changed, 105 insertions(+), 17 deletions(-) diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-stats-app-notices.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-stats-app-notices.ts index 87d94c2d4d6a..72578f88bd28 100644 --- a/projects/packages/premium-analytics/packages/data/src/hooks/use-stats-app-notices.ts +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-stats-app-notices.ts @@ -24,13 +24,36 @@ export function useStatsAppNotices( params?: StatsAppNoticesParams, options?: Us return useStatsAppQuery( statsAppNoticesQuery( params ), options ); } +const NOTICES_QUERY_KEY = [ 'stats-app', 'notices' ]; + +/** + * Saves a notice's visibility. The change lands in the cache before the request + * does, so every mounted copy of the notice hides at once; a failure restores it. + * + * @return The mutation. + */ export function useStatsAppNoticeMutation() { const queryClient = useQueryClient(); return useMutation( { mutationFn: ( data: StatsAppNoticeMutationParams ) => updateStatsAppNotice( data ), - onSuccess: () => { - queryClient.invalidateQueries( { queryKey: [ 'stats-app', 'notices' ] } ); + onMutate: async ( data: StatsAppNoticeMutationParams ) => { + await queryClient.cancelQueries( { queryKey: NOTICES_QUERY_KEY } ); + const previous = queryClient.getQueriesData< StatsAppNotices >( { + queryKey: NOTICES_QUERY_KEY, + } ); + queryClient.setQueriesData< StatsAppNotices >( { queryKey: NOTICES_QUERY_KEY }, notices => + notices ? { ...notices, [ data.id ]: false } : notices + ); + return { previous }; + }, + onError: ( _error, _data, context ) => { + context?.previous.forEach( ( [ queryKey, notices ] ) => + queryClient.setQueryData( queryKey, notices ) + ); + }, + onSettled: () => { + queryClient.invalidateQueries( { queryKey: NOTICES_QUERY_KEY } ); }, } ); } diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx index a49c7055d66b..8666d7fd3704 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx @@ -6,9 +6,11 @@ import { queryClient } from '@jetpack-premium-analytics/data'; import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import apiFetch from '@wordpress/api-fetch'; +import { RegistryProvider } from '@wordpress/data'; /** * Internal dependencies */ +import { createNoticesRegistry } from '../../../../tests/js/notice-test-utils'; import { PlanLimitNotice, resetPlanLimitNoticeForTesting } from './plan-limit-notice'; jest.mock( @@ -23,13 +25,20 @@ const recordEvent = jest.mocked( analytics.tracks.recordEvent ); type Usage = { views_count: number; views_limit: number | null }; type Notices = { tier_upgrade?: boolean }; +let notices = createNoticesRegistry(); + /** * Answers the usage and notices requests the notice makes. * - * @param usage - This cycle's views and the plan's limit. - * @param notices - The Stats notice visibility state. + * @param usage - This cycle's views and the plan's limit. + * @param state - The Stats notice visibility state. + * @param saveReply - The reply to saving a notice's visibility. */ -function mockEndpoints( usage: Usage, notices: Notices = {} ) { +function mockEndpoints( + usage: Usage, + state: Notices = {}, + saveReply: () => Promise< unknown > = () => Promise.resolve( { tier_upgrade: false } ) +) { mockApiFetch.mockImplementation( ( { path, method }: { path: string; method?: string } ) => { if ( path.includes( 'jetpack-stats/usage' ) ) { return Promise.resolve( { @@ -38,7 +47,7 @@ function mockEndpoints( usage: Usage, notices: Notices = {} ) { } ); } if ( path.includes( 'notices' ) ) { - return Promise.resolve( method === 'POST' ? { tier_upgrade: false } : notices ); + return method === 'POST' ? saveReply() : Promise.resolve( state ); } return Promise.reject( new Error( `Unexpected request: ${ path }` ) ); } ); @@ -65,7 +74,11 @@ function setHost( host: string ) { * @return The render result. */ async function renderNotice() { - const view = render( ); + const view = render( + + + + ); // The notices request waits on the usage reply, so settle both rounds. await act( () => jest.runAllTimersAsync() ); await act( () => jest.runAllTimersAsync() ); @@ -82,6 +95,7 @@ describe( 'PlanLimitNotice', () => { jest.useFakeTimers(); queryClient.clear(); resetPlanLimitNoticeForTesting(); + notices = createNoticesRegistry(); recordEvent.mockClear(); mockApiFetch.mockReset(); setHost( 'jetpack' ); @@ -92,12 +106,12 @@ describe( 'PlanLimitNotice', () => { } ); it( 'warns a site at 90% of its limit and links to the upgrade', async () => { - mockEndpoints( { views_count: 9120, views_limit: 10000 } ); + mockEndpoints( { views_count: 9000, views_limit: 10000 } ); const { container } = await renderNotice(); expect( container ).toHaveTextContent( 'You are approaching your plan’s view limit' ); - expect( container ).toHaveTextContent( '9,120 of 10,000 billable views used this cycle.' ); + expect( container ).toHaveTextContent( '9,000 of 10,000 billable views used this cycle.' ); expect( screen.getByRole( 'link', { name: 'Upgrade plan' } ) ).toHaveAttribute( 'href', expect.stringContaining( @@ -122,8 +136,8 @@ describe( 'PlanLimitNotice', () => { expect( container ).toBeEmptyDOMElement(); } ); - it( 'cannot be dismissed once the site is over its limit', async () => { - mockEndpoints( { views_count: 11480, views_limit: 10000 }, { tier_upgrade: false } ); + it( 'cannot be dismissed once the site reaches its limit', async () => { + mockEndpoints( { views_count: 10000, views_limit: 10000 }, { tier_upgrade: false } ); const { container } = await renderNotice(); @@ -159,6 +173,48 @@ describe( 'PlanLimitNotice', () => { ); } ); + it( 'stays dismissed in a section that mounts before the dismissal is saved', async () => { + let saveDismissal = () => {}; + mockEndpoints( + { views_count: 9120, views_limit: 10000 }, + {}, + () => new Promise( resolve => ( saveDismissal = () => resolve( { tier_upgrade: false } ) ) ) + ); + const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } ); + const { unmount } = await renderNotice(); + await user.click( screen.getByRole( 'button', { name: 'Dismiss' } ) ); + unmount(); + const view = render( + + + + ); + + expect( view.container ).toBeEmptyDOMElement(); + saveDismissal(); + } ); + + it( 'brings the notice back and says so when the dismissal fails', async () => { + mockEndpoints( { views_count: 9120, views_limit: 10000 }, {}, () => + Promise.reject( new Error( 'offline' ) ) + ); + const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } ); + const { container } = await renderNotice(); + // Offline: the refetch after the failure never answers either. + mockApiFetch.mockImplementation( ( { method }: { method?: string } ) => + method === 'POST' ? Promise.reject( new Error( 'offline' ) ) : new Promise( () => {} ) + ); + + await user.click( screen.getByRole( 'button', { name: 'Dismiss' } ) ); + await act( () => jest.runAllTimersAsync() ); + + expect( container ).toHaveTextContent( 'You are approaching your plan’s view limit' ); + expect( notices.createErrorNotice ).toHaveBeenCalledWith( + 'Couldn’t dismiss the notice. Please try again.', + { type: 'snackbar' } + ); + } ); + it( 'records one view per page load, though every section tab mounts the notice', async () => { mockEndpoints( { views_count: 11480, views_limit: 10000 } ); diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx index cf83135b44af..a893818aa966 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx @@ -12,7 +12,8 @@ import { import { Notice } from '@jetpack-premium-analytics/externals'; import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; import { statsUpgradeUrl, useTrackEvent } from '@jetpack-premium-analytics/widgets-toolkit'; -import { useCallback, useEffect, useMemo, useState } from '@wordpress/element'; +import { useDispatch } from '@wordpress/data'; +import { useCallback, useEffect, useMemo } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies @@ -90,10 +91,10 @@ function ConnectedNotice( { enabled }: PlanLimitNoticeProps ): JSX.Element | nul const isNear = !! status && ! status.isOver; const { data: notices } = useStatsAppNotices( undefined, { enabled: isNear } ); const { mutate: updateNotice } = useStatsAppNoticeMutation(); - const [ isPostponed, setIsPostponed ] = useState( false ); + const { createErrorNotice } = useDispatch( 'core/notices' ); const trackEvent = useTrackEvent(); - const isHeld = isNear && ( ! notices || notices.tier_upgrade === false || isPostponed ); + const isHeld = isNear && ( ! notices || notices.tier_upgrade === false ); const statusName = status?.isOver ? 'over' : 'near'; useEffect( () => { @@ -106,10 +107,18 @@ function ConnectedNotice( { enabled }: PlanLimitNoticeProps ): JSX.Element | nul }, [ status, isHeld, statusName, trackEvent ] ); const postpone = useCallback( () => { - setIsPostponed( true ); - updateNotice( { id: 'tier_upgrade', status: 'postponed', postponed_for: POSTPONE_SECONDS } ); + updateNotice( + { id: 'tier_upgrade', status: 'postponed', postponed_for: POSTPONE_SECONDS }, + { + onError: () => + createErrorNotice( + __( 'Couldn’t dismiss the notice. Please try again.', 'jetpack-premium-analytics-pkg' ), + { type: 'snackbar' } + ), + } + ); trackEvent( 'jetpack_premium_analytics_plan_limit_notice_dismiss' ); - }, [ updateNotice, trackEvent ] ); + }, [ updateNotice, createErrorNotice, trackEvent ] ); const recordUpgradeClick = useCallback( () => { trackEvent( 'jetpack_premium_analytics_plan_limit_notice_upgrade_click', { From bc431605face7c33954cad3918ef70ebc4b8c91e Mon Sep 17 00:00:00 2001 From: Nikhil Date: Fri, 9 Oct 2026 17:27:01 +0530 Subject: [PATCH 04/11] Premium Analytics: Show the plan limit upgrade action as a button --- .../plan-limit-notice/plan-limit-notice.test.tsx | 2 +- .../plan-limit-notice/plan-limit-notice.tsx | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx index 8666d7fd3704..830a4a09f6f2 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx @@ -105,7 +105,7 @@ describe( 'PlanLimitNotice', () => { jest.useRealTimers(); } ); - it( 'warns a site at 90% of its limit and links to the upgrade', async () => { + it( 'warns a site at 90% of its limit and offers the upgrade', async () => { mockEndpoints( { views_count: 9000, views_limit: 10000 } ); const { container } = await renderNotice(); diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx index a893818aa966..cb9640bb94e3 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx @@ -9,7 +9,7 @@ import { useStatsAppPlanUsage, type StatsAppPlanUsage, } from '@jetpack-premium-analytics/data'; -import { Notice } from '@jetpack-premium-analytics/externals'; +import { LinkButton, Notice } from '@jetpack-premium-analytics/externals'; import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; import { statsUpgradeUrl, useTrackEvent } from '@jetpack-premium-analytics/widgets-toolkit'; import { useDispatch } from '@wordpress/data'; @@ -160,9 +160,15 @@ function ConnectedNotice( { enabled }: PlanLimitNoticeProps ): JSX.Element | nul { upgradeHref && ( - + { __( 'Upgrade plan', 'jetpack-premium-analytics-pkg' ) } - + ) } From 33978745bff46b2562a12808189be3d0926dc434 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Fri, 9 Oct 2026 17:37:58 +0530 Subject: [PATCH 05/11] Premium Analytics: Show the plan limit upgrade action as a link again --- .../plan-limit-notice/plan-limit-notice.test.tsx | 2 +- .../plan-limit-notice/plan-limit-notice.tsx | 12 +++--------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx index 830a4a09f6f2..8666d7fd3704 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx @@ -105,7 +105,7 @@ describe( 'PlanLimitNotice', () => { jest.useRealTimers(); } ); - it( 'warns a site at 90% of its limit and offers the upgrade', async () => { + it( 'warns a site at 90% of its limit and links to the upgrade', async () => { mockEndpoints( { views_count: 9000, views_limit: 10000 } ); const { container } = await renderNotice(); diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx index cb9640bb94e3..a893818aa966 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx @@ -9,7 +9,7 @@ import { useStatsAppPlanUsage, type StatsAppPlanUsage, } from '@jetpack-premium-analytics/data'; -import { LinkButton, Notice } from '@jetpack-premium-analytics/externals'; +import { Notice } from '@jetpack-premium-analytics/externals'; import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; import { statsUpgradeUrl, useTrackEvent } from '@jetpack-premium-analytics/widgets-toolkit'; import { useDispatch } from '@wordpress/data'; @@ -160,15 +160,9 @@ function ConnectedNotice( { enabled }: PlanLimitNoticeProps ): JSX.Element | nul { upgradeHref && ( - + { __( 'Upgrade plan', 'jetpack-premium-analytics-pkg' ) } - + ) } From a069dff9eb5b06cb9c5770d345bc6d4353d8ee80 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Fri, 9 Oct 2026 17:44:47 +0530 Subject: [PATCH 06/11] Premium Analytics: Show the plan limit notice with the other dashboard notices --- .../plan-limit-notice/plan-limit-notice.module.scss | 3 +-- .../packages/premium-analytics/routes/dashboard/stage.tsx | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.module.scss b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.module.scss index c55a9837f321..9555a3c84048 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.module.scss +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.module.scss @@ -1,4 +1,3 @@ .notice { - margin-block-start: var(--wpds-dimension-gap-lg); - margin-inline: var(--wpds-dimension-padding-2xl); + margin-block-end: var(--wpds-dimension-gap-lg); } diff --git a/projects/packages/premium-analytics/routes/dashboard/stage.tsx b/projects/packages/premium-analytics/routes/dashboard/stage.tsx index a25f8357d444..5f8511507e12 100644 --- a/projects/packages/premium-analytics/routes/dashboard/stage.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/stage.tsx @@ -424,8 +424,6 @@ function Dashboard(): JSX.Element { value={ section.slug } className={ styles.content } > - - + + { /* Behind the onboarding journey: it introduces the tabs the banner asks about. */ } Date: Tue, 13 Oct 2026 12:40:31 +0530 Subject: [PATCH 07/11] Premium Analytics: Share the plan upgrade link with the Settings card The notice builds its link with getPlanUpgradeUrl(), which now takes the from value, so widgets-toolkit no longer exports a second builder. The dismissal error is reported from the mutation hook's own onError, so it still shows when the reader switches section before the request fails. The stage renders the notice only outside customize mode, which replaces the enabled prop. --- .../data/src/hooks/use-stats-app-notices.ts | 5 +- .../widgets-toolkit/src/helpers/index.ts | 1 - .../src/helpers/stats-upgrade-url.ts | 25 --------- .../packages/widgets-toolkit/src/index.ts | 1 - .../plan-limit-notice.test.tsx | 28 +++++++++- .../plan-limit-notice/plan-limit-notice.tsx | 52 +++++++------------ .../components/stats-settings/plan-usage.ts | 5 +- .../routes/dashboard/stage.test.tsx | 3 +- .../routes/dashboard/stage.tsx | 2 +- 9 files changed, 54 insertions(+), 68 deletions(-) delete mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/stats-upgrade-url.ts diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-stats-app-notices.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-stats-app-notices.ts index 72578f88bd28..d8a31146eff6 100644 --- a/projects/packages/premium-analytics/packages/data/src/hooks/use-stats-app-notices.ts +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-stats-app-notices.ts @@ -30,9 +30,11 @@ const NOTICES_QUERY_KEY = [ 'stats-app', 'notices' ]; * Saves a notice's visibility. The change lands in the cache before the request * does, so every mounted copy of the notice hides at once; a failure restores it. * + * @param options - Mutation options. + * @param options.onError - Runs after a failed save, even when the caller has since unmounted. * @return The mutation. */ -export function useStatsAppNoticeMutation() { +export function useStatsAppNoticeMutation( { onError }: { onError?: () => void } = {} ) { const queryClient = useQueryClient(); return useMutation( { @@ -51,6 +53,7 @@ export function useStatsAppNoticeMutation() { context?.previous.forEach( ( [ queryKey, notices ] ) => queryClient.setQueryData( queryKey, notices ) ); + onError?.(); }, onSettled: () => { queryClient.invalidateQueries( { queryKey: NOTICES_QUERY_KEY } ); diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts index 99de0e4c2db2..6c65d7391580 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts @@ -21,7 +21,6 @@ export { type Region, } from './build-visitors-by-location-data'; export { flagUrl } from './flag-url'; -export { statsUpgradeUrl } from './stats-upgrade-url'; export { isEmptyChartData, isEmptyPieChartData, getEmptyChartDomain } from './chart-empty-state'; export { getFixedYAxis, diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/stats-upgrade-url.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/stats-upgrade-url.ts deleted file mode 100644 index 7573cce2a1c6..000000000000 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/stats-upgrade-url.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * External dependencies - */ -import { getScriptData } from '@automattic/jetpack-script-data'; - -/** - * The Stats tier-upgrade purchase screen for this site, returning to this - * dashboard after checkout. `undefined` where script data is absent (e.g. - * Storybook without a seeded `window.JetpackScriptData`). - * - * @param from - Where the purchase started, reported to the checkout as `from`. - * @return The purchase URL, or `undefined`. - */ -export function statsUpgradeUrl( from: string ): string | undefined { - const site = getScriptData()?.site; - const blogId = site?.wpcom?.blog_id; - if ( ! site?.admin_url || ! blogId ) { - return undefined; - } - - const backTo = encodeURIComponent( 'admin.php?page=jetpack-premium-analytics-wp-admin' ); - return `${ site.admin_url }admin.php?page=stats#!/stats/purchase/${ blogId }?from=${ encodeURIComponent( - from - ) }&productType=commercial&redirect_uri=${ backTo }`; -} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts index 63517406d6b5..679a5c2176b5 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts @@ -283,7 +283,6 @@ export type { ReportParamsFieldAttributes } from './fields'; export { calculateDelta, flagUrl, - statsUpgradeUrl, BOOKINGS_FILTER, formatLegendLabels, formatDisplayLabel, diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx index 8666d7fd3704..baf943e6a1ef 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx @@ -76,7 +76,7 @@ function setHost( host: string ) { async function renderNotice() { const view = render( - + ); // The notices request waits on the usage reply, so settle both rounds. @@ -186,7 +186,7 @@ describe( 'PlanLimitNotice', () => { unmount(); const view = render( - + ); @@ -215,6 +215,30 @@ describe( 'PlanLimitNotice', () => { ); } ); + it( 'reports a failed dismissal after the reader has left the section', async () => { + let failDismissal = () => {}; + mockEndpoints( + { views_count: 9120, views_limit: 10000 }, + {}, + () => + new Promise( + ( _resolve, reject ) => ( failDismissal = () => reject( new Error( 'offline' ) ) ) + ) + ); + const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } ); + const { unmount } = await renderNotice(); + await user.click( screen.getByRole( 'button', { name: 'Dismiss' } ) ); + unmount(); + + failDismissal(); + await act( () => jest.runAllTimersAsync() ); + + expect( notices.createErrorNotice ).toHaveBeenCalledWith( + 'Couldn’t dismiss the notice. Please try again.', + { type: 'snackbar' } + ); + } ); + it( 'records one view per page load, though every section tab mounts the notice', async () => { mockEndpoints( { views_count: 11480, views_limit: 10000 } ); diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx index a893818aa966..bc80e0725ebd 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx @@ -11,13 +11,14 @@ import { } from '@jetpack-premium-analytics/data'; import { Notice } from '@jetpack-premium-analytics/externals'; import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; -import { statsUpgradeUrl, useTrackEvent } from '@jetpack-premium-analytics/widgets-toolkit'; +import { useTrackEvent } from '@jetpack-premium-analytics/widgets-toolkit'; import { useDispatch } from '@wordpress/data'; -import { useCallback, useEffect, useMemo } from '@wordpress/element'; +import { useCallback, useEffect } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ +import { getPlanUpgradeUrl } from '../stats-settings/plan-usage'; import styles from './plan-limit-notice.module.scss'; import type { JSX } from 'react'; @@ -43,19 +44,12 @@ export function resetPlanLimitNoticeForTesting() { viewedThisLoad.clear(); } -type PlanLimitNoticeProps = { - /** - * Whether the surface the notice belongs to is ready; nothing shows until then. - */ - enabled: boolean; -}; - /** * Where this cycle's views stand against the plan's limit. A missing or zero * limit means there is nothing to meter against. * * @param usage - The plan usage report. - * @return The status, or `null` while the site is clear of the limit. + * @return The status once usage reaches 90% of the limit, otherwise `null`. */ function getPlanLimitStatus( usage?: StatsAppPlanUsage ): PlanLimitStatus | null { const limit = usage?.views_limit; @@ -74,24 +68,26 @@ function getPlanLimitStatus( usage?: StatsAppPlanUsage ): PlanLimitStatus | null /** * Reads the plan usage and renders the notice when the site nears or passes its limit. * - * @param {PlanLimitNoticeProps} props - Component props. * @return The notice, or nothing. */ -function ConnectedNotice( { enabled }: PlanLimitNoticeProps ): JSX.Element | null { +function ConnectedNotice(): JSX.Element | null { // Stats shows this notice on Jetpack-connected sites only, and VIP sites are exempt from the limit. - const isEligible = enabled && ! isSimpleSite() && getScriptData()?.site?.host !== 'vip'; + const isEligible = ! isSimpleSite() && getScriptData()?.site?.host !== 'vip'; const { data: usage } = useStatsAppPlanUsage( { enabled: isEligible } ); - const status = useMemo( - () => ( isEligible ? getPlanLimitStatus( usage ) : null ), - [ isEligible, usage ] - ); + const status = isEligible ? getPlanLimitStatus( usage ) : null; // Only the near-limit notice can be put off; past the limit it stays for the cycle. const isNear = !! status && ! status.isOver; const { data: notices } = useStatsAppNotices( undefined, { enabled: isNear } ); - const { mutate: updateNotice } = useStatsAppNoticeMutation(); const { createErrorNotice } = useDispatch( 'core/notices' ); + const { mutate: updateNotice } = useStatsAppNoticeMutation( { + onError: () => + createErrorNotice( + __( 'Couldn’t dismiss the notice. Please try again.', 'jetpack-premium-analytics-pkg' ), + { type: 'snackbar' } + ), + } ); const trackEvent = useTrackEvent(); const isHeld = isNear && ( ! notices || notices.tier_upgrade === false ); @@ -107,18 +103,9 @@ function ConnectedNotice( { enabled }: PlanLimitNoticeProps ): JSX.Element | nul }, [ status, isHeld, statusName, trackEvent ] ); const postpone = useCallback( () => { - updateNotice( - { id: 'tier_upgrade', status: 'postponed', postponed_for: POSTPONE_SECONDS }, - { - onError: () => - createErrorNotice( - __( 'Couldn’t dismiss the notice. Please try again.', 'jetpack-premium-analytics-pkg' ), - { type: 'snackbar' } - ), - } - ); + updateNotice( { id: 'tier_upgrade', status: 'postponed', postponed_for: POSTPONE_SECONDS } ); trackEvent( 'jetpack_premium_analytics_plan_limit_notice_dismiss' ); - }, [ updateNotice, createErrorNotice, trackEvent ] ); + }, [ updateNotice, trackEvent ] ); const recordUpgradeClick = useCallback( () => { trackEvent( 'jetpack_premium_analytics_plan_limit_notice_upgrade_click', { @@ -130,7 +117,7 @@ function ConnectedNotice( { enabled }: PlanLimitNoticeProps ): JSX.Element | nul return null; } - const upgradeHref = statsUpgradeUrl( 'jetpack-premium-analytics-plan-limit-notice' ); + const upgradeHref = getPlanUpgradeUrl( 'jetpack-premium-analytics-plan-limit-notice' ); const usageSentence = sprintf( /* translators: 1: views used in the current billing cycle, 2: the plan's views limit. */ @@ -181,13 +168,12 @@ function ConnectedNotice( { enabled }: PlanLimitNoticeProps ): JSX.Element | nul * this billing cycle, and offers the upgrade. Brings its own query provider, * since the dashboard stage has none. * - * @param {PlanLimitNoticeProps} props - Component props. * @return The notice, or nothing. */ -export function PlanLimitNotice( { enabled }: PlanLimitNoticeProps ): JSX.Element { +export function PlanLimitNotice(): JSX.Element { return ( - + ); } diff --git a/projects/packages/premium-analytics/routes/dashboard/components/stats-settings/plan-usage.ts b/projects/packages/premium-analytics/routes/dashboard/components/stats-settings/plan-usage.ts index ee7862cf7d31..b702a53f79f6 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/stats-settings/plan-usage.ts +++ b/projects/packages/premium-analytics/routes/dashboard/components/stats-settings/plan-usage.ts @@ -29,9 +29,10 @@ export function getOverLimitMessage( * The Stats tier-upgrade purchase screen for this site, the same flow the Stats "Plan usage" * section links to, returning to this dashboard after checkout. * + * @param from - Where the purchase started, reported to the checkout as `from`. * @return The URL, or `undefined` where script data is absent (e.g. Storybook without a seeded `window.JetpackScriptData`). */ -export function getPlanUpgradeUrl(): string | undefined { +export function getPlanUpgradeUrl( from = 'jetpack-premium-analytics' ): string | undefined { const site = getScriptData()?.site; const blogId = site?.wpcom?.blog_id; if ( ! site?.admin_url || ! blogId ) { @@ -39,5 +40,5 @@ export function getPlanUpgradeUrl(): string | undefined { } const backTo = encodeURIComponent( 'admin.php?page=jetpack-premium-analytics-wp-admin' ); - return `${ site.admin_url }admin.php?page=stats#!/stats/purchase/${ blogId }?from=jetpack-premium-analytics&productType=commercial&redirect_uri=${ backTo }`; + return `${ site.admin_url }admin.php?page=stats#!/stats/purchase/${ blogId }?from=${ from }&productType=commercial&redirect_uri=${ backTo }`; } diff --git a/projects/packages/premium-analytics/routes/dashboard/stage.test.tsx b/projects/packages/premium-analytics/routes/dashboard/stage.test.tsx index 3d5e8010686f..80cf3effd1e9 100644 --- a/projects/packages/premium-analytics/routes/dashboard/stage.test.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/stage.test.tsx @@ -376,8 +376,7 @@ jest.mock( './components', () => ( { enabled ?
: null, OnboardingTour: () =>
, onboardingTourSteps: () => [], - PlanLimitNotice: ( { enabled }: { enabled: boolean } ) => - enabled ?
: null, + PlanLimitNotice: () =>
, // A marker, not the real notice, which reads a query cache these tests do not // stand up. Covered here: where the stage puts it. RefreshFailureNotice: () =>
, diff --git a/projects/packages/premium-analytics/routes/dashboard/stage.tsx b/projects/packages/premium-analytics/routes/dashboard/stage.tsx index 5f8511507e12..2c741aa5cbc9 100644 --- a/projects/packages/premium-analytics/routes/dashboard/stage.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/stage.tsx @@ -436,7 +436,7 @@ function Dashboard(): JSX.Element { { activeSection === section.slug ? (
- + { ! editMode && } { /* Behind the onboarding journey: it introduces the tabs the banner asks about. */ } From 8a161978c3ec8f0bd5aaaa33eb197d824874e853 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Tue, 13 Oct 2026 13:20:12 +0530 Subject: [PATCH 08/11] Premium Analytics: Use the Stats wording in the plan limit notice Views past the limit are still counted, so the notice no longer says they are not. It carries the wording of the Stats tier upgrade notice instead, and drops the usage sentence. --- .../plan-limit-notice.test.tsx | 11 ++++---- .../plan-limit-notice/plan-limit-notice.tsx | 28 ++++++------------- 2 files changed, 14 insertions(+), 25 deletions(-) diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx index baf943e6a1ef..cb8cef5a6124 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx @@ -110,9 +110,8 @@ describe( 'PlanLimitNotice', () => { const { container } = await renderNotice(); - expect( container ).toHaveTextContent( 'You are approaching your plan’s view limit' ); - expect( container ).toHaveTextContent( '9,000 of 10,000 billable views used this cycle.' ); - expect( screen.getByRole( 'link', { name: 'Upgrade plan' } ) ).toHaveAttribute( + expect( container ).toHaveTextContent( 'You are nearing your monthly limit' ); + expect( screen.getByRole( 'link', { name: 'Upgrade now' } ) ).toHaveAttribute( 'href', expect.stringContaining( 'admin.php?page=stats#!/stats/purchase/123456789?from=jetpack-premium-analytics-plan-limit-notice' @@ -141,7 +140,7 @@ describe( 'PlanLimitNotice', () => { const { container } = await renderNotice(); - expect( container ).toHaveTextContent( 'You have gone over your plan’s view limit' ); + expect( container ).toHaveTextContent( 'You have reached your views limit' ); expect( screen.queryByRole( 'button', { name: 'Dismiss' } ) ).not.toBeInTheDocument(); } ); @@ -208,7 +207,7 @@ describe( 'PlanLimitNotice', () => { await user.click( screen.getByRole( 'button', { name: 'Dismiss' } ) ); await act( () => jest.runAllTimersAsync() ); - expect( container ).toHaveTextContent( 'You are approaching your plan’s view limit' ); + expect( container ).toHaveTextContent( 'You are nearing your monthly limit' ); expect( notices.createErrorNotice ).toHaveBeenCalledWith( 'Couldn’t dismiss the notice. Please try again.', { type: 'snackbar' } @@ -265,7 +264,7 @@ describe( 'PlanLimitNotice', () => { const stopNavigation = ( event: Event ) => event.preventDefault(); document.addEventListener( 'click', stopNavigation ); - await user.click( screen.getByRole( 'link', { name: 'Upgrade plan' } ) ); + await user.click( screen.getByRole( 'link', { name: 'Upgrade now' } ) ); document.removeEventListener( 'click', stopNavigation ); expect( recordEvent ).toHaveBeenCalledWith( diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx index bc80e0725ebd..945c3899a2a2 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx @@ -10,11 +10,10 @@ import { type StatsAppPlanUsage, } from '@jetpack-premium-analytics/data'; import { Notice } from '@jetpack-premium-analytics/externals'; -import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; import { useTrackEvent } from '@jetpack-premium-analytics/widgets-toolkit'; import { useDispatch } from '@wordpress/data'; import { useCallback, useEffect } from '@wordpress/element'; -import { __, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ @@ -30,8 +29,6 @@ const POSTPONE_SECONDS = 7 * 24 * 60 * 60; type PlanLimitStatus = { isOver: boolean; - used: number; - limit: number; }; // Once per page load, not per mount: every section tab mounts its own copy. @@ -62,7 +59,7 @@ function getPlanLimitStatus( usage?: StatsAppPlanUsage ): PlanLimitStatus | null return null; } - return { isOver: used >= limit, used, limit }; + return { isOver: used >= limit }; } /** @@ -119,36 +116,29 @@ function ConnectedNotice(): JSX.Element | null { const upgradeHref = getPlanUpgradeUrl( 'jetpack-premium-analytics-plan-limit-notice' ); - const usageSentence = sprintf( - /* translators: 1: views used in the current billing cycle, 2: the plan's views limit. */ - __( '%1$s of %2$s billable views used this cycle.', 'jetpack-premium-analytics-pkg' ), - formatMetricValue( status.used, 'number', { decimals: 0 } ), - formatMetricValue( status.limit, 'number', { decimals: 0 } ) - ); - const { isOver } = status; const title = isOver - ? __( 'You have gone over your plan’s view limit', 'jetpack-premium-analytics-pkg' ) - : __( 'You are approaching your plan’s view limit', 'jetpack-premium-analytics-pkg' ); - const consequence = isOver + ? __( 'You have reached your views limit', 'jetpack-premium-analytics-pkg' ) + : __( 'You are nearing your monthly limit', 'jetpack-premium-analytics-pkg' ); + const description = isOver ? __( - 'Views past the limit are not being counted, so these totals are short until the cycle restarts.', + 'Your access to the traffic dashboard may be disrupted in the future without an upgrade.', 'jetpack-premium-analytics-pkg' ) : __( - 'Views past the limit will not be counted until it restarts.', + 'Your site is receiving more attention and is close to the monthly view limit provided by your current plan. Consider increasing your tier limit to avoid potential service disruptions.', 'jetpack-premium-analytics-pkg' ); return ( { title } - { `${ usageSentence } ${ consequence }` } + { description } { upgradeHref && ( - { __( 'Upgrade plan', 'jetpack-premium-analytics-pkg' ) } + { __( 'Upgrade now', 'jetpack-premium-analytics-pkg' ) } ) } From 797423971ea7b8da280f65c1d74e961f94ad5f80 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Tue, 13 Oct 2026 13:20:39 +0530 Subject: [PATCH 09/11] Premium Analytics: Answer the plan usage request in the e2e helper The plan limit notice reads plan usage on every dashboard load, and the helper blocked that request, so the proxy answered 500 and the specs failed. Fixtures now sit in one table of API path to builder, and usage answers a plan with no views limit. --- .../e2e-premium-analytics-helper.php | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/projects/plugins/premium-analytics/tests/e2e/plugins/e2e-premium-analytics-helper/e2e-premium-analytics-helper.php b/projects/plugins/premium-analytics/tests/e2e/plugins/e2e-premium-analytics-helper/e2e-premium-analytics-helper.php index 3e5837258dff..37cc9f1711c5 100644 --- a/projects/plugins/premium-analytics/tests/e2e/plugins/e2e-premium-analytics-helper/e2e-premium-analytics-helper.php +++ b/projects/plugins/premium-analytics/tests/e2e/plugins/e2e-premium-analytics-helper/e2e-premium-analytics-helper.php @@ -33,7 +33,7 @@ function e2e_pa_fake_connection() { add_filter( 'pre_http_request', 'e2e_pa_intercept_wpcom', 10, 3 ); /** - * Answers the Stats requests the Premium Analytics proxy makes and blocks every other WordPress.com request. + * Answers the WordPress.com requests the Premium Analytics proxy makes and blocks every other one. * * @param false|array|WP_Error $preempt Earlier short-circuit, if any. * @param array $args Request arguments. @@ -46,18 +46,15 @@ function e2e_pa_intercept_wpcom( $preempt, $args, $url ) { return $preempt; } + $query = array(); + wp_parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query ); + $body = 'public-api.wordpress.com' === $host ? e2e_pa_fixture( (string) wp_parse_url( $url, PHP_URL_PATH ), $query ) : null; + // The tokens are fake and the site ID may be a real blog's, so nothing reaches WordPress.com. - $prefix = '/rest/v1.1/sites/' . E2E_PA_SITE_ID . '/stats/'; - $path = (string) wp_parse_url( $url, PHP_URL_PATH ); - if ( 'public-api.wordpress.com' !== $host || 0 !== strpos( $path, $prefix ) ) { + if ( null === $body ) { return new WP_Error( 'e2e_blocked', 'The E2E helper blocks WordPress.com requests it has no fixture for.' ); } - $query = array(); - wp_parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query ); - $route = substr( $path, strlen( $prefix ) ); - $body = 'visits' === $route ? e2e_pa_visits( $query ) : new stdClass(); - return array( 'headers' => array( 'content-type' => 'application/json' ), 'body' => wp_json_encode( $body, JSON_UNESCAPED_SLASHES ), @@ -70,6 +67,40 @@ function e2e_pa_intercept_wpcom( $preempt, $args, $url ) { ); } +/** + * Finds the fixture for one WordPress.com API path. A new endpoint is one row here and one builder below. + * + * @param string $path Request path. + * @param array $query Query args of the request. + * @return array|object|null The reply body, or null when the path has no fixture. + */ +function e2e_pa_fixture( $path, $query ) { + $stats = '/rest/v1.1/sites/' . E2E_PA_SITE_ID . '/stats/'; + $fixtures = array( + $stats . 'visits' => 'e2e_pa_visits', + '/wpcom/v2/sites/' . E2E_PA_SITE_ID . '/jetpack-stats/usage' => 'e2e_pa_plan_usage', + ); + + if ( isset( $fixtures[ $path ] ) ) { + return call_user_func( $fixtures[ $path ], $query ); + } + + // Every other Stats report answers empty. + return 0 === strpos( $path, $stats ) ? new stdClass() : null; +} + +/** + * Builds the usage of a plan with no views limit, so the plan limit notice stays away. + * + * @return array + */ +function e2e_pa_plan_usage() { + return array( + 'current_usage' => array( 'views_count' => 0 ), + 'views_limit' => null, + ); +} + /** * Builds a daily visits series for the requested range. * From b050ecbaf852f356aa1f332226bba5858b6152cd Mon Sep 17 00:00:00 2001 From: Nikhil Date: Tue, 13 Oct 2026 13:29:32 +0530 Subject: [PATCH 10/11] Premium Analytics: Call each e2e fixture builder with its own arguments The fixture table passed the query to every builder through call_user_func(), and Phan rejects that for the plan usage builder, which takes none. A switch on the path calls each builder directly. --- .../e2e-premium-analytics-helper.php | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/projects/plugins/premium-analytics/tests/e2e/plugins/e2e-premium-analytics-helper/e2e-premium-analytics-helper.php b/projects/plugins/premium-analytics/tests/e2e/plugins/e2e-premium-analytics-helper/e2e-premium-analytics-helper.php index 37cc9f1711c5..654bae357007 100644 --- a/projects/plugins/premium-analytics/tests/e2e/plugins/e2e-premium-analytics-helper/e2e-premium-analytics-helper.php +++ b/projects/plugins/premium-analytics/tests/e2e/plugins/e2e-premium-analytics-helper/e2e-premium-analytics-helper.php @@ -68,21 +68,20 @@ function e2e_pa_intercept_wpcom( $preempt, $args, $url ) { } /** - * Finds the fixture for one WordPress.com API path. A new endpoint is one row here and one builder below. + * Finds the fixture for one WordPress.com API path. A new endpoint is one case here and one builder below. * * @param string $path Request path. * @param array $query Query args of the request. * @return array|object|null The reply body, or null when the path has no fixture. */ function e2e_pa_fixture( $path, $query ) { - $stats = '/rest/v1.1/sites/' . E2E_PA_SITE_ID . '/stats/'; - $fixtures = array( - $stats . 'visits' => 'e2e_pa_visits', - '/wpcom/v2/sites/' . E2E_PA_SITE_ID . '/jetpack-stats/usage' => 'e2e_pa_plan_usage', - ); + $stats = '/rest/v1.1/sites/' . E2E_PA_SITE_ID . '/stats/'; - if ( isset( $fixtures[ $path ] ) ) { - return call_user_func( $fixtures[ $path ], $query ); + switch ( $path ) { + case $stats . 'visits': + return e2e_pa_visits( $query ); + case '/wpcom/v2/sites/' . E2E_PA_SITE_ID . '/jetpack-stats/usage': + return e2e_pa_plan_usage(); } // Every other Stats report answers empty. From 59f01948ec4d9ebfe64c76f10fb0ece2f255b2dd Mon Sep 17 00:00:00 2001 From: Nikhil Date: Wed, 14 Oct 2026 10:20:34 +0530 Subject: [PATCH 11/11] Premium Analytics: Say why the over-limit notice ignores the Stats postponement Past the limit the notice stays for the cycle, as the design asks, so it does not read the tier_upgrade postponement that Stats v1 can set from either state. The comment now says so, since the two dashboards can disagree for up to a week. --- .../components/plan-limit-notice/plan-limit-notice.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx index 945c3899a2a2..b0f990b1f5b4 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.tsx @@ -74,7 +74,8 @@ function ConnectedNotice(): JSX.Element | null { const { data: usage } = useStatsAppPlanUsage( { enabled: isEligible } ); const status = isEligible ? getPlanLimitStatus( usage ) : null; - // Only the near-limit notice can be put off; past the limit it stays for the cycle. + // Only the near-limit notice can be put off; past the limit it stays for the cycle, even while + // Stats has `tier_upgrade` postponed. const isNear = !! status && ! status.isOver; const { data: notices } = useStatsAppNotices( undefined, { enabled: isNear } ); const { createErrorNotice } = useDispatch( 'core/notices' );