diff --git a/apps/odyssey-stats/src/styles/wp-admin.scss b/apps/odyssey-stats/src/styles/wp-admin.scss index 69a69f92f12214..690dd1b2d3c03e 100644 --- a/apps/odyssey-stats/src/styles/wp-admin.scss +++ b/apps/odyssey-stats/src/styles/wp-admin.scss @@ -59,7 +59,8 @@ & .is-section-stats .has-fixed-nav { padding-top: 64px; } - & .highlight-cards.has-background-color { + & .highlight-cards.has-background-color, + & .inner-notice-container.has-background-color { background-color: var(--jetpack-white-off); } #wpcontent { diff --git a/client/lib/wpcom-xhr-wrapper/index.js b/client/lib/wpcom-xhr-wrapper/index.js index ddf55c6a10cae2..6be8b2098a74ab 100644 --- a/client/lib/wpcom-xhr-wrapper/index.js +++ b/client/lib/wpcom-xhr-wrapper/index.js @@ -27,7 +27,8 @@ export async function jetpack_site_xhr_wrapper( params, callback ) { 'X-WP-Nonce': config( 'nonce' ), }, isRestAPI: false, - apiNamespace: 'jetpack/v4/stats-app', + apiNamespace: + params.apiNamespace === 'jetpack/v4' ? params.apiNamespace : 'jetpack/v4/stats-app', }; return xhr( params, async function ( error, response, headers ) { diff --git a/client/my-sites/stats/site.jsx b/client/my-sites/stats/site.jsx index d1ca56ed056d33..c2309160b75f32 100644 --- a/client/my-sites/stats/site.jsx +++ b/client/my-sites/stats/site.jsx @@ -1,6 +1,7 @@ import config from '@automattic/calypso-config'; import { getUrlParts } from '@automattic/calypso-url'; import { eye } from '@automattic/components/src/icons'; +import NoticeBanner from '@automattic/components/src/notice-banner'; import { Icon, people, starEmpty, commentContent } from '@wordpress/icons'; import classNames from 'classnames'; import { localize, translate } from 'i18n-calypso'; @@ -31,10 +32,12 @@ import { withAnalytics, } from 'calypso/state/analytics/actions'; import { activateModule } from 'calypso/state/jetpack/modules/actions'; +import { dismissJITMDirect } from 'calypso/state/jitm/actions'; import getCurrentRouteParameterized from 'calypso/state/selectors/get-current-route-parameterized'; import isJetpackModuleActive from 'calypso/state/selectors/is-jetpack-module-active'; import isPrivateSite from 'calypso/state/selectors/is-private-site'; import { isJetpackSite } from 'calypso/state/sites/selectors'; +import hasOptOutNewStatsNotice from 'calypso/state/sites/selectors/has-opt-out-new-stats-notice'; import { getSelectedSiteId, getSelectedSiteSlug } from 'calypso/state/ui/selectors'; import HighlightsSection from './highlights-section'; import MiniCarousel from './mini-carousel'; @@ -117,6 +120,7 @@ class StatsSite extends Component { state = { activeTab: null, activeLegend: null, + isOptOutNoticeDismissed: false, }; static getDerivedStateFromProps( props, state ) { @@ -157,7 +161,16 @@ class StatsSite extends Component { }; renderStats() { - const { date, siteId, slug, isJetpack, isSitePrivate, isOdysseyStats, context } = this.props; + const { + date, + siteId, + slug, + isJetpack, + isSitePrivate, + isOdysseyStats, + context, + showOptOutNotice, + } = this.props; const queryDate = date.format( 'YYYY-MM-DD' ); const { period, endOf } = this.props.period; @@ -179,6 +192,11 @@ class StatsSite extends Component { 'is-period-year': period === 'year', } ); + const dismissOptOutNotice = () => { + this.setState( { isOptOutNoticeDismissed: true } ); + context.store.dispatch( dismissJITMDirect( 'opt-out-new-stats', 'opt-out-new-stats' ) ); + }; + return (
{ ! isOdysseyStats && ( @@ -215,6 +233,28 @@ class StatsSite extends Component { slug={ slug } /> + { isOdysseyStats && showOptOutNotice && ! this.state.isOptOutNoticeDismissed && ( +
+ + { translate( + '{{p}}Enjoy a more modern and mobile friendly experience with new stats and insights to help you grow your site.{{/p}}{{p}}If you prefer to continue using the traditional stats, {{manageYourSettingsLink}}manage your settings{{/manageYourSettingsLink}}.{{/p}}', + { + components: { + p:

, + manageYourSettingsLink: ( + + ), + }, + } + ) } + +

+ ) } +
@@ -440,6 +480,7 @@ export default connect( showEnableStatsModule, path: getCurrentRouteParameterized( state, siteId ), isOdysseyStats, + showOptOutNotice: hasOptOutNewStatsNotice( state, siteId ), }; }, { recordGoogleEvent, enableJetpackStatsModule, recordTracksEvent } diff --git a/client/my-sites/stats/style.scss b/client/my-sites/stats/style.scss index 36f6b8afb5deaa..5fd4e4124a89e8 100644 --- a/client/my-sites/stats/style.scss +++ b/client/my-sites/stats/style.scss @@ -230,6 +230,13 @@ } } +.inner-notice-container { + padding-top: 32px; + p { + margin-bottom: 0; + } +} + // Stats section scoped styles .is-section-stats { &.color-scheme.is-classic-dark { diff --git a/client/state/action-types.ts b/client/state/action-types.ts index 20f396f8bbf43e..47ccc458747cc7 100644 --- a/client/state/action-types.ts +++ b/client/state/action-types.ts @@ -472,6 +472,7 @@ export const JETPACK_USER_CONNECTION_DATA_REQUEST_FAILURE = export const JETPACK_USER_CONNECTION_DATA_REQUEST_SUCCESS = 'JETPACK_USER_CONNECTION_DATA_REQUEST_SUCCESS'; export const JITM_DISMISS = 'JITM_DISMISS'; +export const JITM_DISMISS_DIRECT = 'JITM_DISMISS_DIRECT'; export const JITM_FETCH = 'JITM_FETCH'; export const JITM_OPEN_HELP_CENTER = 'JITM_OPEN_HELP_CENTER'; export const JITM_SET = 'JITM_SET'; diff --git a/client/state/data-layer/wpcom/sites/jitm/index.js b/client/state/data-layer/wpcom/sites/jitm/index.js index f1400a7adb0b72..6e492e35ec75c4 100644 --- a/client/state/data-layer/wpcom/sites/jitm/index.js +++ b/client/state/data-layer/wpcom/sites/jitm/index.js @@ -1,6 +1,6 @@ import moment from 'moment/moment'; import makeJsonSchemaParser from 'calypso/lib/make-json-schema-parser'; -import { JITM_DISMISS, JITM_FETCH } from 'calypso/state/action-types'; +import { JITM_DISMISS, JITM_FETCH, JITM_DISMISS_DIRECT } from 'calypso/state/action-types'; import { registerHandlers } from 'calypso/state/data-layer/handler-registry'; import { http } from 'calypso/state/data-layer/wpcom-http/actions'; import { dispatchRequest } from 'calypso/state/data-layer/wpcom-http/utils'; @@ -97,6 +97,28 @@ export const doDismissJITM = ( action ) => action ); +/** + * Dismisses a jitm on the jetpack site. + * The difference between this and doDismissJITM is that this action sends requests directly to the Jetpack site, + * instead of going through the WordPress.com rest-api endpoint. + * + * @param {Object} action The dismissal action + * @returns {Object} The HTTP fetch action + */ +export const doDismissJITMDirect = ( action ) => + http( + { + method: 'POST', + path: '/jitm', + apiNamespace: 'jetpack/v4', + body: { + feature_class: action.featureClass, + id: action.id, + }, + }, + action + ); + /** * Called when the http layer receives a valid jitm * @@ -141,4 +163,11 @@ registerHandlers( 'state/data-layer/wpcom/sites/jitm/index.js', { onError: noop, } ), ], + [ JITM_DISMISS_DIRECT ]: [ + dispatchRequest( { + fetch: doDismissJITMDirect, + onSuccess: noop, + onError: noop, + } ), + ], } ); diff --git a/client/state/jitm/actions.js b/client/state/jitm/actions.js index 2cc718bd7a5b1f..fab8632747bdf0 100644 --- a/client/state/jitm/actions.js +++ b/client/state/jitm/actions.js @@ -6,6 +6,7 @@ import { JITM_FETCH, JITM_SET, JITM_OPEN_HELP_CENTER, + JITM_DISMISS_DIRECT, } from 'calypso/state/action-types'; import 'calypso/state/data-layer/wpcom/sites/jitm'; import 'calypso/state/jitm/init'; @@ -25,6 +26,19 @@ export const dismissJITM = ( siteId, id, featureClass ) => ( { featureClass, } ); +/** + * Dismisses a jitm directly + * + * @param {string} id The id of the jitm to dismiss + * @param {string} featureClass The feature class of the jitm to dismiss + * @returns {Object} The dismiss action + */ +export const dismissJITMDirect = ( id, featureClass ) => ( { + type: JITM_DISMISS_DIRECT, + id, + featureClass, +} ); + /** * Inserts a jitm into the store for display * diff --git a/client/state/sites/selectors/has-opt-out-new-stats-notice.js b/client/state/sites/selectors/has-opt-out-new-stats-notice.js new file mode 100644 index 00000000000000..40b511b0618193 --- /dev/null +++ b/client/state/sites/selectors/has-opt-out-new-stats-notice.js @@ -0,0 +1,18 @@ +import 'calypso/state/ui/init'; +import getRawSite from 'calypso/state/selectors/get-raw-site'; + +/** + * Returns whether the opt-out notice should be shown. + * + * @param {Object} state Global state tree + * @param siteId The site ID. + * @returns {?boolean} hasOptOutNotice + */ +export default function hasOptOutNewStatsNotice( state, siteId ) { + if ( ! siteId ) { + return null; + } + const site = getRawSite( state, siteId ); + + return site?.stats_notices?.opt_out_new_stats; +} diff --git a/packages/components/src/notice-banner/index.tsx b/packages/components/src/notice-banner/index.tsx new file mode 100644 index 00000000000000..665da18a41eff1 --- /dev/null +++ b/packages/components/src/notice-banner/index.tsx @@ -0,0 +1,96 @@ +import { Icon, warning, info, check, close } from '@wordpress/icons'; +import classNames from 'classnames'; +import React from 'react'; +import './style.scss'; + +type NoticeBannerProps = { + /** The severity of the alert. */ + level: 'error' | 'warning' | 'info' | 'success'; + + /** The title of the NoticeBanner */ + title: string; + + /** A list of action elements to show across the bottom */ + actions?: React.ReactNode[]; + + /** Hide close button */ + hideCloseButton?: boolean; + + /** Method to call when the close button is clicked */ + onClose?: () => void; + + /** Children to be rendered inside the alert. */ + children: React.ReactNode; +}; + +const getIconByLevel = ( level: NoticeBannerProps[ 'level' ] ) => { + switch ( level ) { + case 'error': + return warning; + case 'warning': + return warning; + case 'info': + return info; + case 'success': + return check; + default: + return warning; + } +}; + +/** + * NoticeBanner component + * + * @param {Object} props - The component properties. + * @param {string} props.level - The notice level: error, warning, info, success. + * @param {boolean} props.hideCloseButton - Whether to hide the close button. + * @param {Function} props.onClose - The function to call when the close button is clicked. + * @param {string} props.title - The title of the notice. + * @param {React.ReactNode[]} props.actions - Actions to show across the bottom of the bar. + * @param {React.Component} props.children - The notice content. + * @returns {React.ReactElement} The `NoticeBanner` component. + */ +const NoticeBanner: React.FC< NoticeBannerProps > = ( { + level, + title, + children, + actions, + hideCloseButton, + onClose, +} ) => { + const classes = classNames( 'notice-banner', `is-${ level }` ); + + return ( +
+
+ +
+ +
+
{ title }
+ { children } + + { actions && actions.length > 0 && ( +
+ { actions.map( ( action, index ) => ( +
{ action }
+ ) ) } +
+ ) } +
+ + { ! hideCloseButton && ( + + ) } +
+ ); +}; + +NoticeBanner.defaultProps = { + level: 'info', + hideCloseButton: false, +}; + +export default NoticeBanner; diff --git a/packages/components/src/notice-banner/notice-banner.stories.jsx b/packages/components/src/notice-banner/notice-banner.stories.jsx new file mode 100644 index 00000000000000..aa1df5cb9fb17d --- /dev/null +++ b/packages/components/src/notice-banner/notice-banner.stories.jsx @@ -0,0 +1,90 @@ +import { ExternalLink } from '@wordpress/components'; +import Button from '../button'; +import NoticeBanner from './index'; + +export default { + title: 'Notice Banner', + component: NoticeBanner, + argTypes: { + level: { + control: { + type: 'select', + options: [ 'info', 'success', 'warning', 'error' ], + }, + }, + hideCloseButton: { + control: { + type: 'boolean', + }, + }, + }, +}; + +const Template = ( args ) => ; + +export const _default = Template.bind( {} ); +_default.args = { + level: 'info', + title: 'Improve your hovercraft', + children: + 'Make your hovercraft better with HoverPack; the best hovercraft upgrade kit on the market.', + onClose: () => alert( 'Close clicked' ), + actions: [ + , + + Learn more + , + ], + hideCloseButton: false, +}; + +export const warning = Template.bind( {} ); +warning.args = { + level: 'warning', + title: 'Your hovercraft is full of eels.', + children: ( +
+ Either your vehicle needs to be cleared, or some kind of translation error has occurred. +
+ ), + actions: [ + , + + Learn more + , + ], + hideCloseButton: false, +}; + +export const success = Template.bind( {} ); +success.args = { + level: 'success', + title: 'Your hovercraft has been upgraded.', + children: 'Please enjoy your newer, cooler hovercraft.', + onClose: () => alert( 'Close clicked' ), + actions: [ + , + ], + hideCloseButton: false, +}; + +export const error = Template.bind( {} ); +error.args = { + level: 'error', + title: 'The eels have stolen your hovercraft.', + children: + 'We were unable to remove the eels from your hovercraft. Please contact the authorities, as the eels are armed and dangerous.', + onClose: () => alert( 'Close clicked' ), + actions: [ + + Learn more + , + ], + hideCloseButton: false, +}; diff --git a/packages/components/src/notice-banner/style.scss b/packages/components/src/notice-banner/style.scss new file mode 100644 index 00000000000000..620f4955c6817e --- /dev/null +++ b/packages/components/src/notice-banner/style.scss @@ -0,0 +1,121 @@ +:root { + --font-body: 16px; + --jp-black: #000; + --jp-gray-5: #dcdcde; + --jp-red: #d63639; + --jp-yellow-20: #f0c930; + --jp-green: #069e08; + --spacing-base: 8px; +} +.notice-banner { + display: flex; + align-items: flex-start; + + font-size: var(--font-body); + + background-color: var(--studio-white); + + border: 1px solid var(--jp-black); + border-radius: 4px; + border-color: var(--jp-gray-5); + border-left-width: 6px; + + padding: 24px 31px 27px 18px; +} + +.notice-banner__icon-wrapper { + margin-right: 20px; + width: calc(var(--spacing-base) * 3); + height: calc(var(--spacing-base) * 3); +} + +.notice-banner__close-button { + width: calc(var(--spacing-base) * 3); + height: calc(var(--spacing-base) * 3); + + background-color: transparent; + border: none; + cursor: pointer; + outline: none; +} + +// Mobile layout differences. +@media screen and (max-width: 600px) { + .notice-banner { + position: relative; + padding-top: 68px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.03), 0 1px 2px rgba(0, 0, 0, 0.06); + } + + .notice-banner__icon-wrapper { + position: absolute; + top: 24px; + left: 24px; + } + + .notice-banner__close-button { + position: absolute; + top: 24px; + right: 24px; + } +} + +.notice-banner__main-content { + flex-grow: 1; +} + +.notice-banner__title { + font-weight: 600; + margin-bottom: 8px; +} + +.notice-banner__action-bar { + display: flex; + align-items: center; + margin-top: 20px; + + a { + &, + &:hover, + &:active, + &:focus { + color: var(--jp-black); + } + } + + > * { + margin-right: 24px; + } +} + +.notice-banner.is-error { + border-left-color: var(--jp-red); + + .notice-banner__icon { + fill: var(--jp-red); + } +} + +.notice-banner.is-warning { + border-left-color: var(--jp-yellow-20); + + .notice-banner__icon { + fill: var(--jp-yellow-20); + } +} + +.notice-banner.is-info { + border-left-color: var(--black); + + .notice-banner__icon { + fill: var(--black); + } +} + +.notice-banner.is-success { + border-left-color: var(--jp-green); + + .notice-banner__icon { + fill: var(--jp-green); + } +}