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..b8c5b8ab56c7 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, @@ -31,6 +35,11 @@ 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: [ // Set the highest priority to prevent blocking Stats under any circumstances. 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-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/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..4eaaf218d12a --- /dev/null +++ b/client/my-sites/stats/pricing-grid/pricing-grid.tsx @@ -0,0 +1,321 @@ +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 { 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 { 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 useDismissPricingGrid, { PRICING_GRID_REFERRER } from './hooks/use-dismiss-pricing-grid'; +import './style.scss'; + +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 dismissPricingGrid = useDismissPricingGrid( siteId ); + + 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' ) ); + + // Starting for free is a plan choice: record the dismissal and reveal the dashboard. + const startForFree = () => { + dismissPricingGrid(); + onDismiss?.(); + }; + + // 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 = () => { + page( `/stats/purchase/${ siteSlug }?from=${ PRICING_GRID_REFERRER }` ); + }; + + 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; + } +} 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 18df1638baba..33e06bde1be9 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,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 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,12 +86,20 @@ const PersonalPurchase = ( { } ); }; + const dismissPricingGrid = useDismissPricingGrid( siteId ); + const handleCheckoutPostponed = () => { const event_from = isOdysseyStats ? 'jetpack_odyssey' : 'calypso'; recordTracksEvent( `${ event_from }_stats_purchase_flow_skip_button_clicked`, { blog_id: siteId, } ); + // 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( () => { 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 37101d29d647..3fb10567cea5 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,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 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,12 +280,20 @@ 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`, { blog_id: siteId, } ); + // 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 }` ); }, 250 );