diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ab48387dd261..d26ddfb655aa4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2197,6 +2197,9 @@ importers: projects/packages/jetpack-mu-wpcom: dependencies: + '@automattic/calypso-color-schemes': + specifier: 3.1.3 + version: 3.1.3 '@automattic/color-studio': specifier: 2.6.0 version: 2.6.0 @@ -2215,6 +2218,9 @@ importers: '@automattic/typography': specifier: 1.0.0 version: 1.0.0 + '@popperjs/core': + specifier: ^2.11.8 + version: 2.11.8 '@preact/signals': specifier: ^1.2.2 version: 1.3.0(preact@10.22.1) @@ -2257,15 +2263,24 @@ importers: '@wordpress/plugins': specifier: 7.2.0 version: 7.2.0(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/private-apis': + specifier: ^1.2.0 + version: 1.2.0 '@wordpress/url': specifier: 4.2.0 version: 4.2.0 clsx: specifier: 2.1.1 version: 2.1.1 + debug: + specifier: 4.3.4 + version: 4.3.4 preact: specifier: ^10.13.1 version: 10.22.1 + react-popper: + specifier: ^2.3.0 + version: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) redux: specifier: ^4.2.1 version: 4.2.1 @@ -6402,6 +6417,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@preact/signals-core@1.7.0': resolution: {integrity: sha512-bEZLgmJGSBVP5PUPDowhPW3bVdMmp9Tr5OEl+SQK+8Tv9T7UsIfyN905cfkmmeqw8z4xp8T6zrl4M1uj9+HAfg==} @@ -12884,6 +12902,9 @@ packages: react: ^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0 react-dom: ^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0 + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -12902,6 +12923,13 @@ packages: peerDependencies: react: ^16.13.1 || ^17.0.0 || ^18.0.0 + react-popper@2.3.0: + resolution: {integrity: sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==} + peerDependencies: + '@popperjs/core': ^2.0.0 + react: ^16.8.0 || ^17 || ^18 + react-dom: ^16.8.0 || ^17 || ^18 + react-redux@7.2.8: resolution: {integrity: sha512-6+uDjhs3PSIclqoCk0kd6iX74gzrGc3W5zcAjbrFgEdIjRSQObdIwfx80unTkVUYvbQ95Y8Av3OvFHq1w5EOUw==} peerDependencies: @@ -14412,6 +14440,9 @@ packages: walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + warning@4.0.3: + resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + watchpack@2.4.1: resolution: {integrity: sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==} engines: {node: '>=10.13.0'} @@ -16516,6 +16547,8 @@ snapshots: dependencies: playwright: 1.45.1 + '@popperjs/core@2.11.8': {} + '@preact/signals-core@1.7.0': {} '@preact/signals@1.3.0(preact@10.22.1)': @@ -26117,6 +26150,8 @@ snapshots: react-dom: 18.3.1(react@18.3.1) react-is: 18.1.0 + react-fast-compare@3.2.2: {} + react-is@16.13.1: {} react-is@17.0.2: {} @@ -26130,6 +26165,14 @@ snapshots: prop-types: 15.8.1 react: 18.3.1 + react-popper@2.3.0(@popperjs/core@2.11.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@popperjs/core': 2.11.8 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-fast-compare: 3.2.2 + warning: 4.0.3 + react-redux@7.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.24.7 @@ -27844,6 +27887,10 @@ snapshots: dependencies: makeerror: 1.0.12 + warning@4.0.3: + dependencies: + loose-envify: 1.4.0 + watchpack@2.4.1: dependencies: glob-to-regexp: 0.4.1 diff --git a/projects/js-packages/ai-client/changelog/update-ai-logo-generator-fix-upgrade-message b/projects/js-packages/ai-client/changelog/update-ai-logo-generator-fix-upgrade-message new file mode 100644 index 0000000000000..c2b1e59cc0f46 --- /dev/null +++ b/projects/js-packages/ai-client/changelog/update-ai-logo-generator-fix-upgrade-message @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +AI Logo Generator: update upgrade message. diff --git a/projects/js-packages/ai-client/changelog/update-ai-logo-generator-small-ui-fixes b/projects/js-packages/ai-client/changelog/update-ai-logo-generator-small-ui-fixes new file mode 100644 index 0000000000000..77945125c637c --- /dev/null +++ b/projects/js-packages/ai-client/changelog/update-ai-logo-generator-small-ui-fixes @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +AI Logo Generator: fix small UI issues. diff --git a/projects/js-packages/ai-client/src/logo-generator/components/feature-fetch-failure-screen.tsx b/projects/js-packages/ai-client/src/logo-generator/components/feature-fetch-failure-screen.tsx index dc26c0570e1ca..cd1f53acdf38c 100644 --- a/projects/js-packages/ai-client/src/logo-generator/components/feature-fetch-failure-screen.tsx +++ b/projects/js-packages/ai-client/src/logo-generator/components/feature-fetch-failure-screen.tsx @@ -13,7 +13,7 @@ export const FeatureFetchFailureScreen: React.FC< { onRetry: () => void; } > = ( { onCancel, onRetry } ) => { const errorMessage = __( - 'We are sorry. There was an error loading your Jetpack AI account settings. Please, try again.', + 'We are sorry. There was an error loading your Jetpack AI plan data. Please, try again.', 'jetpack-ai-client' ); diff --git a/projects/js-packages/ai-client/src/logo-generator/components/generator-modal.tsx b/projects/js-packages/ai-client/src/logo-generator/components/generator-modal.tsx index dd5c261c3db91..7ae0c62baef04 100644 --- a/projects/js-packages/ai-client/src/logo-generator/components/generator-modal.tsx +++ b/projects/js-packages/ai-client/src/logo-generator/components/generator-modal.tsx @@ -235,7 +235,7 @@ export const GeneratorModal: React.FC< GeneratorModalProps > = ( { /> { logoAccepted ? (
- +
+ + ); +} diff --git a/projects/js-packages/publicize-components/src/components/social-post-modal/preview-section.tsx b/projects/js-packages/publicize-components/src/components/social-post-modal/preview-section.tsx new file mode 100644 index 0000000000000..a30441e693ca9 --- /dev/null +++ b/projects/js-packages/publicize-components/src/components/social-post-modal/preview-section.tsx @@ -0,0 +1,46 @@ +import { TabPanel } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { store as socialStore } from '../../social-store'; +import ConnectionIcon from '../connection-icon'; +import styles from './styles.module.scss'; + +/** + * Preview section of the social post modal. + * + * @returns {import('react').ReactNode} - Preview section of the social post modal. + */ +export function PreviewSection() { + const connections = useSelect( select => { + const store = select( socialStore ); + + return store.getConnections().map( connection => { + const title = connection.display_name || connection.external_display; + const name = `${ connection.service_name }-${ connection.connection_id }`; + const icon = ( + + ); + + return { + ...connection, + // Add the props needed for the TabPanel component + name, + title, + icon, + }; + } ); + }, [] ); + + return ( +
+ + { ( tab: ( typeof connections )[ number ] ) => ( +
Content for { tab.title }
+ ) } +
+
+ ); +} diff --git a/projects/js-packages/publicize-components/src/components/social-post-modal/settings-section.tsx b/projects/js-packages/publicize-components/src/components/social-post-modal/settings-section.tsx new file mode 100644 index 0000000000000..66d3ecbe2a5f2 --- /dev/null +++ b/projects/js-packages/publicize-components/src/components/social-post-modal/settings-section.tsx @@ -0,0 +1,21 @@ +import { __ } from '@wordpress/i18n'; +import { SharePostForm } from '../form/share-post-form'; +import styles from './styles.module.scss'; + +/** + * Settings section of the social post modal. + * + * @returns {import('react').ReactNode} - Settings section of the social post modal. + */ +export function SettingsSection() { + return ( +
+
+

{ __( 'Social Preview', 'jetpack' ) }

+
+
+ +
+
+ ); +} diff --git a/projects/js-packages/publicize-components/src/components/social-post-modal/styles.module.scss b/projects/js-packages/publicize-components/src/components/social-post-modal/styles.module.scss new file mode 100644 index 0000000000000..eda4f70fef63e --- /dev/null +++ b/projects/js-packages/publicize-components/src/components/social-post-modal/styles.module.scss @@ -0,0 +1,91 @@ +@import '@automattic/jetpack-base-styles/gutenberg-base-styles'; + +.panel { + margin: 1rem 0 1.5rem; +} + +.modal { + + @include break-small { + width: calc(100vw - 40px); + } + + @include break-large { + width: calc(#{$break-large} - 40px); + } + + :global(.components-modal__content.hide-header) { + padding: 0; + } +} + +.modal-content { + display: flex; + + @media (max-width: $break-medium ) { + flex-direction: column; + } +} + +.close-button { + position: absolute; + right: 0.5rem; + top: 0.5rem; + height: 2rem; +} + +.settings-header { + height: 48px; // Same as tablist height + display: flex; + align-items: center; + padding-inline: 2rem; + border-bottom: 1px solid var(--studio-gray-5); + + + @include break-medium { + border-inline-end: 1px solid var(--studio-gray-5); + } + + h2 { + font-weight: 600; + margin-block: 0; + font-size: 1rem; + line-height: 16px; + } +} + +.settings-section { + @include break-medium { + flex: 1 + } +} + +.settings-content { + padding: 2rem; + + @include break-medium { + border-inline-end: 1px solid var(--studio-gray-5); + } +} + +.preview-section { + flex: 1; + background: var(--jp-gray-0); // closest to #F0F2F5; + + @include break-medium { + flex: 2; + } + + :global(.components-tab-panel__tabs) { + justify-content: center; + background: var(--jp-white); + height: 48px; + border-bottom: 1px solid var(--studio-gray-5); + } +} + +.preview-content { + display: flex; + justify-content: center; + padding: 2rem; +} diff --git a/projects/js-packages/publicize-components/src/hooks/use-media-restrictions/index.ts b/projects/js-packages/publicize-components/src/hooks/use-media-restrictions/index.ts index 3b8be9e473086..48cc3a44d04ad 100644 --- a/projects/js-packages/publicize-components/src/hooks/use-media-restrictions/index.ts +++ b/projects/js-packages/publicize-components/src/hooks/use-media-restrictions/index.ts @@ -1,6 +1,7 @@ import { useRef, useMemo } from '@wordpress/element'; import { Connection } from '../../social-store/types'; import useAttachedMedia from '../use-attached-media'; +import useImageGeneratorConfig from '../use-image-generator-config'; import { MediaDetails } from '../use-media-details/types'; import { NO_MEDIA_ERROR, @@ -134,13 +135,20 @@ const getVideoValidationError = ( sizeInMb, length, width, height, videoLimits ) * @param {object} mediaData - Data for media, width, height, source_url etc. * @param {string} serviceName - The name of the social media service we want to validate against. facebook, tumblr etc. * @param {boolean} hasAttachedMedia - Whether the media is attached. + * @param {boolean} hasAutoGeneratedImage - Whether there is an auto generated image. * @returns {(FILE_SIZE_ERROR | FILE_TYPE_ERROR | VIDEO_LENGTH_TOO_SHORT_ERROR | VIDEO_LENGTH_TOO_LONG_ERROR)} Returns validation error. */ -const getValidationError = ( metaData, mediaData, serviceName, hasAttachedMedia ) => { +const getValidationError = ( + metaData, + mediaData, + serviceName, + hasAttachedMedia, + hasAutoGeneratedImage +) => { const restrictions = RESTRICTIONS[ serviceName ] ?? DEFAULT_RESTRICTIONS; if ( ! metaData || Object.keys( metaData ).length === 0 ) { - return restrictions.requiresMedia ? NO_MEDIA_ERROR : null; + return restrictions.requiresMedia && ! hasAutoGeneratedImage ? NO_MEDIA_ERROR : null; } const { mime, fileSize } = metaData; @@ -183,6 +191,7 @@ const useMediaRestrictions = ( media: MediaDetails ): MediaRestrictions => { const { attachedMedia } = useAttachedMedia(); + const { isEnabled: hasAutoGeneratedImage } = useImageGeneratorConfig(); const hasAttachedMedia = attachedMedia.length > 0; const errors = useRef( {} ); @@ -192,7 +201,8 @@ const useMediaRestrictions = ( media.metaData, media.mediaData, service_name, - hasAttachedMedia + hasAttachedMedia, + hasAutoGeneratedImage && service_name === 'instagram-business' ); if ( error ) { errs[ connection_id ] = error; @@ -207,7 +217,7 @@ const useMediaRestrictions = ( validationErrors: errors.current, isConvertible: isMediaConvertible( media.metaData ), }; - }, [ connections, media.metaData, media.mediaData, hasAttachedMedia ] ); + }, [ connections, media.metaData, media.mediaData, hasAttachedMedia, hasAutoGeneratedImage ] ); }; export default useMediaRestrictions; diff --git a/projects/js-packages/publicize-components/src/social-store/reducer/index.js b/projects/js-packages/publicize-components/src/social-store/reducer/index.js index abd4003d17c65..e330313125616 100644 --- a/projects/js-packages/publicize-components/src/social-store/reducer/index.js +++ b/projects/js-packages/publicize-components/src/social-store/reducer/index.js @@ -14,6 +14,7 @@ const reducer = combineReducers( { hasPaidPlan: ( state = false ) => state, userConnectionUrl: ( state = '' ) => state, useAdminUiV1: ( state = false ) => state, + featureFlags: ( state = false ) => state, hasPaidFeatures: ( state = false ) => state, connectionRefreshPath: ( state = '' ) => state, } ); diff --git a/projects/js-packages/publicize-components/src/social-store/selectors/index.js b/projects/js-packages/publicize-components/src/social-store/selectors/index.js index 6b196c1b4bc68..ad6608112a2fe 100644 --- a/projects/js-packages/publicize-components/src/social-store/selectors/index.js +++ b/projects/js-packages/publicize-components/src/social-store/selectors/index.js @@ -12,6 +12,7 @@ const selectors = { ...socialImageGeneratorSettingsSelectors, userConnectionUrl: state => state.userConnectionUrl, useAdminUiV1: state => state.useAdminUiV1, + featureFlags: state => state.featureFlags, hasPaidFeatures: state => state.hasPaidFeatures, connectionRefreshPath: state => state.connectionRefreshPath, }; diff --git a/projects/js-packages/publicize-components/src/social-store/types.ts b/projects/js-packages/publicize-components/src/social-store/types.ts index 88344bb91c09b..79ecd3ae19b2d 100644 --- a/projects/js-packages/publicize-components/src/social-store/types.ts +++ b/projects/js-packages/publicize-components/src/social-store/types.ts @@ -60,6 +60,7 @@ export type SocialStoreState = { // on Jetack Social admin page jetpackSettings?: JetpackSettings; useAdminUiV1?: boolean; + featureFlags?: Record< string, boolean >; }; export interface KeyringAdditionalUser { diff --git a/projects/packages/classic-theme-helper/.phan/baseline.php b/projects/packages/classic-theme-helper/.phan/baseline.php index 0ddcfaf434b6c..82a2e7525ac13 100644 --- a/projects/packages/classic-theme-helper/.phan/baseline.php +++ b/projects/packages/classic-theme-helper/.phan/baseline.php @@ -10,18 +10,21 @@ return [ // # Issue statistics: // PhanTypeMismatchArgumentInternal : 10+ occurrences + // PhanUndeclaredClassMethod : 7 occurrences + // PhanUndeclaredClassReference : 4 occurrences // PhanTypeInvalidDimOffset : 2 occurrences // PhanTypeMismatchArgument : 2 occurrences // PhanTypeComparisonToArray : 1 occurrence // PhanTypeMismatchArgumentProbablyReal : 1 occurrence // PhanTypeMismatchProperty : 1 occurrence // PhanTypePossiblyInvalidDimOffset : 1 occurrence - // PhanUndeclaredFunction : 1 occurrence + // PhanUndeclaredTypeProperty : 1 occurrence // Currently, file_suppressions and directory_suppressions are the only supported suppressions 'file_suppressions' => [ '_inc/lib/tonesque.php' => ['PhanTypeMismatchArgument', 'PhanTypeMismatchArgumentInternal', 'PhanTypeMismatchArgumentProbablyReal'], 'src/class-featured-content.php' => ['PhanTypeComparisonToArray', 'PhanTypeInvalidDimOffset', 'PhanTypeMismatchArgument', 'PhanTypeMismatchProperty', 'PhanTypePossiblyInvalidDimOffset'], + 'src/social-links.php' => ['PhanUndeclaredClassMethod', 'PhanUndeclaredClassReference', 'PhanUndeclaredTypeProperty'], ], // 'directory_suppressions' => ['src/directory_name' => ['PhanIssueName1', 'PhanIssueName2']] can be manually added if needed. // (directory_suppressions will currently be ignored by subsequent calls to --save-baseline, but may be preserved in future Phan releases) diff --git a/projects/packages/classic-theme-helper/changelog/add-social-links-to-classic-theme-helper-package b/projects/packages/classic-theme-helper/changelog/add-social-links-to-classic-theme-helper-package new file mode 100644 index 0000000000000..c314b1446fbd5 --- /dev/null +++ b/projects/packages/classic-theme-helper/changelog/add-social-links-to-classic-theme-helper-package @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Social Links: Added feature to Classic Theme Helper package. diff --git a/projects/packages/classic-theme-helper/package.json b/projects/packages/classic-theme-helper/package.json index bd863220da3ce..3a6afb51dd960 100644 --- a/projects/packages/classic-theme-helper/package.json +++ b/projects/packages/classic-theme-helper/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@automattic/jetpack-classic-theme-helper", - "version": "0.4.3", + "version": "0.4.4-alpha", "description": "Features used with classic themes", "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/packages/classic-theme-helper/#readme", "bugs": { diff --git a/projects/packages/classic-theme-helper/src/class-main.php b/projects/packages/classic-theme-helper/src/class-main.php index 4445f2f760f27..d608530e70557 100644 --- a/projects/packages/classic-theme-helper/src/class-main.php +++ b/projects/packages/classic-theme-helper/src/class-main.php @@ -14,7 +14,7 @@ */ class Main { - const PACKAGE_VERSION = '0.4.3'; + const PACKAGE_VERSION = '0.4.4-alpha'; /** * Modules to include. diff --git a/projects/packages/classic-theme-helper/src/social-links.php b/projects/packages/classic-theme-helper/src/social-links.php new file mode 100644 index 0000000000000..f09024595d1f5 --- /dev/null +++ b/projects/packages/classic-theme-helper/src/social-links.php @@ -0,0 +1,279 @@ +theme_supported_services = $theme_support[0]; + $this->links = class_exists( \Jetpack_Options::class ) ? \Jetpack_Options::get_option( 'social_links', array() ) : ''; + + $this->admin_setup(); + + add_filter( 'jetpack_has_social_links', array( $this, 'has_social_links' ) ); + add_filter( 'jetpack_get_social_links', array( $this, 'get_social_links' ) ); + + foreach ( $theme_support[0] as $service ) { + add_filter( "pre_option_jetpack-$service", array( $this, 'get_social_link_filter' ) ); // - `get_option( 'jetpack-service' );` + add_filter( "theme_mod_jetpack-$service", array( $this, 'get_social_link_filter' ) ); // - `get_theme_mod( 'jetpack-service' );` + } + } + + /** + * Init the admin setup. + */ + public function admin_setup() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + if ( ! is_admin() && ! $this->is_customize_preview() ) { + return; + } + + // @phan-suppress-next-line PhanUndeclaredFunction -- Function checked with function_exists - see https://github.com/phan/phan/issues/1204. + $this->publicize = function_exists( 'publicize_init' ) ? publicize_init() : null; + $publicize_services = $this->publicize->get_services( 'connected' ); + $this->services = array_intersect( array_keys( $publicize_services ), $this->theme_supported_services ); + + add_action( 'publicize_connected', array( $this, 'check_links' ), 20 ); + add_action( 'publicize_disconnected', array( $this, 'check_links' ), 20 ); + add_action( 'customize_register', array( $this, 'customize_register' ) ); + add_filter( 'sanitize_option_jetpack_options', array( $this, 'sanitize_link' ) ); + } + + /** + * Compares the currently saved links with the connected services and removes + * links from services that are no longer connected. + * + * @return void + */ + public function check_links() { + $active_links = array_intersect_key( $this->links, array_flip( $this->services ) ); + + if ( $active_links !== $this->links ) { + $this->links = $active_links; + if ( class_exists( \Jetpack_Options::class ) ) { + \Jetpack_Options::update_option( 'social_links', $active_links ); + } + } + } + + /** + * Add social link dropdown to the Customizer. + * + * @param \WP_Customize_Manager $wp_customize Theme Customizer object. + */ + public function customize_register( $wp_customize ) { + $wp_customize->add_section( + 'jetpack_social_links', + array( + 'title' => esc_html__( 'Connect', 'jetpack-classic-theme-helper' ), + 'priority' => 35, + ) + ); + + if ( class_exists( \Publicize::class ) ) { + foreach ( array_keys( $this->publicize->get_services( 'all' ) ) as $service ) { + $choices = $this->get_customize_select( $service ); + + if ( empty( $choices ) ) { + continue; + } + + $wp_customize->add_setting( + "jetpack_options[social_links][$service]", + array( + 'type' => 'option', + 'default' => '', + ) + ); + + $wp_customize->add_control( + "jetpack-$service", + array( + 'label' => $this->publicize->get_service_label( $service ), + 'section' => 'jetpack_social_links', + 'settings' => "jetpack_options[social_links][$service]", + 'type' => 'select', + 'choices' => $choices, + ) + ); + } + } + } + + /** + * Sanitizes social links. + * + * @param array $option The incoming values to be sanitized. + * @return array + */ + public function sanitize_link( $option ) { + foreach ( $this->services as $service ) { + if ( ! empty( $option['social_links'][ $service ] ) ) { + $option['social_links'][ $service ] = esc_url_raw( $option['social_links'][ $service ] ); + } else { + unset( $option['social_links'][ $service ] ); + } + } + + return $option; + } + + /** + * Returns whether there are any social links set. + * + * @return bool + */ + public function has_social_links() { + return ! empty( $this->links ); + } + + /** + * Return available social links. + * + * @return array + */ + public function get_social_links() { + return $this->links; + } + + /** + * Short-circuits get_option and get_theme_mod calls. + * + * @param string $link The incoming value to be replaced. + * @return string $link The social link that we've got. + */ + public function get_social_link_filter( $link ) { + if ( preg_match( '/_jetpack-(.+)$/i', current_filter(), $matches ) && ! empty( $this->links[ $matches[1] ] ) ) { + return $this->links[ $matches[1] ]; + } + + return $link; + } + + /** + * Puts together an array of choices for a specific service. + * + * @param string $service The social service. + * @return array An associative array with profile links and display names. + */ + private function get_customize_select( $service ) { + $choices = array( + '' => __( '— Select —', 'jetpack-classic-theme-helper' ), + ); + + if ( isset( $this->links[ $service ] ) ) { + $choices[ $this->links[ $service ] ] = $this->links[ $service ]; + } + + if ( class_exists( \Publicize::class ) ) { + $connected_services = $this->publicize->get_services( 'connected' ); + if ( isset( $connected_services[ $service ] ) ) { + foreach ( $connected_services[ $service ] as $c ) { + $profile_link = $this->publicize->get_profile_link( $service, $c ); + + if ( false === $profile_link ) { + continue; + } + + $choices[ $profile_link ] = $this->publicize->get_display_name( $service, $c ); + } + } + } + + if ( 1 === count( $choices ) ) { + return array(); + } + + return $choices; + } + + /** + * Back-compat function for versions prior to 4.0. + */ + private function is_customize_preview() { + global $wp_customize; + return is_a( $wp_customize, 'WP_Customize_Manager' ) && $wp_customize->is_preview(); + } + } + +} // - end if ( ! class_exists( 'Social_Links' ) diff --git a/projects/packages/connection/CHANGELOG.md b/projects/packages/connection/CHANGELOG.md index bc52731f97123..9eb380c3dda9a 100644 --- a/projects/packages/connection/CHANGELOG.md +++ b/projects/packages/connection/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.11.3] - 2024-08-01 +### Added +- Added support for 'recommendations_evaluation' Jetpack option" [#38534] + ## [2.11.2] - 2024-07-22 ### Fixed - Fixed textdomain on i18n messages imported from the IDC package. [#38412] @@ -1131,6 +1135,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Separate the connection library into its own package. +[2.11.3]: https://github.com/Automattic/jetpack-connection/compare/v2.11.2...v2.11.3 [2.11.2]: https://github.com/Automattic/jetpack-connection/compare/v2.11.1...v2.11.2 [2.11.1]: https://github.com/Automattic/jetpack-connection/compare/v2.11.0...v2.11.1 [2.11.0]: https://github.com/Automattic/jetpack-connection/compare/v2.10.2...v2.11.0 diff --git a/projects/packages/connection/changelog/add-evaluation-recommendations-logic b/projects/packages/connection/changelog/add-evaluation-recommendations-logic deleted file mode 100644 index a9e1d32b91432..0000000000000 --- a/projects/packages/connection/changelog/add-evaluation-recommendations-logic +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: added - -Added support for 'recommendations_evaluation' Jetpack option" diff --git a/projects/packages/connection/src/class-package-version.php b/projects/packages/connection/src/class-package-version.php index 7b8c21c1276ed..09ff7311fdea3 100644 --- a/projects/packages/connection/src/class-package-version.php +++ b/projects/packages/connection/src/class-package-version.php @@ -12,7 +12,7 @@ */ class Package_Version { - const PACKAGE_VERSION = '2.11.3-alpha'; + const PACKAGE_VERSION = '2.11.3'; const PACKAGE_SLUG = 'connection'; diff --git a/projects/packages/explat/CHANGELOG.md b/projects/packages/explat/CHANGELOG.md index ec40ad3d8fa44..0759c69ee4159 100644 --- a/projects/packages/explat/CHANGELOG.md +++ b/projects/packages/explat/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.1] - 2024-08-01 +### Changed +- Internal updates. + ## 0.1.0 - 2024-07-29 ### Added - Adds a new component to fetch experiments specifically for authenticated users [#37999] @@ -14,3 +18,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - ExPlat: add condition to prevent fetching the experiment assignment if there's not anon id (meaning that Tracks is likely disabled) [#38327] - Updated package dependencies. [#38132] + +[0.1.1]: https://github.com/Automattic/jetpack-explat/compare/v0.1.0...v0.1.1 diff --git a/projects/packages/explat/changelog/chore-update-explat-client-react-helpers-version b/projects/packages/explat/changelog/chore-update-explat-client-react-helpers-version deleted file mode 100644 index e903d6095bb18..0000000000000 --- a/projects/packages/explat/changelog/chore-update-explat-client-react-helpers-version +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: fixed -Comment: This change updates one of the dependencies to remove the noise added by the previous version. It does not affect the end user. - - diff --git a/projects/packages/explat/package.json b/projects/packages/explat/package.json index c2faeabd0f31c..8601d22a73dd5 100644 --- a/projects/packages/explat/package.json +++ b/projects/packages/explat/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@automattic/jetpack-explat", - "version": "0.1.1-alpha", + "version": "0.1.1", "description": "A package for running A/B tests on the Experimentation Platform (ExPlat) in the plugin.", "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/packages/explat/#readme", "bugs": { diff --git a/projects/packages/explat/src/class-explat.php b/projects/packages/explat/src/class-explat.php index 1b8c025ff3575..5c6db2b653c1d 100644 --- a/projects/packages/explat/src/class-explat.php +++ b/projects/packages/explat/src/class-explat.php @@ -20,7 +20,7 @@ class ExPlat { * * @var string */ - const PACKAGE_VERSION = '0.1.1-alpha'; + const PACKAGE_VERSION = '0.1.1'; /** * Initializer. diff --git a/projects/plugins/social/changelog/prerelease b/projects/packages/forms/changelog/fix-contact-form-php-fatals similarity index 52% rename from projects/plugins/social/changelog/prerelease rename to projects/packages/forms/changelog/fix-contact-form-php-fatals index 7d3f9cba4bc0e..99822d7f43a14 100644 --- a/projects/plugins/social/changelog/prerelease +++ b/projects/packages/forms/changelog/fix-contact-form-php-fatals @@ -1,4 +1,5 @@ Significance: patch Type: fixed +Comment: Prevent PHP fatals + -Updated package dependencies. diff --git a/projects/packages/forms/changelog/update-react-19-compat-ReactDOM-render b/projects/packages/forms/changelog/update-react-19-compat-ReactDOM-render new file mode 100644 index 0000000000000..174e4e7d14779 --- /dev/null +++ b/projects/packages/forms/changelog/update-react-19-compat-ReactDOM-render @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +React compatibility: Changing ReactDOM.render usage to be via ReactDOM.createRoot. diff --git a/projects/packages/forms/package.json b/projects/packages/forms/package.json index 432cdad399939..549d8353a6076 100644 --- a/projects/packages/forms/package.json +++ b/projects/packages/forms/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@automattic/jetpack-forms", - "version": "0.32.6", + "version": "0.32.7-alpha", "description": "Jetpack Forms", "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/packages/forms/#readme", "bugs": { diff --git a/projects/packages/forms/src/class-jetpack-forms.php b/projects/packages/forms/src/class-jetpack-forms.php index 7bdfd4db58086..8b1e9f5842de9 100644 --- a/projects/packages/forms/src/class-jetpack-forms.php +++ b/projects/packages/forms/src/class-jetpack-forms.php @@ -15,7 +15,7 @@ */ class Jetpack_Forms { - const PACKAGE_VERSION = '0.32.6'; + const PACKAGE_VERSION = '0.32.7-alpha'; /** * Load the contact form module. diff --git a/projects/packages/forms/src/contact-form/class-admin.php b/projects/packages/forms/src/contact-form/class-admin.php index 5d37d93caac14..5ed484a355738 100644 --- a/projects/packages/forms/src/contact-form/class-admin.php +++ b/projects/packages/forms/src/contact-form/class-admin.php @@ -190,8 +190,8 @@ public function export_to_gdrive() { $grunion = Contact_Form_Plugin::init(); $export_data = $grunion->get_feedback_entries_from_post(); - $fields = array_keys( $export_data ); - $row_count = count( reset( $export_data ) ); + $fields = is_array( $export_data ) ? array_keys( $export_data ) : array(); + $row_count = ! is_array( $export_data ) || empty( $export_data ) ? 0 : count( reset( $export_data ) ); $sheet_data = array( $fields ); diff --git a/projects/packages/forms/src/contact-form/class-contact-form.php b/projects/packages/forms/src/contact-form/class-contact-form.php index bf8796bc5582d..eebfd3adef6eb 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form.php +++ b/projects/packages/forms/src/contact-form/class-contact-form.php @@ -310,7 +310,11 @@ public static function parse( $attributes, $content ) { "\n\n"; // Don't show the feedback details unless the nonce matches - if ( $feedback_id && isset( $_GET['_wpnonce'] ) && wp_verify_nonce( stripslashes( $_GET['_wpnonce'] ), "contact-form-sent-{$feedback_id}" ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + if ( + $feedback_id + && isset( $_GET['_wpnonce'] ) + && wp_verify_nonce( sanitize_key( wp_unslash( $_GET['_wpnonce'] ) ), "contact-form-sent-{$feedback_id}" ) + ) { $r_success_message .= self::success_message( $feedback_id, $form ); } @@ -1050,35 +1054,50 @@ public function process_submission() { // For each of the "standard" fields, grab their field label and value. if ( isset( $field_ids['name'] ) ) { - $field = $this->fields[ $field_ids['name'] ]; - $comment_author = Contact_Form_Plugin::strip_tags( - stripslashes( - /** This filter is already documented in core/wp-includes/comment-functions.php */ - apply_filters( 'pre_comment_author_name', addslashes( $field->value ) ) - ) - ); + $field = $this->fields[ $field_ids['name'] ]; + + if ( is_string( $field->value ) ) { + $comment_author = Contact_Form_Plugin::strip_tags( + stripslashes( + /** This filter is already documented in core/wp-includes/comment-functions.php */ + apply_filters( 'pre_comment_author_name', addslashes( $field->value ) ) + ) + ); + } elseif ( is_array( $field->value ) ) { + $field->value = ''; + } } if ( isset( $field_ids['email'] ) ) { - $field = $this->fields[ $field_ids['email'] ]; - $comment_author_email = Contact_Form_Plugin::strip_tags( - stripslashes( - /** This filter is already documented in core/wp-includes/comment-functions.php */ - apply_filters( 'pre_comment_author_email', addslashes( $field->value ) ) - ) - ); + $field = $this->fields[ $field_ids['email'] ]; + + if ( is_string( $field->value ) ) { + $comment_author_email = Contact_Form_Plugin::strip_tags( + stripslashes( + /** This filter is already documented in core/wp-includes/comment-functions.php */ + apply_filters( 'pre_comment_author_email', addslashes( $field->value ) ) + ) + ); + } elseif ( is_array( $field->value ) ) { + $field->value = ''; + } } if ( isset( $field_ids['url'] ) ) { - $field = $this->fields[ $field_ids['url'] ]; - $comment_author_url = Contact_Form_Plugin::strip_tags( - stripslashes( - /** This filter is already documented in core/wp-includes/comment-functions.php */ - apply_filters( 'pre_comment_author_url', addslashes( $field->value ) ) - ) - ); - if ( 'http://' === $comment_author_url ) { - $comment_author_url = ''; + $field = $this->fields[ $field_ids['url'] ]; + + if ( is_string( $field->value ) ) { + $comment_author_url = Contact_Form_Plugin::strip_tags( + stripslashes( + /** This filter is already documented in core/wp-includes/comment-functions.php */ + apply_filters( 'pre_comment_author_url', addslashes( $field->value ) ) + ) + ); + if ( 'http://' === $comment_author_url ) { + $comment_author_url = ''; + } + } elseif ( is_array( $field->value ) ) { + $field->value = ''; } } diff --git a/projects/packages/forms/src/dashboard/index.js b/projects/packages/forms/src/dashboard/index.js index 99229a79c9b55..703fd1545a373 100644 --- a/projects/packages/forms/src/dashboard/index.js +++ b/projects/packages/forms/src/dashboard/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { render } from '@wordpress/element'; +import { createRoot } from '@wordpress/element'; import { get } from 'lodash'; import { createHashRouter, Navigate, RouterProvider } from 'react-router-dom'; /** @@ -18,7 +18,7 @@ export const config = key => get( settings, key ); window.addEventListener( 'load', () => { const container = document.getElementById( 'jp-forms-dashboard' ); - settings = JSON.parse( unescape( container.dataset.config ) ); + settings = JSON.parse( decodeURIComponent( container.dataset.config ) ); delete container.dataset.config; const router = createHashRouter( [ @@ -36,5 +36,6 @@ window.addEventListener( 'load', () => { }, ] ); - render( , container ); + const root = createRoot( container ); + root.render( ); } ); diff --git a/projects/packages/image-cdn/changelog/fix-photon-amazon-cdn b/projects/packages/image-cdn/changelog/fix-photon-amazon-cdn new file mode 100644 index 0000000000000..d6b2aadf0439e --- /dev/null +++ b/projects/packages/image-cdn/changelog/fix-photon-amazon-cdn @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Do not serve media from Amazon CDN from Jetpack's CDN. diff --git a/projects/packages/image-cdn/src/class-image-cdn-core.php b/projects/packages/image-cdn/src/class-image-cdn-core.php index 9348576ab4737..d577eeb68dfe2 100644 --- a/projects/packages/image-cdn/src/class-image-cdn-core.php +++ b/projects/packages/image-cdn/src/class-image-cdn-core.php @@ -342,6 +342,7 @@ public static function banned_domains( $skip, $image_url ) { '/\.cdninstagram\.com$/', '/^(commons|upload)\.wikimedia\.org$/', '/\.wikipedia\.org$/', + '/^m\.media-amazon\.com$/', ); $host = wp_parse_url( $image_url, PHP_URL_HOST ); diff --git a/projects/packages/image-cdn/src/class-image-cdn.php b/projects/packages/image-cdn/src/class-image-cdn.php index d02fba3e2c8c7..9765c6a05a60f 100644 --- a/projects/packages/image-cdn/src/class-image-cdn.php +++ b/projects/packages/image-cdn/src/class-image-cdn.php @@ -12,7 +12,7 @@ */ final class Image_CDN { - const PACKAGE_VERSION = '0.4.3'; + const PACKAGE_VERSION = '0.4.4-alpha'; /** * Singleton. diff --git a/projects/packages/image-cdn/tests/php/test_class.image_cdn_core.php b/projects/packages/image-cdn/tests/php/test_class.image_cdn_core.php index d3bfb70912559..204075146cd8c 100644 --- a/projects/packages/image-cdn/tests/php/test_class.image_cdn_core.php +++ b/projects/packages/image-cdn/tests/php/test_class.image_cdn_core.php @@ -428,6 +428,10 @@ public function get_photon_domains() { true, 'https://en.wikipedia.org/wiki/File:MM10249.jpg', ), + 'Banned Amazon domain' => array( + true, + 'http://m.media-amazon.com/images/I/41YeeCMUwTL._SL300_.jpg', + ), ); } } diff --git a/projects/packages/jetpack-mu-wpcom/changelog/fix-masterbar-icons-overlap-in-mobile b/projects/packages/jetpack-mu-wpcom/changelog/fix-masterbar-icons-overlap-in-mobile new file mode 100644 index 0000000000000..800805205f65a --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/fix-masterbar-icons-overlap-in-mobile @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Masterbar: Fix icon overlap issue at smaller resolutions diff --git a/projects/packages/waf/changelog/fix-protect-global-stats-type-check b/projects/packages/jetpack-mu-wpcom/changelog/fix-rtl-admin-bar similarity index 54% rename from projects/packages/waf/changelog/fix-protect-global-stats-type-check rename to projects/packages/jetpack-mu-wpcom/changelog/fix-rtl-admin-bar index e4c7352e60e2b..e1d7a3d29a70c 100644 --- a/projects/packages/waf/changelog/fix-protect-global-stats-type-check +++ b/projects/packages/jetpack-mu-wpcom/changelog/fix-rtl-admin-bar @@ -1,4 +1,4 @@ Significance: patch Type: fixed -Fix global stats type check +Fix RTL admin bar diff --git a/projects/packages/jetpack-mu-wpcom/changelog/fix-wpcom-icon-color b/projects/packages/jetpack-mu-wpcom/changelog/fix-wpcom-icon-color new file mode 100644 index 0000000000000..692475dbeb8d0 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/fix-wpcom-icon-color @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Admin bar: help center and notification icons now follow color scheme diff --git a/projects/packages/jetpack-mu-wpcom/changelog/mu-wpcom-a8c-fse b/projects/packages/jetpack-mu-wpcom/changelog/mu-wpcom-a8c-fse new file mode 100644 index 0000000000000..5a016d496aca0 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/mu-wpcom-a8c-fse @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +MU WPCOM: Port FSE feature from ETK diff --git a/projects/packages/jetpack-mu-wpcom/changelog/remove-dead-css-code b/projects/packages/jetpack-mu-wpcom/changelog/remove-dead-css-code new file mode 100644 index 0000000000000..d9686a1d86f25 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/remove-dead-css-code @@ -0,0 +1,4 @@ +Significance: patch +Type: removed + +Removed dead CSS code diff --git a/projects/packages/jetpack-mu-wpcom/changelog/wpcom-block-editor-nux b/projects/packages/jetpack-mu-wpcom/changelog/wpcom-block-editor-nux new file mode 100644 index 0000000000000..44b012c3bcd33 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/wpcom-block-editor-nux @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Added wpcom-block-editor-nux feature from calypso's ETK package. diff --git a/projects/packages/jetpack-mu-wpcom/package.json b/projects/packages/jetpack-mu-wpcom/package.json index 40a68e6784eee..e4e684ca3ad91 100644 --- a/projects/packages/jetpack-mu-wpcom/package.json +++ b/projects/packages/jetpack-mu-wpcom/package.json @@ -48,12 +48,14 @@ "webpack-cli": "4.9.1" }, "dependencies": { + "@automattic/calypso-color-schemes": "3.1.3", "@automattic/color-studio": "2.6.0", "@automattic/i18n-utils": "1.2.3", "@automattic/jetpack-base-styles": "workspace:*", "@automattic/jetpack-shared-extension-utils": "workspace:*", "@automattic/typography": "1.0.0", "@automattic/page-pattern-modal": "1.1.5", + "@popperjs/core": "^2.11.8", "@preact/signals": "^1.2.2", "@sentry/browser": "7.80.1", "@tanstack/react-query": "^5.15.5", @@ -66,13 +68,16 @@ "@wordpress/element": "6.2.0", "@wordpress/hooks": "4.2.0", "@wordpress/i18n": "5.2.0", + "@wordpress/icons": "10.2.0", "@wordpress/plugins": "7.2.0", + "@wordpress/private-apis": "^1.2.0", "@wordpress/url": "4.2.0", - "@wordpress/icons": "10.2.0", "clsx": "2.1.1", + "debug": "4.3.4", "preact": "^10.13.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-popper": "^2.3.0", "redux": "^4.2.1", "redux-saga": "^1.3.0", "swiper": "^8.4.5", diff --git a/projects/packages/jetpack-mu-wpcom/src/assets/images/block-picker.svg b/projects/packages/jetpack-mu-wpcom/src/assets/images/block-picker.svg new file mode 100644 index 0000000000000..a7fe75f9d393e --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/assets/images/block-picker.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/packages/jetpack-mu-wpcom/src/assets/images/draft-post.svg b/projects/packages/jetpack-mu-wpcom/src/assets/images/draft-post.svg new file mode 100644 index 0000000000000..cf24d89ced98a --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/assets/images/draft-post.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/projects/packages/jetpack-mu-wpcom/src/assets/images/editor.svg b/projects/packages/jetpack-mu-wpcom/src/assets/images/editor.svg new file mode 100644 index 0000000000000..b3f080ffd46fd --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/assets/images/editor.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/packages/jetpack-mu-wpcom/src/assets/images/illo-share.svg b/projects/packages/jetpack-mu-wpcom/src/assets/images/illo-share.svg new file mode 100644 index 0000000000000..41b457cb64585 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/assets/images/illo-share.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/packages/jetpack-mu-wpcom/src/assets/images/post-published.svg b/projects/packages/jetpack-mu-wpcom/src/assets/images/post-published.svg new file mode 100644 index 0000000000000..3b55263b209af --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/assets/images/post-published.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/projects/packages/jetpack-mu-wpcom/src/assets/images/preview.svg b/projects/packages/jetpack-mu-wpcom/src/assets/images/preview.svg new file mode 100644 index 0000000000000..120e144993c70 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/assets/images/preview.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/projects/packages/jetpack-mu-wpcom/src/assets/images/private.svg b/projects/packages/jetpack-mu-wpcom/src/assets/images/private.svg new file mode 100644 index 0000000000000..6603602512258 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/assets/images/private.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/packages/jetpack-mu-wpcom/src/assets/images/product-published.svg b/projects/packages/jetpack-mu-wpcom/src/assets/images/product-published.svg new file mode 100644 index 0000000000000..258f49c3780c7 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/assets/images/product-published.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/packages/jetpack-mu-wpcom/src/assets/images/video-success.svg b/projects/packages/jetpack-mu-wpcom/src/assets/images/video-success.svg new file mode 100644 index 0000000000000..038f9f732ade2 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/assets/images/video-success.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php b/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php index aa7d9f2f259da..32dfd66c452ef 100644 --- a/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php +++ b/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php @@ -64,6 +64,9 @@ public static function init() { // Unified navigation fix for changes in WordPress 6.2. add_action( 'admin_enqueue_scripts', array( __CLASS__, 'unbind_focusout_on_wp_admin_bar_menu_toggle' ) ); + // Load the Map block settings. + add_action( 'enqueue_block_assets', array( __CLASS__, 'load_jetpack_mu_wpcom_settings' ), 999 ); + // Load the Map block settings. add_action( 'enqueue_block_assets', array( __CLASS__, 'load_map_block_settings' ), 999 ); @@ -160,11 +163,13 @@ public static function load_etk_features() { require_once __DIR__ . '/features/paragraph-block-placeholder/paragraph-block-placeholder.php'; require_once __DIR__ . '/features/tags-education/tags-education.php'; require_once __DIR__ . '/features/wpcom-block-description-links/wpcom-block-description-links.php'; + require_once __DIR__ . '/features/wpcom-block-editor-nux/class-wpcom-block-editor-nux.php'; require_once __DIR__ . '/features/wpcom-blocks/a8c-posts-list/a8c-posts-list.php'; require_once __DIR__ . '/features/wpcom-blocks/event-countdown/event-countdown.php'; require_once __DIR__ . '/features/wpcom-blocks/timeline/timeline.php'; require_once __DIR__ . '/features/wpcom-documentation-links/wpcom-documentation-links.php'; require_once __DIR__ . '/features/wpcom-global-styles/index.php'; + require_once __DIR__ . '/features/wpcom-legacy-fse/wpcom-legacy-fse.php'; require_once __DIR__ . '/features/wpcom-whats-new/wpcom-whats-new.php'; require_once __DIR__ . '/features/starter-page-templates/class-starter-page-templates.php'; } @@ -228,6 +233,35 @@ public static function load_wpcom_rest_api_endpoints() { } } + /** + * Adds a global variable containing the config of the plugin to the window object. + */ + public static function load_jetpack_mu_wpcom_settings() { + $handle = 'jetpack-mu-wpcom-settings'; + + // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.NotInFooter + wp_register_script( + $handle, + false, + array(), + true + ); + + $data = wp_json_encode( + array( + 'assetsUrl' => plugins_url( 'build/', self::BASE_FILE ), + ) + ); + + wp_add_inline_script( + $handle, + "var JETPACK_MU_WPCOM_SETTINGS = $data;", + 'before' + ); + + wp_enqueue_script( $handle ); + } + /** * Adds a global variable containing the map provider in a map_block_settings object to the window object. */ diff --git a/projects/packages/jetpack-mu-wpcom/src/common/index.php b/projects/packages/jetpack-mu-wpcom/src/common/index.php index 3e5d787915aca..3ce0ce6640bde 100644 --- a/projects/packages/jetpack-mu-wpcom/src/common/index.php +++ b/projects/packages/jetpack-mu-wpcom/src/common/index.php @@ -63,3 +63,20 @@ function wpcom_enqueue_tracking_scripts( string $handle ) { Tracking::register_tracks_functions_scripts( true ); } } + +/** + * Record tracks event. + * + * @param mixed $event_name The event. + * @param mixed $event_properties The event property. + * + * @return void + */ +function wpcom_record_tracks_event( $event_name, $event_properties ) { + if ( function_exists( 'wpcomsh_record_tracks_event' ) ) { + wpcomsh_record_tracks_event( $event_name, $event_properties ); + } elseif ( function_exists( 'require_lib' ) && function_exists( 'tracks_record_event' ) ) { + require_lib( 'tracks/client' ); + tracks_record_event( get_current_user_id(), $event_name, $event_properties ); + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/common/public-path.js b/projects/packages/jetpack-mu-wpcom/src/common/public-path.js new file mode 100644 index 0000000000000..cd1835effc44a --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/public-path.js @@ -0,0 +1,12 @@ +/* exported __webpack_public_path__ */ +/* global __webpack_public_path__ */ + +/** + * Dynamically set WebPack's publicPath so that split assets can be found. + * Unfortunately we can't set `publicPath: 'auto'` because WordPress.com Simple's JS concatenation breaks it (and other plugins that do JS concatenation probably would too). + * @see https://webpack.js.org/guides/public-path/#on-the-fly + */ +if ( typeof window === 'object' && window.JETPACK_MU_WPCOM_SETTINGS ) { + // eslint-disable-next-line no-global-assign + __webpack_public_path__ = window.JETPACK_MU_WPCOM_SETTINGS.assetsUrl; +} diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/keyboard-navigation.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/keyboard-navigation.tsx new file mode 100644 index 0000000000000..7d913d40d3a7f --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/keyboard-navigation.tsx @@ -0,0 +1,61 @@ +/** + * External dependencies + */ +import * as React from 'react'; +/** + * Internal dependencies + */ +import useFocusHandler from '../hooks/use-focus-handler'; +import useFocusTrap from '../hooks/use-focus-trap'; +import useKeydownHandler from '../hooks/use-keydown-handler'; + +interface Props { + onMinimize: () => void; + onDismiss: ( target: string ) => () => void; + onNextStepProgression: () => void; + onPreviousStepProgression: () => void; + tourContainerRef: React.MutableRefObject< null | HTMLElement >; + isMinimized: boolean; +} + +const KeyboardNavigation: React.FunctionComponent< Props > = ( { + onMinimize, + onDismiss, + onNextStepProgression, + onPreviousStepProgression, + tourContainerRef, + isMinimized, +} ) => { + /** + * Expand Tour Nav + */ + function ExpandedTourNav() { + useKeydownHandler( { + onEscape: onMinimize, + onArrowRight: onNextStepProgression, + onArrowLeft: onPreviousStepProgression, + } ); + useFocusTrap( tourContainerRef ); + + return null; + } + + /** + * Minimize Tour Nav + */ + function MinimizedTourNav() { + useKeydownHandler( { onEscape: onDismiss( 'esc-key-minimized' ) } ); + + return null; + } + + const isTourFocused = useFocusHandler( tourContainerRef ); + + if ( ! isTourFocused ) { + return null; + } + + return isMinimized ? : ; +}; + +export default KeyboardNavigation; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-frame.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-frame.tsx new file mode 100644 index 0000000000000..e030a37cdda26 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-frame.tsx @@ -0,0 +1,286 @@ +/** + * External Dependencies + */ +import { useViewportMatch } from '@wordpress/compose'; +import { useEffect, useState, useCallback, useMemo, useRef } from '@wordpress/element'; +import clsx from 'clsx'; +import { usePopper } from 'react-popper'; +/** + * Internal Dependencies + */ +import useStepTracking from '../hooks/use-step-tracking'; +import { classParser } from '../utils'; +import { liveResizeModifier } from '../utils/live-resize-modifier'; +import KeyboardNavigation from './keyboard-navigation'; +import TourKitMinimized from './tour-kit-minimized'; +import Overlay from './tour-kit-overlay'; +import Spotlight from './tour-kit-spotlight'; +import TourKitStep from './tour-kit-step'; +import type { Callback, Config } from '../types'; + +const handleCallback = ( currentStepIndex: number, callback?: Callback ) => { + typeof callback === 'function' && callback( currentStepIndex ); +}; + +interface Props { + config: Config; +} + +const TourKitFrame: React.FunctionComponent< Props > = ( { config } ) => { + const [ currentStepIndex, setCurrentStepIndex ] = useState( 0 ); + const [ initialFocusedElement, setInitialFocusedElement ] = useState< HTMLElement | null >( + null + ); + const [ isMinimized, setIsMinimized ] = useState( config.isMinimized ?? false ); + + const [ popperElement, setPopperElement ] = useState< HTMLElement | null >( null ); + const [ tourReady, setTourReady ] = useState( false ); + const tourContainerRef = useRef( null ); + const isMobile = useViewportMatch( 'mobile', '<' ); + const lastStepIndex = config.steps.length - 1; + const referenceElements = config.steps[ currentStepIndex ].referenceElements; + const referenceElementSelector = + referenceElements?.[ isMobile ? 'mobile' : 'desktop' ] || referenceElements?.desktop; + const referenceElement = referenceElementSelector + ? document.querySelector< HTMLElement >( referenceElementSelector ) + : null; + + useEffect( () => { + if ( config.isMinimized ) { + setIsMinimized( true ); + } + }, [ config.isMinimized ] ); + + const showArrowIndicator = useCallback( () => { + if ( config.options?.effects?.arrowIndicator === false ) { + return false; + } + + return !! ( referenceElement && ! isMinimized && tourReady ); + }, [ config.options?.effects?.arrowIndicator, isMinimized, referenceElement, tourReady ] ); + + const showSpotlight = useCallback( () => { + if ( ! config.options?.effects?.spotlight ) { + return false; + } + + return ! isMinimized; + }, [ config.options?.effects?.spotlight, isMinimized ] ); + + const showOverlay = useCallback( () => { + if ( showSpotlight() || ! config.options?.effects?.overlay ) { + return false; + } + + return ! isMinimized; + }, [ config.options?.effects?.overlay, isMinimized, showSpotlight ] ); + + const handleDismiss = useCallback( + ( source: string ) => { + return () => { + config.closeHandler( config.steps, currentStepIndex, source ); + }; + }, + [ config, currentStepIndex ] + ); + + const handleNextStepProgression = useCallback( () => { + let newStepIndex = currentStepIndex; + if ( lastStepIndex > currentStepIndex ) { + newStepIndex = currentStepIndex + 1; + setCurrentStepIndex( newStepIndex ); + } + handleCallback( newStepIndex, config.options?.callbacks?.onNextStep ); + }, [ config.options?.callbacks?.onNextStep, currentStepIndex, lastStepIndex ] ); + + const handlePreviousStepProgression = useCallback( () => { + let newStepIndex = currentStepIndex; + if ( currentStepIndex > 0 ) { + newStepIndex = currentStepIndex - 1; + setCurrentStepIndex( newStepIndex ); + } + handleCallback( newStepIndex, config.options?.callbacks?.onPreviousStep ); + }, [ config.options?.callbacks?.onPreviousStep, currentStepIndex ] ); + + const handleGoToStep = useCallback( + ( stepIndex: number ) => { + setCurrentStepIndex( stepIndex ); + handleCallback( stepIndex, config.options?.callbacks?.onGoToStep ); + }, + [ config.options?.callbacks?.onGoToStep ] + ); + + const handleMinimize = useCallback( () => { + setIsMinimized( true ); + handleCallback( currentStepIndex, config.options?.callbacks?.onMinimize ); + }, [ config.options?.callbacks?.onMinimize, currentStepIndex ] ); + + const handleMaximize = useCallback( () => { + setIsMinimized( false ); + handleCallback( currentStepIndex, config.options?.callbacks?.onMaximize ); + }, [ config.options?.callbacks?.onMaximize, currentStepIndex ] ); + + const { + styles: popperStyles, + attributes: popperAttributes, + update: popperUpdate, + } = usePopper( referenceElement, popperElement, { + strategy: 'fixed', + placement: config?.placement ?? 'bottom', + modifiers: [ + { + name: 'preventOverflow', + options: { + rootBoundary: 'document', + padding: 16, // same as the left/margin of the tour frame + }, + }, + { + name: 'arrow', + options: { + padding: 12, + }, + }, + { + name: 'offset', + options: { + offset: [ 0, showArrowIndicator() ? 12 : 10 ], + }, + }, + { + name: 'flip', + options: { + fallbackPlacements: [ 'top', 'left', 'right' ], + }, + }, + useMemo( + () => liveResizeModifier( config.options?.effects?.liveResize ), + [ config.options?.effects?.liveResize ] + ), + ...( config.options?.popperModifiers || [] ), + ], + } ); + + const stepRepositionProps = + ! isMinimized && referenceElement && tourReady + ? { + style: popperStyles?.popper, + ...popperAttributes?.popper, + } + : null; + + const arrowPositionProps = + ! isMinimized && referenceElement && tourReady + ? { + style: popperStyles?.arrow, + ...popperAttributes?.arrow, + } + : null; + + /* + * Focus first interactive element when step renders. + */ + useEffect( () => { + setTimeout( () => initialFocusedElement?.focus() ); + }, [ initialFocusedElement ] ); + + /* + * Fixes issue with Popper misplacing the instance on mount + * See: https://stackoverflow.com/questions/65585859/react-popper-incorrect-position-on-mount + */ + useEffect( () => { + // If no reference element to position step near + if ( ! referenceElement ) { + setTourReady( true ); + return; + } + + setTourReady( false ); + + if ( popperUpdate ) { + popperUpdate() + .then( () => setTourReady( true ) ) + .catch( () => setTourReady( true ) ); + } + }, [ popperUpdate, referenceElement ] ); + + useEffect( () => { + if ( referenceElement && config.options?.effects?.autoScroll ) { + referenceElement.scrollIntoView( config.options.effects.autoScroll ); + } + }, [ config.options?.effects?.autoScroll, referenceElement ] ); + + const classes = clsx( + 'tour-kit-frame', + isMobile ? 'is-mobile' : 'is-desktop', + { 'is-visible': tourReady }, + classParser( config.options?.classNames ) + ); + + useStepTracking( currentStepIndex, config.options?.callbacks?.onStepViewOnce ); + + useEffect( () => { + if ( config.options?.callbacks?.onStepView ) { + handleCallback( currentStepIndex, config.options?.callbacks?.onStepView ); + } + }, [ config.options?.callbacks?.onStepView, currentStepIndex ] ); + + return ( + <> + +
+ { showOverlay() && } + { showSpotlight() && ( + + ) } +
) } + > + { showArrowIndicator() && ( +
) } + /> + ) } + { ! isMinimized ? ( + + ) : ( + + ) } +
+
+ + ); +}; + +export default TourKitFrame; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-minimized.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-minimized.tsx new file mode 100644 index 0000000000000..6831fbf8f6051 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-minimized.tsx @@ -0,0 +1,30 @@ +/** + * Internal Dependencies + */ +import { MinimizedTourRendererProps } from '../types'; +import type { Config } from '../types'; + +interface Props extends MinimizedTourRendererProps { + config: Config; +} + +const TourKitMinimized: React.FunctionComponent< Props > = ( { + config, + steps, + currentStepIndex, + onMaximize, + onDismiss, +} ) => { + return ( +
+ +
+ ); +}; + +export default TourKitMinimized; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-overlay.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-overlay.tsx new file mode 100644 index 0000000000000..3c440bcdc2de7 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-overlay.tsx @@ -0,0 +1,20 @@ +/** + * External Dependencies + */ +import clsx from 'clsx'; + +interface Props { + visible: boolean; +} + +const TourKitOverlay: React.FunctionComponent< Props > = ( { visible } ) => { + return ( +
+ ); +}; + +export default TourKitOverlay; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-spotlight-interactivity.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-spotlight-interactivity.tsx new file mode 100644 index 0000000000000..61b7e94bca203 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-spotlight-interactivity.tsx @@ -0,0 +1,38 @@ +import { SPOTLIT_ELEMENT_CLASS } from './tour-kit-spotlight'; + +export interface SpotlightInteractivityConfiguration { + /** If true, the user will be allowed to interact with the spotlit element. Defaults to false. */ + enabled?: boolean; + /** + * This element is the root element within which all children will have + * pointer-events disabled during the tour. Defaults to '#wpwrap' + */ + rootElementSelector?: string; +} + +export const SpotlightInteractivity: React.VFC< SpotlightInteractivityConfiguration > = ( { + enabled = false, + rootElementSelector = '#wpwrap', +}: SpotlightInteractivityConfiguration ) => { + if ( ! enabled ) { + return null; + } + return ( + + ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-spotlight.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-spotlight.tsx new file mode 100644 index 0000000000000..81e9ed80ff131 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-spotlight.tsx @@ -0,0 +1,122 @@ +import { useMemo, useState, useEffect } from '@wordpress/element'; +import clsx from 'clsx'; +import { usePopper } from 'react-popper'; +import { LiveResizeConfiguration, liveResizeModifier } from '../utils/live-resize-modifier'; +import Overlay from './tour-kit-overlay'; +import { + SpotlightInteractivity, + SpotlightInteractivityConfiguration, +} from './tour-kit-spotlight-interactivity'; +import type { Rect, Placement } from '@popperjs/core'; + +export const SPOTLIT_ELEMENT_CLASS = 'wp-tour-kit-spotlit'; +interface Props { + referenceElement: HTMLElement | null; + styles?: React.CSSProperties; + interactivity?: SpotlightInteractivityConfiguration; + liveResize?: LiveResizeConfiguration; +} + +const TourKitSpotlight: React.FunctionComponent< Props > = ( { + referenceElement, + styles, + interactivity, + liveResize, +} ) => { + const [ popperElement, sePopperElement ] = useState< HTMLElement | null >( null ); + const referenceRect = referenceElement?.getBoundingClientRect(); + + const modifiers = [ + { + name: 'flip', + enabled: false, + }, + { + name: 'preventOverflow', + options: { + mainAxis: false, // true by default + }, + }, + useMemo( + () => ( { + name: 'offset', + options: { + offset: ( { + placement, + reference, + popper, + }: { + placement: Placement; + reference: Rect; + popper: Rect; + } ): [ number, number ] => { + if ( placement === 'bottom' ) { + return [ 0, -( reference.height + ( popper.height - reference.height ) / 2 ) ]; + } + return [ 0, 0 ]; + }, + }, + } ), + [] + ), + // useMemo because https://popper.js.org/react-popper/v2/faq/#why-i-get-render-loop-whenever-i-put-a-function-inside-the-popper-configuration + useMemo( () => { + return liveResizeModifier( liveResize ); + }, [ liveResize ] ), + ]; + + const { styles: popperStyles, attributes: popperAttributes } = usePopper( + referenceElement, + popperElement, + { + strategy: 'fixed', + placement: 'bottom', + modifiers, + } + ); + + const clipDimensions = referenceRect + ? { + width: `${ referenceRect.width }px`, + height: `${ referenceRect.height }px`, + } + : null; + + const clipRepositionProps = referenceElement + ? { + style: { + ...( clipDimensions && clipDimensions ), + ...popperStyles?.popper, + ...( styles && styles ), + }, + ...popperAttributes?.popper, + } + : null; + + /** + * Add a .wp-spotlit class to the referenced element so that we can + * apply CSS styles to it, for whatever purposes such as interactivity + */ + useEffect( () => { + referenceElement?.classList.add( SPOTLIT_ELEMENT_CLASS ); + return () => { + referenceElement?.classList.remove( SPOTLIT_ELEMENT_CLASS ); + }; + }, [ referenceElement ] ); + + return ( + <> + + +
) } + /> + + ); +}; + +export default TourKitSpotlight; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-step.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-step.tsx new file mode 100644 index 0000000000000..ded21b229ed03 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit-step.tsx @@ -0,0 +1,52 @@ +/** + * External Dependencies + */ +import { useViewportMatch } from '@wordpress/compose'; +import clsx from 'clsx'; +/** + * Internal Dependencies + */ +import { classParser } from '../utils'; +import type { Config, TourStepRendererProps } from '../types'; + +interface Props extends TourStepRendererProps { + config: Config; +} + +const TourKitStep: React.FunctionComponent< Props > = ( { + config, + steps, + currentStepIndex, + onMinimize, + onDismiss, + onNextStep, + onPreviousStep, + setInitialFocusedElement, + onGoToStep, +} ) => { + const isMobile = useViewportMatch( 'mobile', '<' ); + const classes = clsx( + 'tour-kit-step', + `is-step-${ currentStepIndex }`, + classParser( + config.steps[ currentStepIndex ].options?.classNames?.[ isMobile ? 'mobile' : 'desktop' ] + ) + ); + + return ( +
+ +
+ ); +}; + +export default TourKitStep; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit.tsx new file mode 100644 index 0000000000000..97d0fea0f31ec --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/components/tour-kit.tsx @@ -0,0 +1,40 @@ +import { createPortal, useEffect, useRef } from '@wordpress/element'; +import React from 'react'; +import { TourKitContextProvider } from '../contexts'; +import ErrorBoundary from '../error-boundary'; +import TourKitFrame from './tour-kit-frame'; +import type { Config } from '../types'; + +import '../styles.scss'; + +interface Props { + config: Config; + __temp__className?: string; +} + +const TourKit: React.FunctionComponent< Props > = ( { config, __temp__className } ) => { + const portalParent = useRef( document.createElement( 'div' ) ).current; + + useEffect( () => { + const classes = [ 'tour-kit', ...( __temp__className ? [ __temp__className ] : [] ) ]; + + portalParent.classList.add( ...classes ); + + const portalParentElement = config.options?.portalParentElement || document.body; + portalParentElement.appendChild( portalParent ); + + return () => { + portalParentElement.removeChild( portalParent ); + }; + }, [ __temp__className, portalParent, config.options?.portalParentElement ] ); + + return ( + + +
{ createPortal( , portalParent ) }
+
+
+ ); +}; + +export default TourKit; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/constants.ts b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/constants.ts new file mode 100644 index 0000000000000..b8522601535a0 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/constants.ts @@ -0,0 +1,3 @@ +// Copied from https://github.com/Automattic/wp-calypso/blob/ce1a376af4bcc8987855ced4c961efacda6e1d32/packages/onboarding/src/utils/flows.ts#L32-L33 +export const START_WRITING_FLOW = 'start-writing'; +export const DESIGN_FIRST_FLOW = 'design-first'; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/contexts/has-seen-seller-celebration-modal-context.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/contexts/has-seen-seller-celebration-modal-context.tsx new file mode 100644 index 0000000000000..94bc6c49e7e13 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/contexts/has-seen-seller-celebration-modal-context.tsx @@ -0,0 +1,68 @@ +import apiFetch from '@wordpress/api-fetch'; +import { useState, useEffect } from '@wordpress/element'; +import * as React from 'react'; +import { useContext } from 'react'; + +type HasSeenSCModalResult = { + has_seen_seller_celebration_modal: boolean; +}; + +type HasSeenSellerCelebrationModalContextType = { + hasSeenSellerCelebrationModal: boolean; + updateHasSeenSellerCelebrationModal: ( value: boolean ) => void; +}; + +const HasSeenSCModalContext = React.createContext< HasSeenSellerCelebrationModalContextType >( { + hasSeenSellerCelebrationModal: false, + // eslint-disable-next-line @typescript-eslint/no-empty-function + updateHasSeenSellerCelebrationModal: () => {}, +} ); + +export const useHasSeenSellerCelebrationModal = () => { + return useContext( HasSeenSCModalContext ); +}; + +export const HasSeenSellerCelebrationModalProvider: React.FC< { children: JSX.Element } > = + function ( { children } ) { + const [ hasSeenSellerCelebrationModal, setHasSeenSellerCelebrationModal ] = useState( false ); + + /** + * Fetch the value that whether the video celebration modal has been seen. + */ + function fetchHasSeenSellerCelebrationModal() { + apiFetch< HasSeenSCModalResult >( { + path: '/wpcom/v2/block-editor/has-seen-seller-celebration-modal', + } ) + .then( ( result: HasSeenSCModalResult ) => + setHasSeenSellerCelebrationModal( result.has_seen_seller_celebration_modal ) + ) + .catch( () => setHasSeenSellerCelebrationModal( false ) ); + } + + /** + * Update the value that whether the video celebration modal has been seen. + * + * @param value - The value to update. + */ + function updateHasSeenSellerCelebrationModal( value: boolean ) { + apiFetch( { + method: 'PUT', + path: '/wpcom/v2/block-editor/has-seen-seller-celebration-modal', + data: { has_seen_seller_celebration_modal: value }, + } ).finally( () => { + setHasSeenSellerCelebrationModal( true ); + } ); + } + + useEffect( () => { + fetchHasSeenSellerCelebrationModal(); + }, [] ); + + return ( + + { children } + + ); + }; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/contexts/has-seen-video-celebration-modal-context.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/contexts/has-seen-video-celebration-modal-context.tsx new file mode 100644 index 0000000000000..18b9345434ec6 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/contexts/has-seen-video-celebration-modal-context.tsx @@ -0,0 +1,68 @@ +import apiFetch from '@wordpress/api-fetch'; +import { useState, useEffect } from '@wordpress/element'; +import * as React from 'react'; +import { useContext } from 'react'; + +type HasSeenVCModalResult = { + has_seen_video_celebration_modal: boolean; +}; + +type HasSeenVideoCelebrationModalContextType = { + hasSeenVideoCelebrationModal: boolean; + updateHasSeenVideoCelebrationModal: ( value: boolean ) => void; +}; + +const HasSeenVCModalContext = React.createContext< HasSeenVideoCelebrationModalContextType >( { + hasSeenVideoCelebrationModal: false, + // eslint-disable-next-line @typescript-eslint/no-empty-function + updateHasSeenVideoCelebrationModal: () => {}, +} ); + +export const useHasSeenVideoCelebrationModal = () => { + return useContext( HasSeenVCModalContext ); +}; + +export const HasSeenVideoCelebrationModalProvider: React.FC< { children: JSX.Element } > = + function ( { children } ) { + const [ hasSeenVideoCelebrationModal, setHasSeenVideoCelebrationModal ] = useState( false ); + + useEffect( () => { + fetchHasSeenVideoCelebrationModal(); + }, [] ); + + /** + * Fetch the value that whether the video celebration modal has been seen. + */ + function fetchHasSeenVideoCelebrationModal() { + apiFetch< HasSeenVCModalResult >( { + path: '/wpcom/v2/block-editor/has-seen-video-celebration-modal', + } ) + .then( ( result: HasSeenVCModalResult ) => + setHasSeenVideoCelebrationModal( result.has_seen_video_celebration_modal ) + ) + .catch( () => setHasSeenVideoCelebrationModal( false ) ); + } + + /** + * Update the value that whether the video celebration modal has been seen. + * + * @param value - The value to update. + */ + function updateHasSeenVideoCelebrationModal( value: boolean ) { + apiFetch( { + method: 'PUT', + path: '/wpcom/v2/block-editor/has-seen-video-celebration-modal', + data: { has_seen_video_celebration_modal: value }, + } ).finally( () => { + setHasSeenVideoCelebrationModal( value ); + } ); + } + + return ( + + { children } + + ); + }; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/contexts/index.ts b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/contexts/index.ts new file mode 100644 index 0000000000000..21d21c340259d --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/contexts/index.ts @@ -0,0 +1,4 @@ +export * from './has-seen-seller-celebration-modal-context'; +export * from './has-seen-video-celebration-modal-context'; +export * from './should-show-first-post-published-modal-context'; +export * from './tour-kit-context'; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/contexts/should-show-first-post-published-modal-context.jsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/contexts/should-show-first-post-published-modal-context.jsx new file mode 100644 index 0000000000000..d9b5b30e8ee0f --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/contexts/should-show-first-post-published-modal-context.jsx @@ -0,0 +1,31 @@ +import { useSelect, useDispatch } from '@wordpress/data'; +import { useEffect } from '@wordpress/element'; +import * as React from 'react'; +import { useContext } from 'react'; + +const ShouldShowFPPModalContext = React.createContext( false ); + +export const useShouldShowFirstPostPublishedModal = () => { + return useContext( ShouldShowFPPModalContext ); +}; + +export const ShouldShowFirstPostPublishedModalProvider = ( { children } ) => { + const value = useSelect( + select => select( 'automattic/wpcom-welcome-guide' ).getShouldShowFirstPostPublishedModal(), + [] + ); + + const { fetchShouldShowFirstPostPublishedModal } = useDispatch( + 'automattic/wpcom-welcome-guide' + ); + + useEffect( () => { + fetchShouldShowFirstPostPublishedModal(); + }, [ fetchShouldShowFirstPostPublishedModal ] ); + + return ( + + { children } + + ); +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/contexts/tour-kit-context.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/contexts/tour-kit-context.tsx new file mode 100644 index 0000000000000..392911e574750 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/contexts/tour-kit-context.tsx @@ -0,0 +1,16 @@ +import { createContext, useContext } from '@wordpress/element'; +import type { Config } from '../types'; + +interface TourKitContext { + config: Config; +} + +const TourKitContext = createContext< TourKitContext >( {} as TourKitContext ); + +export const TourKitContextProvider: React.FunctionComponent< + TourKitContext & { children?: React.ReactNode } +> = ( { config, children } ) => { + return { children }; +}; + +export const useTourKitContext = (): TourKitContext => useContext( TourKitContext ); diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/error-boundary.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/error-boundary.tsx new file mode 100644 index 0000000000000..250ce916b1496 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/error-boundary.tsx @@ -0,0 +1,33 @@ +import React, { ErrorInfo } from 'react'; + +type State = { + hasError: boolean; +}; + +class ErrorBoundary extends React.Component< { children: React.ReactNode }, State > { + state = { + hasError: false, + }; + + static getDerivedStateFromError() { + // Update state so the next render will show the fallback UI. + return { hasError: true }; + } + + componentDidCatch( error: Error, errorInfo: ErrorInfo ) { + // You can also log the error to an error reporting service + // eslint-disable-next-line no-console + console.error( error, errorInfo ); + } + + render() { + if ( this.state.hasError ) { + // You can render any custom fallback UI + return

Something went wrong.

; + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/index.ts b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/index.ts new file mode 100644 index 0000000000000..7c2fc8e6c2b2b --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/index.ts @@ -0,0 +1,9 @@ +export { default as useFocusHandle } from './use-focus-handler'; +export { default as useFocusTrap } from './use-focus-trap'; +export { default as useHasSelectedPaymentBlockOnce } from './use-has-selected-payment-block-once'; +export { default as useKeydownHandler } from './use-keydown-handler'; +export { default as useShouldShowSellerCelebrationModal } from './use-should-show-seller-celebration-modal'; +export { default as useShouldShowVideoCelebrationModal } from './use-should-show-video-celebration-modal'; +export { default as useSiteIntent } from './use-site-intent'; +export { default as useSitePlan } from './use-site-plan'; +export { default as useStepTracking } from './use-step-tracking'; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-focus-handler.ts b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-focus-handler.ts new file mode 100644 index 0000000000000..7e54ac45673dc --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-focus-handler.ts @@ -0,0 +1,60 @@ +/** + * External Dependencies + */ +import { useEffect, useCallback, useState } from '@wordpress/element'; + +/** + * A hook that returns true/false if ref node receives focus by either tabbing or clicking into any of its children. + * @param ref - React.MutableRefObject< null | HTMLElement > + */ +const useFocusHandler = ( ref: React.MutableRefObject< null | HTMLElement > ): boolean => { + const [ hasFocus, setHasFocus ] = useState( false ); + + const handleFocus = useCallback( () => { + if ( document.hasFocus() && ref.current?.contains( document.activeElement ) ) { + setHasFocus( true ); + } else { + setHasFocus( false ); + } + }, [ ref ] ); + + const handleMousedown = useCallback( + ( event: MouseEvent ) => { + if ( ref.current?.contains( event.target as Node ) ) { + setHasFocus( true ); + } else { + setHasFocus( false ); + } + }, + [ ref ] + ); + + const handleKeyup = useCallback( + ( event: KeyboardEvent ) => { + if ( event.key === 'Tab' ) { + if ( ref.current?.contains( event.target as Node ) ) { + setHasFocus( true ); + } else { + setHasFocus( false ); + } + } + }, + [ ref ] + ); + + useEffect( () => { + document.addEventListener( 'focusin', handleFocus ); + document.addEventListener( 'mousedown', handleMousedown ); + document.addEventListener( 'keyup', handleKeyup ); + + return () => { + document.removeEventListener( 'focusin', handleFocus ); + document.removeEventListener( 'mousedown', handleMousedown ); + document.removeEventListener( 'keyup', handleKeyup ); + }; + }, [ ref, handleFocus, handleKeyup, handleMousedown ] ); + + return hasFocus; +}; + +export default useFocusHandler; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-focus-trap.ts b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-focus-trap.ts new file mode 100644 index 0000000000000..b8e0bc7b313a9 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-focus-trap.ts @@ -0,0 +1,56 @@ +/** + * External Dependencies + */ +import { focus } from '@wordpress/dom'; +import { useEffect, useCallback, useState } from '@wordpress/element'; +/** + * A hook that constraints tabbing/focus on focuable elements in the given element ref. + * @param ref - React.MutableRefObject< null | HTMLElement > + */ +const useFocusTrap = ( ref: React.MutableRefObject< null | HTMLElement > ): void => { + const [ firstFocusableElement, setFirstFocusableElement ] = useState< HTMLElement | undefined >(); + const [ lastFocusableElement, setLastFocusableElement ] = useState< HTMLElement | undefined >(); + + const handleTrapFocus = useCallback( + ( event: KeyboardEvent ) => { + let handled = false; + + if ( event.key === 'Tab' ) { + if ( event.shiftKey ) { + // Shift + Tab + if ( document.activeElement === firstFocusableElement ) { + lastFocusableElement?.focus(); + handled = true; + } + } else if ( document.activeElement === lastFocusableElement ) { + // Tab + firstFocusableElement?.focus(); + handled = true; + } + } + + if ( handled ) { + event.preventDefault(); + event.stopPropagation(); + } + }, + [ firstFocusableElement, lastFocusableElement ] + ); + + useEffect( () => { + const focusableElements = ref.current ? focus.focusable.find( ref.current as HTMLElement ) : []; + + if ( focusableElements && focusableElements.length ) { + setFirstFocusableElement( focusableElements[ 0 ] as HTMLElement ); + setLastFocusableElement( focusableElements[ focusableElements.length - 1 ] as HTMLElement ); + } + + document.addEventListener( 'keydown', handleTrapFocus ); + + return () => { + document.removeEventListener( 'keydown', handleTrapFocus ); + }; + }, [ ref, handleTrapFocus ] ); +}; + +export default useFocusTrap; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-has-selected-payment-block-once.js b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-has-selected-payment-block-once.js new file mode 100644 index 0000000000000..f8060b9a2d232 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-has-selected-payment-block-once.js @@ -0,0 +1,62 @@ +import { useSelect } from '@wordpress/data'; +import { useState, useEffect } from '@wordpress/element'; + +/** + * Watch the user's block selection and keep a note if they ever select a payments + * block. + * A payments block is any block with 'payments' in the name, like jetpack/simple-payments + * or jetpack/recurring-payments. + * Selecting a block whose direct parent has 'payments' in the name also counts. + * This is to account for clicking inside the button in a payments block, for example. + * @returns {boolean} Has the user selected a payments block (or a direct descendant) at least once? + */ +const useHasSelectedPaymentBlockOnce = () => { + const [ hasSelectedPaymentsOnce, setHasSelectedPaymentsOnce ] = useState( false ); + + // Get the name of the currently selected block + const selectedBlockName = useSelect( select => { + // Special case: We know we're returning true, so we don't need to find block names. + if ( hasSelectedPaymentsOnce ) { + return ''; + } + + const selectedBlock = select( 'core/block-editor' ).getSelectedBlock(); + return selectedBlock?.name ?? ''; + } ); + + // Get the name of the currently selected block's direct parent, if one exists + const parentSelectedBlockName = useSelect( select => { + // Special case: We know we're returning true, so we don't need to find block names. + if ( hasSelectedPaymentsOnce ) { + return ''; + } + + const selectedBlock = select( 'core/block-editor' ).getSelectedBlock(); + if ( selectedBlock?.clientId ) { + const parentIds = select( 'core/block-editor' ).getBlockParents( selectedBlock?.clientId ); + if ( parentIds && parentIds.length ) { + const parent = select( 'core/block-editor' ).getBlock( parentIds[ parentIds.length - 1 ] ); + return parent?.name ?? ''; + } + } + return ''; + } ); + + // On selection change, set hasSelectedPaymentsOnce=true if block name or parent's block name contains 'payments' + useEffect( () => { + if ( + ! hasSelectedPaymentsOnce && + ( selectedBlockName.includes( 'payments' ) || parentSelectedBlockName.includes( 'payments' ) ) + ) { + setHasSelectedPaymentsOnce( true ); + } + }, [ + selectedBlockName, + parentSelectedBlockName, + hasSelectedPaymentsOnce, + setHasSelectedPaymentsOnce, + ] ); + + return hasSelectedPaymentsOnce; +}; +export default useHasSelectedPaymentBlockOnce; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-keydown-handler.ts b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-keydown-handler.ts new file mode 100644 index 0000000000000..6ea129fe08f2e --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-keydown-handler.ts @@ -0,0 +1,52 @@ +/* eslint-disable jsdoc/require-param */ +/** + * External Dependencies + */ +import { useEffect, useCallback } from '@wordpress/element'; + +interface Props { + onEscape?: () => void; + onArrowRight?: () => void; + onArrowLeft?: () => void; +} + +/** + * A hook the applies the respective callbacks in response to keydown events. + */ +const useKeydownHandler = ( { onEscape, onArrowRight, onArrowLeft }: Props ): void => { + const handleKeydown = useCallback( + ( event: KeyboardEvent ) => { + let handled = false; + + switch ( event.key ) { + case 'Escape': + onEscape && ( onEscape(), ( handled = true ) ); + break; + case 'ArrowRight': + onArrowRight && ( onArrowRight(), ( handled = true ) ); + break; + case 'ArrowLeft': + onArrowLeft && ( onArrowLeft(), ( handled = true ) ); + break; + default: + break; + } + + if ( handled ) { + event.preventDefault(); + event.stopPropagation(); + } + }, + [ onEscape, onArrowRight, onArrowLeft ] + ); + + useEffect( () => { + document.addEventListener( 'keydown', handleKeydown ); + + return () => { + document.removeEventListener( 'keydown', handleKeydown ); + }; + }, [ handleKeydown ] ); +}; + +export default useKeydownHandler; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-should-show-seller-celebration-modal.js b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-should-show-seller-celebration-modal.js new file mode 100644 index 0000000000000..80f37b3b530cf --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-should-show-seller-celebration-modal.js @@ -0,0 +1,67 @@ +import { useSelect } from '@wordpress/data'; +import { useState, useEffect } from '@wordpress/element'; +import { useHasSeenSellerCelebrationModal } from '../contexts/has-seen-seller-celebration-modal-context'; +import useHasSelectedPaymentBlockOnce from './use-has-selected-payment-block-once'; +import useSiteIntent from './use-site-intent'; + +const useShouldShowSellerCelebrationModal = () => { + const [ shouldShowSellerCelebrationModal, setShouldShowSellerCelebrationModal ] = + useState( false ); + + const { siteIntent: intent } = useSiteIntent(); + const hasSelectedPaymentsOnce = useHasSelectedPaymentBlockOnce(); + + const { hasSeenSellerCelebrationModal } = useHasSeenSellerCelebrationModal(); + + const hasPaymentsBlock = useSelect( select => { + const isSiteEditor = !! select( 'core/edit-site' ); + + if ( isSiteEditor ) { + const page = select( 'core/edit-site' ).getPage(); + const pageId = parseInt( page?.context?.postId ); + const pageEntity = select( 'core' ).getEntityRecord( 'postType', 'page', pageId ); + + let paymentsBlock = false; + // Only check for payment blocks if we haven't seen the celebration modal text yet + if ( ! hasSeenSellerCelebrationModal ) { + const didCountRecurringPayments = + select( 'core/block-editor' ).getGlobalBlockCount( 'jetpack/recurring-payments' ) > 0; + const didCountSimplePayments = + select( 'core/block-editor' ).getGlobalBlockCount( 'jetpack/simple-payments' ) > 0; + paymentsBlock = + ( pageEntity?.content?.raw?.includes( '' ) || + pageEntity?.content?.raw?.includes( '' ) || + didCountRecurringPayments || + didCountSimplePayments ) ?? + false; + } + + return paymentsBlock; + } + + let paymentBlockCount = 0; + // Only check for payment blocks if we haven't seen the celebration modal yet + if ( ! hasSeenSellerCelebrationModal ) { + paymentBlockCount += select( 'core/block-editor' ).getGlobalBlockCount( + 'jetpack/recurring-payments' + ); + paymentBlockCount += + select( 'core/block-editor' ).getGlobalBlockCount( 'jetpack/simple-payments' ); + } + + return paymentBlockCount > 0; + } ); + + useEffect( () => { + if ( + intent === 'sell' && + hasPaymentsBlock && + hasSelectedPaymentsOnce && + ! hasSeenSellerCelebrationModal + ) { + setShouldShowSellerCelebrationModal( true ); + } + }, [ intent, hasPaymentsBlock, hasSelectedPaymentsOnce, hasSeenSellerCelebrationModal ] ); + return shouldShowSellerCelebrationModal; +}; +export default useShouldShowSellerCelebrationModal; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-should-show-video-celebration-modal.ts b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-should-show-video-celebration-modal.ts new file mode 100644 index 0000000000000..2bd32dbc881ae --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-should-show-video-celebration-modal.ts @@ -0,0 +1,44 @@ +import { useState, useEffect } from '@wordpress/element'; +import request from 'wpcom-proxy-request'; +import { useHasSeenVideoCelebrationModal } from '../contexts/has-seen-video-celebration-modal-context'; +import useSiteIntent from './use-site-intent'; + +interface Site { + options?: { + launchpad_checklist_tasks_statuses?: { + video_uploaded: boolean; + }; + }; +} +const useShouldShowVideoCelebrationModal = ( isEditorSaving: boolean ) => { + const { siteIntent: intent } = useSiteIntent(); + + const [ shouldShowVideoCelebrationModal, setShouldShowVideoCelebrationModal ] = useState( false ); + const { hasSeenVideoCelebrationModal } = useHasSeenVideoCelebrationModal(); + + useEffect( () => { + const maybeRenderVideoCelebrationModal = async () => { + // Get latest site options since the video may have just been uploaded. + const siteObj = ( await request( { + path: `/sites/${ window._currentSiteId }?http_envelope=1`, + apiVersion: '1.1', + } ) ) as Site; + + if ( siteObj?.options?.launchpad_checklist_tasks_statuses?.video_uploaded ) { + setShouldShowVideoCelebrationModal( true ); + } + }; + + if ( 'videopress' === intent && ! hasSeenVideoCelebrationModal ) { + maybeRenderVideoCelebrationModal(); + } else if ( hasSeenVideoCelebrationModal ) { + setShouldShowVideoCelebrationModal( false ); + } + }, [ + isEditorSaving, // included so that we check whether the video has been uploaded on save. + intent, + hasSeenVideoCelebrationModal, + ] ); + return shouldShowVideoCelebrationModal; +}; +export default useShouldShowVideoCelebrationModal; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-site-intent.js b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-site-intent.js new file mode 100644 index 0000000000000..496ee2bc23bbd --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-site-intent.js @@ -0,0 +1,14 @@ +// FIXME: We can use `useSiteIntent` from `@automattic/data-stores` and remove this. +// https://github.com/Automattic/wp-calypso/pull/73565#discussion_r1113839120 +const useSiteIntent = () => { + // We can skip the request altogether since this information is already added to the window in + // https://github.com/Automattic/jetpack/blob/e135711f9a130946dae1bca6c9c0967350331067/projects/plugins/jetpack/extensions/plugins/launchpad-save-modal/launchpad-save-modal.php#LL31C8-L31C34 + // We could update this to use the launchpad endpoint in jetpack-mu-wpcom, but that may require + // permissions changes as it requires 'manage_options' to read + // https://github.com/Automattic/jetpack/blob/e135711f9a130946dae1bca6c9c0967350331067/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad.php#L121. + return { + siteIntent: window.Jetpack_LaunchpadSaveModal?.siteIntentOption, + siteIntentFetched: true, + }; +}; +export default useSiteIntent; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-site-plan.js b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-site-plan.js new file mode 100644 index 0000000000000..d3c64400da453 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-site-plan.js @@ -0,0 +1,24 @@ +import { useState, useEffect } from '@wordpress/element'; +import { useCallback } from 'react'; +import request from 'wpcom-proxy-request'; + +const useSitePlan = siteIdOrSlug => { + const [ sitePlan, setSitePlan ] = useState( {} ); + + const fetchSite = useCallback( async () => { + const siteObj = await request( { + path: `/sites/${ siteIdOrSlug }?http_envelope=1`, + apiVersion: '1.1', + } ); + if ( siteObj?.plan ) { + setSitePlan( siteObj.plan ); + } + }, [ siteIdOrSlug ] ); + + useEffect( () => { + fetchSite(); + }, [ fetchSite ] ); + + return sitePlan; +}; +export default useSitePlan; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-step-tracking.ts b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-step-tracking.ts new file mode 100644 index 0000000000000..a1e231ace668c --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/hooks/use-step-tracking.ts @@ -0,0 +1,26 @@ +/** + * External Dependencies + */ +import { useState, useEffect } from '@wordpress/element'; +/** + * Internal Dependencies + */ +import { Callback } from '../types'; + +const useStepTracking = ( + currentStepIndex: number, + onStepViewOnce: Callback | undefined +): void => { + const [ stepsViewed, setStepsViewed ] = useState< number[] >( [] ); + + useEffect( () => { + if ( stepsViewed.includes( currentStepIndex ) ) { + return; + } + + setStepsViewed( prev => [ ...prev, currentStepIndex ] ); + onStepViewOnce?.( currentStepIndex ); + }, [ currentStepIndex, onStepViewOnce, stepsViewed ] ); +}; + +export default useStepTracking; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/index.ts b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/index.ts new file mode 100644 index 0000000000000..2cf54b34efa8a --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/index.ts @@ -0,0 +1,6 @@ +export { default } from './components/tour-kit'; +export * from './constants'; +export { default as WpcomTourKit, usePrefetchTourAssets } from './variants/wpcom'; +export * from './contexts'; +export * from './hooks'; +export * from './types'; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/styles.scss b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/styles.scss new file mode 100644 index 0000000000000..ef38a0c05b1e1 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/styles.scss @@ -0,0 +1,91 @@ +.tour-kit-frame { + visibility: hidden; + + &.is-visible { + visibility: visible; + } +} + +.tour-kit-frame__container { + border-radius: 2px; + bottom: 44px; + display: inline; + left: 16px; + position: fixed; + z-index: 9999; + // Avoid the text cursor when the text is not selectable + cursor: default; + box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.25); + background: var(--studio-white); +} + +.tour-kit-overlay { + position: fixed; + width: 100vw; + height: 100vh; + top: 0; + left: 0; + background: rgba(0, 0, 0); + opacity: 0; + + &.is-visible { + opacity: 0.5; + } +} + +.tour-kit-spotlight { + &.is-visible { + position: fixed; + overflow: hidden; + // box-shadow: 0 0 0 9999px rgba(0, 0, 255, 0.2); + outline: 99999px solid rgba(0, 0, 0, 0.5); + z-index: 1; + } +} + +.tour-kit-frame__arrow { + visibility: hidden; +} + +.tour-kit-frame__arrow, +.tour-kit-frame__arrow::before { + position: absolute; + width: 12px; + height: 12px; + background: inherit; + z-index: -1; +} + +.tour-kit-frame__arrow::before { + visibility: visible; + content: ""; + transform: rotate(45deg); +} + +.tour-kit-frame__container[data-popper-placement^="top"] > .tour-kit-frame__arrow { + bottom: -6px; + &::before { + box-shadow: 1px 1px 2px -1px rgb(0 0 0 / 25%); + } +} + +.tour-kit-frame__container[data-popper-placement^="bottom"] > .tour-kit-frame__arrow { + top: -6px; + &::before { + box-shadow: -1px -1px 2px -1px rgb(0 0 0 / 25%); + } +} + +.tour-kit-frame__container[data-popper-placement^="left"] > .tour-kit-frame__arrow { + right: -6px; + &::before { + box-shadow: 1px -1px 2px -1px rgb(0 0 0 / 25%); + } +} + +.tour-kit-frame__container[data-popper-placement^="right"] > .tour-kit-frame__arrow { + left: -6px; + &::before { + box-shadow: -1px 1px 2px -1px rgb(0 0 0 / 25%); + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/types.ts b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/types.ts new file mode 100644 index 0000000000000..3f4ec5c080a13 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/types.ts @@ -0,0 +1,164 @@ +import * as PopperJS from '@popperjs/core'; +import React from 'react'; +import { SpotlightInteractivityConfiguration } from './components/tour-kit-spotlight-interactivity'; +import { LiveResizeConfiguration } from './utils/live-resize-modifier'; +import type { Modifier } from 'react-popper'; + +export interface Step { + slug?: string; + referenceElements?: { + desktop?: string; + mobile?: string; + }; + meta: { + [ key: string ]: unknown; + // | React.FunctionComponent< Record< string, unknown > > + // | HTMLElement + // | string + // | ... + }; + options?: { + classNames?: { + /** + * `desktop` classes are applied when min-width is larger or equal to 480px. + */ + desktop?: string | string[]; + /** + * `mobile` classes are applied when max-width is smaller than 480px. + */ + mobile?: string | string[]; + }; + }; +} + +export interface TourStepRendererProps { + steps: Step[]; + currentStepIndex: number; + onDismiss: ( source: string ) => () => void; + onNextStep: () => void; + onPreviousStep: () => void; + onMinimize: () => void; + setInitialFocusedElement: React.Dispatch< React.SetStateAction< HTMLElement | null > >; + onGoToStep: ( stepIndex: number ) => void; +} + +export interface MinimizedTourRendererProps { + steps: Step[]; + currentStepIndex: number; + onMaximize: () => void; + onDismiss: ( source: string ) => () => void; +} + +export type TourStepRenderer = React.FunctionComponent< TourStepRendererProps >; +export type MinimizedTourRenderer = React.FunctionComponent< MinimizedTourRendererProps >; +export type Callback = ( currentStepIndex: number ) => void; +export type CloseHandler = ( steps: Step[], currentStepIndex: number, source: string ) => void; +export type PopperModifier = Partial< Modifier< unknown, Record< string, unknown > > >; + +export interface Callbacks { + onMinimize?: Callback; + onMaximize?: Callback; + onGoToStep?: Callback; + onNextStep?: Callback; + onPreviousStep?: Callback; + onStepViewOnce?: Callback; + onStepView?: Callback; +} + +export interface Options { + classNames?: string | string[]; + callbacks?: Callbacks; + /** An object to enable/disable/combine various tour effects, such as spotlight, overlay, and autoscroll */ + effects?: { + /** + * Adds a semi-transparent overlay and highlights the reference element + * when provided with a transparent box over it. The existence of this configuration + * key implies enabling the spotlight effect. + */ + spotlight?: { + /** An object that configures whether the user is allowed to interact with the referenced element during the tour */ + interactivity?: SpotlightInteractivityConfiguration; + /** CSS properties that configures the styles applied to the spotlight overlay */ + styles?: React.CSSProperties; + }; + /** Shows a little triangle that points to the referenced element. Defaults to true */ + arrowIndicator?: boolean; + /** + * Includes the semi-transparent overlay for all the steps Also blocks interactions for everything except the tour dialogues, + * including the referenced elements. Refer to spotlight interactivity configuration to affect this. + * + * Defaults to false, but if spotlight is enabled it implies this is enabled as well. + */ + overlay?: boolean; + /** Configures the autoscroll behaviour. Defaults to False. More information about the configuration at: https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView */ + autoScroll?: ScrollIntoViewOptions | boolean; + /** Configures the behaviour for automatically resizing the tour kit elements (TourKitFrame and Spotlight). Defaults to disabled. */ + liveResize?: LiveResizeConfiguration; + }; + popperModifiers?: PopperModifier[]; + portalParentElement?: HTMLElement | null; +} + +export interface Config { + steps: Step[]; + renderers: { + tourStep: TourStepRenderer; + tourMinimized: MinimizedTourRenderer; + }; + closeHandler: CloseHandler; + isMinimized?: boolean; + options?: Options; + placement?: PopperJS.Placement; +} + +export type Tour = React.FunctionComponent< { config: Config } >; + +/************************ + * WPCOM variant types: * + ************************/ + +export type OnTourRateCallback = ( currentStepIndex: number, liked: boolean ) => void; + +export interface WpcomStep extends Step { + meta: { + heading: string | null; + descriptions: { + desktop: string | React.ReactElement | null; + mobile: string | React.ReactElement | null; + }; + imgSrc?: { + desktop?: { + src: string; + type: string; + }; + mobile?: { + src: string; + type: string; + }; + }; + imgLink?: { + href: string; + playable?: boolean; + onClick?: () => void; + }; + }; +} + +export interface WpcomTourStepRendererProps extends TourStepRendererProps { + steps: WpcomStep[]; +} + +export interface WpcomOptions extends Options { + tourRating?: { + enabled: boolean; + useTourRating?: () => 'thumbs-up' | 'thumbs-down' | undefined; + onTourRate?: ( rating: 'thumbs-up' | 'thumbs-down' ) => void; + }; +} + +export interface WpcomConfig extends Omit< Config, 'renderers' > { + steps: WpcomStep[]; + options?: WpcomOptions; +} + +export type WpcomTour = React.FunctionComponent< { config: WpcomConfig } >; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/utils/index.ts b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/utils/index.ts new file mode 100644 index 0000000000000..3f00a820d1703 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/utils/index.ts @@ -0,0 +1,16 @@ +import debugFactory from 'debug'; + +/** + * Helper to convert CSV of `classes` to an array. + * @param classes - String or array of classes to format. + * @returns Array of classes + */ +export function classParser( classes?: string | string[] ): string[] | null { + if ( classes?.length ) { + return classes.toString().split( ',' ); + } + + return null; +} + +export const debug = debugFactory( 'tour-kit' ); diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/utils/live-resize-modifier.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/utils/live-resize-modifier.tsx new file mode 100644 index 0000000000000..ab1a14cf8568b --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/utils/live-resize-modifier.tsx @@ -0,0 +1,111 @@ +import { debug } from '../utils'; +import type { ModifierArguments, Options, State } from '@popperjs/core'; +import type { Modifier } from 'react-popper'; + +// Adds the resizeObserver and mutationObserver properties to the popper effect function argument +type ModifierArgumentsWithObserversProp = ModifierArguments< Options > & { + state: State & { + elements: State[ 'elements' ] & { + reference: State[ 'elements' ][ 'reference' ] & { + [ key: symbol ]: { + resizeObserver: ResizeObserver; + mutationObserver: MutationObserver; + }; + }; + }; + }; +}; + +export interface LiveResizeConfiguration { + /** CSS Selector for the the DOM node (and children) to observe for mutations */ + rootElementSelector?: string; + /** True to enable update on reference element resize, defaults to false */ + resize?: boolean; + /** True to enable update on node and subtree mutation, defaults to false. May be performance intensive */ + mutation?: boolean; +} + +type liveResizeModifierFactory = ( + params: LiveResizeConfiguration | undefined +) => Modifier< 'liveResizeModifier', Record< string, unknown > >; + +/** + * Function that returns a Popper modifier that observes the specified root element as well as + * reference element for any changes. The reason for being a currying function is so that + * we can customise the root element selector, otherwise observing at a higher than necessary + * level might cause unnecessary performance penalties. + * + * The Popper modifier queues an asynchronous update on the Popper instance whenever either of the + * Observers trigger its callback. + * + * @param config - The config. + * @param config.rootElementSelector - The selector of the root element. + * @param config.mutation - Whether to mutate. + * @param config.resize - Whether to resize. + * @returns custom Popper modifier. + */ +export const liveResizeModifier: liveResizeModifierFactory = ( + { rootElementSelector, mutation = false, resize = false }: LiveResizeConfiguration = { + mutation: false, + resize: false, + } +) => ( { + name: 'liveResizeModifier', + enabled: true, + phase: 'main', + fn: () => { + return; + }, + effect: arg0 => { + try { + const { state, instance } = arg0 as ModifierArgumentsWithObserversProp; // augment types here because we are mutating the properties on the argument that is passed in + + const ObserversProp = Symbol(); // use a symbol here so that we don't clash with multiple poppers using this modifier on the same reference node + const { reference } = state.elements; + + reference[ ObserversProp ] = { + resizeObserver: new ResizeObserver( () => { + instance.update(); + } ), + + mutationObserver: new MutationObserver( () => { + instance.update(); + } ), + }; + + if ( resize ) { + if ( reference instanceof Element ) { + reference[ ObserversProp ].resizeObserver.observe( reference ); + } else { + debug( + 'Error: ResizeObserver does not work with virtual elements, Tour Kit will not resize automatically if the size of the referenced element changes.' + ); + } + } + + if ( mutation ) { + const rootElementNode = document.querySelector( rootElementSelector || '#wpwrap' ); + if ( rootElementNode instanceof Element ) { + reference[ ObserversProp ].mutationObserver.observe( rootElementNode, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + } ); + } else { + debug( + `Error: ${ rootElementSelector } selector did not find a valid DOM element, Tour Kit will not update automatically if the DOM layout changes.` + ); + } + } + + return () => { + reference[ ObserversProp ].resizeObserver.disconnect(); + reference[ ObserversProp ].mutationObserver.disconnect(); + delete reference[ ObserversProp ]; + }; + } catch ( error ) { + debug( 'Error: Tour Kit live resize modifier failed unexpectedly:', error ); + } + }, +} ); diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-minimized.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-minimized.tsx new file mode 100644 index 0000000000000..1afd5ce37fca4 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-minimized.tsx @@ -0,0 +1,50 @@ +import { Button, Flex } from '@wordpress/components'; +import { createInterpolateElement } from '@wordpress/element'; +import { sprintf } from '@wordpress/i18n'; +import { Icon, close } from '@wordpress/icons'; +import { useI18n } from '@wordpress/react-i18n'; +import maximize from '../icons/maximize'; +import type { MinimizedTourRendererProps } from '../../../types'; + +const WpcomTourKitMinimized: React.FunctionComponent< MinimizedTourRendererProps > = ( { + steps, + onMaximize, + onDismiss, + currentStepIndex, +} ) => { + const { __ } = useI18n(); + const lastStepIndex = steps.length - 1; + const page = currentStepIndex + 1; + const numberOfPages = lastStepIndex + 1; + + return ( + + + + + ); +}; + +export default WpcomTourKitMinimized; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-pagination-control.scss b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-pagination-control.scss new file mode 100644 index 0000000000000..fe9a19fc4b508 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-pagination-control.scss @@ -0,0 +1,44 @@ +@import "@automattic/calypso-color-schemes"; + +.wpcom-tour-kit-pagination-control { + margin: 0; + display: flex; + justify-content: center; + width: 100%; + + li { + display: inline-flex; + margin: auto 4px; + height: 18px; + align-items: center; + border: none; + } + + li.pagination-control__last-item { + margin-left: auto; + height: 32px; + } + + button.pagination-control__page { + border: none; + padding: 0; + width: 6px; + height: 6px; + border-radius: 50%; + cursor: pointer; + transition: all 0.2s ease-in-out; + background-color: var(--color-neutral-10); + + &:hover { + background-color: var(--color-neutral-40); + } + + &:disabled { + cursor: default; + } + + &.is-current { + background-color: var(--wp-admin-theme-color); + } + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-pagination-control.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-pagination-control.tsx new file mode 100644 index 0000000000000..a37ce82c23b98 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-pagination-control.tsx @@ -0,0 +1,49 @@ +import { __, sprintf } from '@wordpress/i18n'; +import clsx from 'clsx'; +import './wpcom-tour-kit-pagination-control.scss'; + +interface Props { + onChange: ( page: number ) => void; + activePageIndex: number; + numberOfPages: number; + classNames?: string | string[]; + children?: React.ReactNode; +} + +const WpcomTourKitPaginationControl: React.FunctionComponent< Props > = ( { + activePageIndex, + numberOfPages, + onChange, + classNames, + children, +} ) => { + const classes = clsx( 'wpcom-tour-kit-pagination-control', classNames ); + + return ( +
    + { Array.from( { length: numberOfPages } ).map( ( value, index ) => ( +
  • +
  • + ) ) } + { children &&
  • { children }
  • } +
+ ); +}; + +export default WpcomTourKitPaginationControl; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-rating.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-rating.tsx new file mode 100644 index 0000000000000..82c20f1410019 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-rating.tsx @@ -0,0 +1,73 @@ +import { Button } from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import { useI18n } from '@wordpress/react-i18n'; +import clsx from 'clsx'; +import { useTourKitContext } from '../../../index'; +import thumbsDown from '../icons/thumbs_down'; +import thumbsUp from '../icons/thumbs_up'; +import type { WpcomConfig } from '../../../index'; + +const WpcomTourKitRating: React.FunctionComponent = () => { + const [ tempRating, setTempRating ] = useState< 'thumbs-up' | 'thumbs-down' >(); + const context = useTourKitContext(); + const config = context.config as unknown as WpcomConfig; + const tourRating = config.options?.tourRating?.useTourRating?.() ?? tempRating; + const { __ } = useI18n(); + + let isDisabled = false; + + if ( ! config.options?.tourRating?.enabled ) { + return null; + } + + // check is on tempRating to allow rerating in a restarted tour + if ( ! isDisabled && tempRating !== undefined ) { + isDisabled = true; + } + + const rateTour = ( isThumbsUp: boolean ) => { + if ( isDisabled ) { + return; + } + + const rating = isThumbsUp ? 'thumbs-up' : 'thumbs-down'; + + if ( rating !== tourRating ) { + isDisabled = true; + setTempRating( rating ); + config.options?.tourRating?.onTourRate?.( rating ); + } + }; + + return ( + <> +

+ { __( 'Did you find this guide helpful?', 'jetpack-mu-wpcom' ) } +

+
+
+ + ); +}; + +export default WpcomTourKitRating; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-step-card-navigation.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-step-card-navigation.tsx new file mode 100644 index 0000000000000..e324f0b2a4380 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-step-card-navigation.tsx @@ -0,0 +1,62 @@ +import { Button } from '@wordpress/components'; +import { useI18n } from '@wordpress/react-i18n'; +import WpcomTourKitPaginationControl from './wpcom-tour-kit-pagination-control'; +import type { WpcomTourStepRendererProps } from '../../../types'; + +type Props = Omit< WpcomTourStepRendererProps, 'onMinimize' >; + +const WpcomTourKitStepCardNavigation: React.FunctionComponent< Props > = ( { + currentStepIndex, + onDismiss, + onGoToStep, + onNextStep, + onPreviousStep, + setInitialFocusedElement, + steps, +} ) => { + const { __ } = useI18n(); + const isFirstStep = currentStepIndex === 0; + const lastStepIndex = steps.length - 1; + + return ( + <> + + { isFirstStep ? ( +
+ + +
+ ) : ( +
+ + +
+ ) } +
+ + ); +}; + +export default WpcomTourKitStepCardNavigation; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-step-card-overlay-controls.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-step-card-overlay-controls.tsx new file mode 100644 index 0000000000000..bfad1e0204dc7 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-step-card-overlay-controls.tsx @@ -0,0 +1,41 @@ +import { Button, Flex } from '@wordpress/components'; +import { close } from '@wordpress/icons'; +import { useI18n } from '@wordpress/react-i18n'; +import minimize from '../icons/minimize'; +import type { TourStepRendererProps } from '../../../types'; + +interface Props { + onMinimize: TourStepRendererProps[ 'onMinimize' ]; + onDismiss: TourStepRendererProps[ 'onDismiss' ]; +} + +const WpcomTourKitStepCardOverlayControls: React.FunctionComponent< Props > = ( { + onMinimize, + onDismiss, +} ) => { + const { __ } = useI18n(); + + return ( +
+ + + + +
+ ); +}; + +export default WpcomTourKitStepCardOverlayControls; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-step-card.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-step-card.tsx new file mode 100644 index 0000000000000..743d4f1cce9ac --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-step-card.tsx @@ -0,0 +1,109 @@ +import { Button, Card, CardBody, CardFooter, CardMedia } from '@wordpress/components'; +import { useViewportMatch } from '@wordpress/compose'; +import { Icon } from '@wordpress/icons'; +import { useI18n } from '@wordpress/react-i18n'; +import clsx from 'clsx'; +import WpcomTourKitRating from './wpcom-tour-kit-rating'; +import WpcomTourKitStepCardNavigation from './wpcom-tour-kit-step-card-navigation'; +import WpcomTourKitStepCardOverlayControls from './wpcom-tour-kit-step-card-overlay-controls'; +import type { WpcomTourStepRendererProps } from '../../../types'; + +const WpcomTourKitStepCard: React.FunctionComponent< WpcomTourStepRendererProps > = ( { + steps, + currentStepIndex, + onMinimize, + onDismiss, + onGoToStep, + onNextStep, + onPreviousStep, + setInitialFocusedElement, +} ) => { + const { __ } = useI18n(); + const lastStepIndex = steps.length - 1; + const { descriptions, heading, imgSrc, imgLink } = steps[ currentStepIndex ].meta; + const isLastStep = currentStepIndex === lastStepIndex; + const isMobile = useViewportMatch( 'mobile', '<' ); + const description = descriptions[ isMobile ? 'mobile' : 'desktop' ] ?? descriptions.desktop; + + return ( + + + { imgSrc && ( + + + { imgSrc.mobile && ( + + ) } + { + + { imgLink && ( + + + + + } + size={ 27 } + /> + + ) } + + ) } + +

{ heading }

+

+ { description } + { isLastStep ? ( + + ) : null } +

+
+ + { isLastStep ? ( + + ) : ( + + ) } + +
+ ); +}; + +export default WpcomTourKitStepCard; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-step.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-step.tsx new file mode 100644 index 0000000000000..24064230211d2 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit-step.tsx @@ -0,0 +1,28 @@ +import WpcomTourKitStepCard from './wpcom-tour-kit-step-card'; +import type { WpcomTourStepRendererProps } from '../../../types'; + +const WpcomTourKitStep: React.FunctionComponent< WpcomTourStepRendererProps > = ( { + steps, + currentStepIndex, + onDismiss, + onNextStep, + onPreviousStep, + onMinimize, + setInitialFocusedElement, + onGoToStep, +} ) => { + return ( + + ); +}; + +export default WpcomTourKitStep; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit.tsx new file mode 100644 index 0000000000000..863d20ac6745d --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/components/wpcom-tour-kit.tsx @@ -0,0 +1,29 @@ +import TourKit from '../../../components/tour-kit'; +import usePrefetchTourAssets from '../hooks/use-prefetch-tour-assets'; +import WpcomTourKitMinimized from './wpcom-tour-kit-minimized'; +import WpcomTourKitStep from './wpcom-tour-kit-step'; +import '../styles.scss'; +import type { WpcomConfig, TourStepRenderer } from '../../../types'; + +interface Props { + config: WpcomConfig; +} + +const WpcomTourKit: React.FunctionComponent< Props > = ( { config } ) => { + usePrefetchTourAssets( config.steps ); + + return ( + + ); +}; + +export default WpcomTourKit; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/hooks/use-prefetch-tour-assets.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/hooks/use-prefetch-tour-assets.tsx new file mode 100644 index 0000000000000..76db7335bf3eb --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/hooks/use-prefetch-tour-assets.tsx @@ -0,0 +1,16 @@ +import { useEffect } from '@wordpress/element'; +import type { WpcomStep } from '../../../types'; + +/** + * The hook to prefetch the assets of the tour + * + * @param steps - The steps that require assets. + */ +export default function usePrefetchTourAssets( steps: WpcomStep[] ): void { + useEffect( () => { + steps.forEach( step => { + step.meta.imgSrc?.mobile && ( new window.Image().src = step.meta.imgSrc.mobile.src ); + step.meta.imgSrc?.desktop && ( new window.Image().src = step.meta.imgSrc.desktop.src ); + } ); + }, [ steps ] ); +} diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/icons/maximize.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/icons/maximize.tsx new file mode 100644 index 0000000000000..0d0ee00282e4c --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/icons/maximize.tsx @@ -0,0 +1,13 @@ +import { SVG, Path } from '@wordpress/primitives'; + +const minimize = ( + + + +); + +export default minimize; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/icons/minimize.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/icons/minimize.tsx new file mode 100644 index 0000000000000..e844c2cd1fc64 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/icons/minimize.tsx @@ -0,0 +1,14 @@ +import { SVG, Path } from '@wordpress/primitives'; + +const minimize = ( + + + +); + +export default minimize; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/icons/thumbs_down.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/icons/thumbs_down.tsx new file mode 100644 index 0000000000000..784e6668db965 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/icons/thumbs_down.tsx @@ -0,0 +1,14 @@ +import { SVG, Path } from '@wordpress/primitives'; + +const thumbsDown = ( + + + +); + +export default thumbsDown; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/icons/thumbs_up.tsx b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/icons/thumbs_up.tsx new file mode 100644 index 0000000000000..d57b147512eac --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/icons/thumbs_up.tsx @@ -0,0 +1,14 @@ +import { SVG, Path } from '@wordpress/primitives'; + +const thumbsUp = ( + + + +); + +export default thumbsUp; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/index.ts b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/index.ts new file mode 100644 index 0000000000000..f092f4ed3fa54 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/index.ts @@ -0,0 +1,2 @@ +export { default } from './components/wpcom-tour-kit'; +export { default as usePrefetchTourAssets } from './hooks/use-prefetch-tour-assets'; diff --git a/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/styles.scss b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/styles.scss new file mode 100644 index 0000000000000..56483b6839201 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/common/tour-kit/variants/wpcom/styles.scss @@ -0,0 +1,232 @@ +@use "sass:math"; +@import "@wordpress/base-styles/colors"; +@import "@wordpress/base-styles/mixins"; +@import "@wordpress/base-styles/variables"; +@import "@wordpress/base-styles/z-index"; + +$wpcom-tour-kit-step-card-overlay-controls-button-bg-color: #32373c; // former $dark-gray-700. TODO: replace with standard color + +.wpcom-tour-kit-minimized { + border-radius: 2px; + box-shadow: + 0 2px 6px rgba(60, 66, 87, 0.08), + 0 0 0 1px rgba(60, 66, 87, 0.16), + 0 1px 1px rgba(0, 0, 0, 0.08); + background-color: $white; + color: $black; + + .components-button { + height: 44px; + + .wpcom-tour-kit-minimized__tour-index { + color: $gray-600; + } + + svg { + color: #50575e; + } + + &:hover { + .wpcom-tour-kit-minimized__tour-index, + svg { + color: inherit; + } + } + } +} + +.wpcom-tour-kit-step-card__heading { + font-size: 1.125rem; /* stylelint-disable-line scales/font-sizes */ + margin: 0.5rem 0; +} + +.wpcom-tour-kit-step-card__description { + font-size: 0.875rem; + /* stylelint-disable-next-line declaration-property-unit-allowed-list */ + line-height: 1.5rem; + margin: 0; + + .components-button { + height: auto; + line-height: 1; + text-decoration: underline; + padding: 0 0 0 4px; + } +} + +// @todo clk - update? +.wpcom-tour-kit .tour-kit-frame__container { + box-shadow: none; +} + +.wpcom-tour-kit-step-card { + width: 416px; + max-width: 92vw; + + &.wpcom-tour-kit-step-card.is-elevated { + box-shadow: rgba(0, 0, 0, 0.1) 0 0 0 1px, rgba(0, 0, 0, 0.1) 0 2px 4px 0; + } + + &.components-card { + border: none; + border-radius: 4px; + box-shadow: none; + } + + .components-card__body { + min-height: 114px; + } + + .components-card__body, + .components-card__footer { + border-top: none; + padding: $grid-unit-20 !important; + } + + .components-card__footer { + .wpcom-tour-kit-rating__end-text { + color: $gray-600; + font-size: 0.875rem; + font-style: italic; + } + + .wpcom-tour-kit-rating__end-icon.components-button.has-icon { + background-color: #f6f7f7; + border-radius: 50%; + color: $gray-600; + margin-left: 8px; + + path { + fill: $gray-600; + } + + &.active { + background-color: $black; + opacity: 1; + + path { + fill: $white; + } + } + } + } + + .components-card__media { + height: 0; + padding-top: math.percentage(math.div(math.ceil(math.div(1, 1.53) * 100), 100)); // img width:height ratio (1:1.53) + position: relative; + width: 100%; + + img { + left: 0; + position: absolute; + top: 0; + width: 100%; + } + } + + .components-guide__page-control { + margin: 0; + + .components-button { + min-width: auto; + &.has-icon { + padding: 3px; + } + } + + li { + margin-bottom: 0; + } + } +} + +.wpcom-tour-kit-step-card-overlay-controls__minimize-icon svg { + position: relative; + left: -2px; +} + +.wpcom-tour-kit-step-card-overlay-controls { + left: 0; + padding: $grid-unit-15; + right: 0; + z-index: 1; // z-index is needed because overlay controls are written before components-card__media, and so ends up under the image + + .components-button { + width: 32px; + min-width: 32px; + height: 32px; + background: $wpcom-tour-kit-step-card-overlay-controls-button-bg-color; + transition: opacity 200ms; + opacity: 0.7; + + &:active { + opacity: 0.9; + } + } + + @media (hover: hover) and (pointer: fine) { + // styles only applicable for hoverable viewports with precision pointing devices connected (eg: mouse) + .components-button { + opacity: 0; + } + + .tour-kit-frame__container:hover &, + .tour-kit-frame__container:focus-within & { + .components-button { + opacity: 0.7; + + &:hover, + &:focus { + opacity: 0.9; + } + } + } + } +} + +.wpcom-tour-kit-step-card-navigation__next-btn { + margin-left: $grid-unit-15; + justify-content: center; + min-width: 85px; +} + +.wpcom-tour-kit-step-card__media { + position: relative; +} + +// TODO: Remove once @wordpress/components/src/card/styles/card-styles.js is updated +.wpcom-tour-kit-step-card__media img { + display: block; + height: auto; + max-width: 100%; + width: 100%; +} + +.wpcom-tour-kit-step-card__media-link { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + + svg { + display: none; + transition: transform 0.15s ease-in-out; + + &:hover { + transform: scale(1.05); + } + } + + &--playable { + background-color: rgba(0, 0, 0, 0.5); + + svg { + display: block; + } + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-bar/wpcom-admin-bar.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-bar/wpcom-admin-bar.php index 5d429b09c7a10..001ccc09535dc 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-bar/wpcom-admin-bar.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-bar/wpcom-admin-bar.php @@ -90,10 +90,20 @@ function wpcom_enqueue_admin_bar_assets() { wp_add_inline_style( 'wpcom-admin-bar', <<.ab-item .ab-icon:before { + margin: 0 7px 0 1px; + } + } } /** @@ -73,6 +96,7 @@ justify-content: center; text-indent: 0; font-size: 0; + width: 52px; } & a.ab-item:before { @@ -82,6 +106,25 @@ mask-size: contain; } } + @media (max-width: 480px) { + & a.ab-item { + width: 46px; + } + & a.ab-item:before { + margin: 0 5px; + } + } +} + +#wpadminbar #wp-admin-bar-site-name>.ab-item:before { + /** + * Always show the House icon by the site name. + */ + content: "\f102" !important; + + @media (max-width: 480px) { + max-width: 46px; + } } /** @@ -96,6 +139,14 @@ } } + // Move the secondary admin bar to the left side if the site is RTL. + body.rtl & { + #wp-admin-bar-top-secondary { + left: 0; + right: inherit; + } + } + #wp-admin-bar-top-secondary { float: none; position: absolute; @@ -110,17 +161,14 @@ } @media (max-width: 782px) { + height: 31px; + #wp-admin-bar-help-center { display: block !important; width: 52px !important; margin-right: 0 !important; .ab-item { width: 52px; - svg { - width: 36px; - height: 36px; - padding: 4px 8px; - } } } @@ -141,7 +189,7 @@ right: 0; width: 36px !important; height: 40px !important; - background-size: contain; + background-size: contain; } } } @@ -151,12 +199,19 @@ } } } - } -} + @media (max-width: 480px) { + #wp-admin-bar-notes { + width: 46px !important; + } + #wp-admin-bar-my-account { + .ab-item { + width: 46px; -/** - * Always show the House icon by the site name. - */ -#wpadminbar #wp-admin-bar-site-name>.ab-item:before { - content: "\f102" !important; + img { + right: 7px; + } + } + } + } + } } diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/README.md b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/README.md new file mode 100644 index 0000000000000..4c7e0026936e7 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/README.md @@ -0,0 +1,7 @@ +# Nux Welcome Tour Modal + +A help tour to show new users some of the basics of using the editor. + +## Testing Instructions + +Instructions for testing the modal and its variants, and for resetting the state of `nux-status` so that the modal is shown again can be found on the PR [#47779](https://github.com/Automattic/wp-calypso/pull/47779) diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wp-rest-wpcom-block-editor-first-post-published-modal-controller.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wp-rest-wpcom-block-editor-first-post-published-modal-controller.php new file mode 100644 index 0000000000000..504a678843e09 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wp-rest-wpcom-block-editor-first-post-published-modal-controller.php @@ -0,0 +1,75 @@ +namespace = 'wpcom/v2'; + $this->rest_base = 'block-editor/should-show-first-post-published-modal'; + } + + /** + * Register available routes. + */ + public function register_rest_route() { + register_rest_route( + $this->namespace, + $this->rest_base, + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'should_show_first_post_published_modal' ), + 'permission_callback' => array( $this, 'permission_callback' ), + ), + ) + ); + } + + /** + * Callback to determine whether the request can proceed. + * + * @return boolean + */ + public function permission_callback() { + return current_user_can( 'read' ); + } + + /** + * Should we show the first post published modal + * + * @return \WP_REST_Response + */ + public function should_show_first_post_published_modal() { + // As we has synced the `has_never_published_post` option to part of atomic sites but we cannot + // update the value now, always return false to avoid showing the modal at every publishing until + // we can update the value on atomic sites. See D69932-code. + if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) { + return rest_ensure_response( + array( + 'should_show_first_post_published_modal' => false, + ) + ); + } + + $has_never_published_post = (bool) get_option( 'has_never_published_post', false ); + $intent = get_option( 'site_intent', '' ); + $should_show_first_post_published_modal = $has_never_published_post && 'write' === $intent; + + return rest_ensure_response( + array( + 'should_show_first_post_published_modal' => $should_show_first_post_published_modal, + ) + ); + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wp-rest-wpcom-block-editor-nux-status-controller.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wp-rest-wpcom-block-editor-nux-status-controller.php new file mode 100644 index 0000000000000..9f8930d7392dd --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wp-rest-wpcom-block-editor-nux-status-controller.php @@ -0,0 +1,126 @@ +namespace = 'wpcom/v2'; + $this->rest_base = 'block-editor/nux'; + } + + /** + * Register available routes. + */ + public function register_rest_route() { + register_rest_route( + $this->namespace, + $this->rest_base, + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_nux_status' ), + 'permission_callback' => array( $this, 'permission_callback' ), + ), + array( + 'methods' => \WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_nux_status' ), + 'permission_callback' => array( $this, 'permission_callback' ), + ), + ) + ); + } + + /** + * Callback to determine whether the request can proceed. + * + * @return boolean + */ + public function permission_callback() { + return is_user_logged_in(); + } + + /** + * Should we show the wpcom welcome guide (i.e. welcome tour or nux modal) + * + * @param mixed $nux_status Can be "enabled", "dismissed", or undefined. + * @return boolean + */ + public function show_wpcom_welcome_guide( $nux_status ) { + return 'enabled' === $nux_status; + } + + /** + * Return the WPCOM NUX status + * + * This is only called for sites where the user hasn't already dismissed the tour. + * Once the tour has been dismissed, the closed state is saved in local storage (for the current site) + * see src/block-editor-nux.js + * + * @return \WP_REST_Response + */ + public function get_nux_status() { + + $should_open_patterns_panel = (bool) get_option( 'was_created_with_blank_canvas_design' ); + + if ( $should_open_patterns_panel ) { + $variant = 'blank-canvas-tour'; + } else { + $variant = 'tour'; + } + + if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) { + $is_p2 = false; + } else { + $blog_id = get_current_blog_id(); + $is_p2 = \WPForTeams\is_wpforteams_site( $blog_id ); + } + + if ( $is_p2 ) { + // disable welcome tour for authoring P2s. + // see: https://github.com/Automattic/wp-calypso/issues/62973. + $nux_status = 'disabled'; + } elseif ( has_filter( 'wpcom_block_editor_nux_get_status' ) ) { + $nux_status = apply_filters( 'wpcom_block_editor_nux_get_status', false ); + } elseif ( ! metadata_exists( 'user', get_current_user_id(), 'wpcom_block_editor_nux_status' ) ) { + $nux_status = 'enabled'; + } else { + $nux_status = get_user_meta( get_current_user_id(), 'wpcom_block_editor_nux_status', true ); + } + + $show_welcome_guide = $this->show_wpcom_welcome_guide( $nux_status ); + + return rest_ensure_response( + array( + 'show_welcome_guide' => $show_welcome_guide, + 'variant' => $variant, + ) + ); + } + + /** + * Update the WPCOM NUX status + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response + */ + public function update_nux_status( $request ) { + $params = $request->get_json_params(); + $nux_status = $params['show_welcome_guide'] ? 'enabled' : 'dismissed'; + if ( has_action( 'wpcom_block_editor_nux_update_status' ) ) { + do_action( 'wpcom_block_editor_nux_update_status', $nux_status ); + } + update_user_meta( get_current_user_id(), 'wpcom_block_editor_nux_status', $nux_status ); + return rest_ensure_response( array( 'show_welcome_guide' => $this->show_wpcom_welcome_guide( $nux_status ) ) ); + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wp-rest-wpcom-block-editor-seller-celebration-modal-controller.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wp-rest-wpcom-block-editor-seller-celebration-modal-controller.php new file mode 100644 index 0000000000000..6e4045465bd4a --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wp-rest-wpcom-block-editor-seller-celebration-modal-controller.php @@ -0,0 +1,101 @@ +namespace = 'wpcom/v2'; + $this->rest_base = 'block-editor/has-seen-seller-celebration-modal'; + $this->wpcom_is_site_specific_endpoint = true; + $this->wpcom_is_wpcom_only_endpoint = true; + } + + /** + * Register available routes. + */ + public function register_rest_route() { + register_rest_route( + $this->namespace, + $this->rest_base, + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'has_seen_seller_celebration_modal' ), + 'permission_callback' => array( $this, 'permission_callback' ), + ), + ) + ); + register_rest_route( + $this->namespace, + $this->rest_base, + array( + array( + 'methods' => \WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'set_has_seen_seller_celebration_modal' ), + 'permission_callback' => array( $this, 'permission_callback' ), + 'args' => array( + 'has_seen_seller_celebration_modal' => array( + 'required' => true, + 'type' => 'boolean', + ), + ), + ), + ) + ); + } + + /** + * Callback to determine whether the request can proceed. + * + * @return boolean + */ + public function permission_callback() { + return current_user_can( 'read' ); + } + + /** + * Whether the user has seen the seller celebration modal + * + * @return \WP_REST_Response + */ + public function has_seen_seller_celebration_modal() { + // See D69932-code and apps/editing-toolkit/editing-toolkit-plugin/wpcom-block-editor-nux/class-wp-rest-wpcom-block-editor-first-post-published-modal-controller.php. + if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) { + return rest_ensure_response( + array( + 'has_seen_seller_celebration_modal' => false, + ) + ); + } + $has_seen_seller_celebration_modal = (bool) get_option( 'has_seen_seller_celebration_modal', false ); + + return rest_ensure_response( + array( + 'has_seen_seller_celebration_modal' => $has_seen_seller_celebration_modal, + ) + ); + } + + /** + * Update the option for whether the user has seen the seller celebration modal. + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response + */ + public function set_has_seen_seller_celebration_modal( $request ) { + $params = $request->get_json_params(); + update_option( 'has_seen_seller_celebration_modal', $params['has_seen_seller_celebration_modal'] ); + return rest_ensure_response( array( 'has_seen_seller_celebration_modal' => $params['has_seen_seller_celebration_modal'] ) ); + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wp-rest-wpcom-block-editor-sharing-modal-controller.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wp-rest-wpcom-block-editor-sharing-modal-controller.php new file mode 100644 index 0000000000000..8e1f319808f2b --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wp-rest-wpcom-block-editor-sharing-modal-controller.php @@ -0,0 +1,72 @@ +namespace = 'wpcom/v2'; + $this->rest_base = 'block-editor/sharing-modal-dismissed'; + } + + /** + * Register available routes. + */ + public function register_rest_route() { + register_rest_route( + $this->namespace, + $this->rest_base, + array( + array( + 'methods' => \WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'set_wpcom_sharing_modal_dismissed' ), + 'permission_callback' => array( $this, 'permission_callback' ), + ), + ) + ); + } + + /** + * Callback to determine whether the request can proceed. + * + * @return boolean + */ + public function permission_callback() { + return current_user_can( 'read' ); + } + + /** + * Get the sharing modal dismissed status + * + * @return boolean + */ + public static function get_wpcom_sharing_modal_dismissed() { + $old_sharing_modal_dismissed = (bool) get_option( 'sharing_modal_dismissed', false ); + if ( $old_sharing_modal_dismissed ) { + return true; + } + return (bool) get_option( 'wpcom_sharing_modal_dismissed', false ); + } + + /** + * Dismiss the sharing modal + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response + */ + public function set_wpcom_sharing_modal_dismissed( $request ) { + $params = $request->get_json_params(); + update_option( 'wpcom_sharing_modal_dismissed', $params['wpcom_sharing_modal_dismissed'] ); + return rest_ensure_response( array( 'wpcom_sharing_modal_dismissed' => $this->get_wpcom_sharing_modal_dismissed() ) ); + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wp-rest-wpcom-block-editor-video-celebration-modal-controller.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wp-rest-wpcom-block-editor-video-celebration-modal-controller.php new file mode 100644 index 0000000000000..c74f8dcdf51ed --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wp-rest-wpcom-block-editor-video-celebration-modal-controller.php @@ -0,0 +1,101 @@ +namespace = 'wpcom/v2'; + $this->rest_base = 'block-editor/has-seen-video-celebration-modal'; + $this->wpcom_is_site_specific_endpoint = true; + $this->wpcom_is_wpcom_only_endpoint = true; + } + + /** + * Register available routes. + */ + public function register_rest_route() { + register_rest_route( + $this->namespace, + $this->rest_base, + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'has_seen_video_celebration_modal' ), + 'permission_callback' => array( $this, 'permission_callback' ), + ), + ) + ); + register_rest_route( + $this->namespace, + $this->rest_base, + array( + array( + 'methods' => \WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'set_has_seen_video_celebration_modal' ), + 'permission_callback' => array( $this, 'permission_callback' ), + 'args' => array( + 'has_seen_video_celebration_modal' => array( + 'required' => true, + 'type' => 'boolean', + ), + ), + ), + ) + ); + } + + /** + * Callback to determine whether the request can proceed. + * + * @return boolean + */ + public function permission_callback() { + return current_user_can( 'read' ); + } + + /** + * Whether the site has displayed the video upload celebration modal. + * + * @return \WP_REST_Response + */ + public function has_seen_video_celebration_modal() { + // See D69932-code and apps/editing-toolkit/editing-toolkit-plugin/wpcom-block-editor-nux/class-wp-rest-wpcom-block-editor-first-post-published-modal-controller.php. + if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) { + return rest_ensure_response( + array( + 'has_seen_video_celebration_modal' => true, + ) + ); + } + $has_seen_video_celebration_modal = (bool) get_option( 'has_seen_video_celebration_modal', false ); + + return rest_ensure_response( + array( + 'has_seen_video_celebration_modal' => $has_seen_video_celebration_modal, + ) + ); + } + + /** + * Update the option for whether the user has seen the video upload celebration modal. + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response + */ + public function set_has_seen_video_celebration_modal( $request ) { + $params = $request->get_json_params(); + update_option( 'has_seen_video_celebration_modal', $params['has_seen_video_celebration_modal'] ); + return rest_ensure_response( array( 'has_seen_video_celebration_modal' => $params['has_seen_video_celebration_modal'] ) ); + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wpcom-block-editor-nux.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wpcom-block-editor-nux.php new file mode 100644 index 0000000000000..a69a84a54c3cb --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/class-wpcom-block-editor-nux.php @@ -0,0 +1,112 @@ + get_option( 'launchpad_screen' ), + 'siteUrlOption' => get_option( 'siteurl' ), + 'siteIntentOption' => get_option( 'site_intent' ), + ), + JSON_HEX_TAG | JSON_HEX_AMP + ); + + wp_add_inline_script( + $handle, + "var launchpadOptions = $launchpad_options;", + 'before' + ); + + /** + * Enqueue the sharing modal options. + */ + $sharing_modal_options = wp_json_encode( + array( + 'isDismissed' => WP_REST_WPCOM_Block_Editor_Sharing_Modal_Controller::get_wpcom_sharing_modal_dismissed(), + ), + JSON_HEX_TAG | JSON_HEX_AMP + ); + + wp_add_inline_script( + $handle, + "var sharingModalOptions = $sharing_modal_options;", + 'before' + ); + } + + /** + * Register the WPCOM Block Editor NUX endpoints. + */ + public function register_rest_api() { + require_once __DIR__ . '/class-wp-rest-wpcom-block-editor-nux-status-controller.php'; + $controller = new WP_REST_WPCOM_Block_Editor_NUX_Status_Controller(); + $controller->register_rest_route(); + + require_once __DIR__ . '/class-wp-rest-wpcom-block-editor-first-post-published-modal-controller.php'; + $first_post_published_modal_controller = new WP_REST_WPCOM_Block_Editor_First_Post_Published_Modal_Controller(); + $first_post_published_modal_controller->register_rest_route(); + + require_once __DIR__ . '/class-wp-rest-wpcom-block-editor-seller-celebration-modal-controller.php'; + $seller_celebration_modal_controller = new WP_REST_WPCOM_Block_Editor_Seller_Celebration_Modal_Controller(); + $seller_celebration_modal_controller->register_rest_route(); + + require_once __DIR__ . '/class-wp-rest-wpcom-block-editor-video-celebration-modal-controller.php'; + $video_celebration_modal_controller = new WP_REST_WPCOM_Block_Editor_Video_Celebration_Modal_Controller(); + $video_celebration_modal_controller->register_rest_route(); + + require_once __DIR__ . '/class-wp-rest-wpcom-block-editor-sharing-modal-controller.php'; + $sharing_modal_controller = new WP_REST_WPCOM_Block_Editor_Sharing_Modal_Controller(); + $sharing_modal_controller->register_rest_route(); + } +} +add_action( 'init', array( __NAMESPACE__ . '\WPCOM_Block_Editor_NUX', 'init' ) ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/index.js b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/index.js new file mode 100644 index 0000000000000..4479be948bef3 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/index.js @@ -0,0 +1,8 @@ +/*** THIS MUST BE THE FIRST THING EVALUATED IN THIS SCRIPT *****/ +import '../../common/public-path'; +import { register } from './src/store'; + +import './src/disable-core-nux'; +import './src/block-editor-nux'; + +register(); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/block-editor-nux.js b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/block-editor-nux.js new file mode 100644 index 0000000000000..1749ed15908da --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/block-editor-nux.js @@ -0,0 +1,137 @@ +import { LocaleProvider } from '@automattic/i18n-utils'; +import { Guide, GuidePage } from '@wordpress/components'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useEffect, useState } from '@wordpress/element'; +import { applyFilters } from '@wordpress/hooks'; +import { registerPlugin } from '@wordpress/plugins'; +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; +import { getQueryArg } from '@wordpress/url'; +import { + HasSeenSellerCelebrationModalProvider, + HasSeenVideoCelebrationModalProvider, + ShouldShowFirstPostPublishedModalProvider, +} from '../../../common/tour-kit'; +import { BloggingPromptsModal } from './blogging-prompts-modal'; +import DraftPostModal from './draft-post-modal'; +import FirstPostPublishedModal from './first-post-published-modal'; +import PurchaseNotice from './purchase-notice'; +import SellerCelebrationModal from './seller-celebration-modal'; +import PostPublishedSharingModal from './sharing-modal'; +import { DEFAULT_VARIANT, BLANK_CANVAS_VARIANT } from './store'; +import VideoPressCelebrationModal from './video-celebration-modal'; +import WpcomNux from './welcome-modal/wpcom-nux'; +import LaunchWpcomWelcomeTour from './welcome-tour/tour-launch'; + +/** + * Sometimes Gutenberg doesn't allow you to re-register the module and throws an error. + * FIXME: The new version allow it by default, but we might need to ensure that all the site has the new version. + * @see https://github.com/Automattic/wp-calypso/pull/79663 + */ +let unlock; +try { + unlock = __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.', + '@wordpress/edit-site' + ).unlock; +} catch ( error ) { + // eslint-disable-next-line no-console + console.error( 'Error: Unable to get the unlock api. Reason: %s', error ); +} + +/** + * The WelcomeTour component + */ +function WelcomeTour() { + const [ showDraftPostModal ] = useState( + getQueryArg( window.location.href, 'showDraftPostModal' ) + ); + + const { + show, + isLoaded, + variant, + isManuallyOpened, + isNewPageLayoutModalOpen, + siteEditorCanvasMode, + } = useSelect( select => { + const welcomeGuideStoreSelect = select( 'automattic/wpcom-welcome-guide' ); + const starterPageLayoutsStoreSelect = select( 'automattic/starter-page-layouts' ); + let canvasMode; + if ( unlock && select( 'core/edit-site' ) ) { + canvasMode = + select( 'core/edit-site' ) && unlock( select( 'core/edit-site' ) ).getCanvasMode(); + } + + return { + show: welcomeGuideStoreSelect.isWelcomeGuideShown(), + isLoaded: welcomeGuideStoreSelect.isWelcomeGuideStatusLoaded(), + variant: welcomeGuideStoreSelect.getWelcomeGuideVariant(), + isManuallyOpened: welcomeGuideStoreSelect.isWelcomeGuideManuallyOpened(), + isNewPageLayoutModalOpen: starterPageLayoutsStoreSelect?.isOpen(), // Handle the case where SPT is not initalized. + siteEditorCanvasMode: canvasMode, + }; + }, [] ); + + const setOpenState = useDispatch( 'automattic/starter-page-layouts' )?.setOpenState; + + const { fetchWelcomeGuideStatus } = useDispatch( 'automattic/wpcom-welcome-guide' ); + + // On mount check if the WPCOM welcome guide status exists in state (from local storage), otherwise fetch it from the API. + useEffect( () => { + if ( ! isLoaded ) { + fetchWelcomeGuideStatus(); + } + }, [ fetchWelcomeGuideStatus, isLoaded ] ); + + const filteredShow = applyFilters( 'a8c.WpcomBlockEditorWelcomeTour.show', show ); + + if ( ! filteredShow || isNewPageLayoutModalOpen ) { + return null; + } + + // Hide the Welcome Tour when not in the edit mode. Note that canvas mode is available only in the site editor + if ( siteEditorCanvasMode && siteEditorCanvasMode !== 'edit' ) { + return null; + } + + // Open patterns panel before Welcome Tour if necessary (e.g. when using Blank Canvas theme) + // Do this only when Welcome Tour is not manually opened. + // NOTE: at the moment, 'starter-page-templates' assets are not loaded on /site-editor/ page so 'setOpenState' may be undefined + if ( variant === BLANK_CANVAS_VARIANT && ! isManuallyOpened && setOpenState ) { + setOpenState( 'OPEN_FOR_BLANK_CANVAS' ); + return null; + } + + if ( variant === DEFAULT_VARIANT ) { + return ( + + { showDraftPostModal ? : } + + ); + } + + // This case is redundant now and it will be cleaned up in a follow-up PR + if ( variant === 'modal' && Guide && GuidePage ) { + return ; + } + + return null; +} + +registerPlugin( 'wpcom-block-editor-nux', { + render: () => ( + + + + + + + + + + + + + + ), +} ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/blogging-prompts-modal/icons.js b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/blogging-prompts-modal/icons.js new file mode 100644 index 0000000000000..1b968dbc81d19 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/blogging-prompts-modal/icons.js @@ -0,0 +1,13 @@ +import { Path, SVG } from '@wordpress/components'; + +export const ArrowRightIcon = () => ( + + + +); + +export const ArrowLeftIcon = () => ( + + + +); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/blogging-prompts-modal/index.js b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/blogging-prompts-modal/index.js new file mode 100644 index 0000000000000..74ff6b5be19c2 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/blogging-prompts-modal/index.js @@ -0,0 +1,106 @@ +import apiFetch from '@wordpress/api-fetch'; +import { createBlock } from '@wordpress/blocks'; +import { Button, Modal } from '@wordpress/components'; +import { dispatch, select } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { addQueryArgs, getQueryArg } from '@wordpress/url'; +import moment from 'moment'; +import { useEffect, useState } from 'react'; +import { wpcomTrackEvent } from '../../../../common/tracks'; +import { ArrowLeftIcon, ArrowRightIcon } from './icons'; + +import './style.scss'; + +export const BloggingPromptsModalInner = () => { + const [ isOpen, setIsOpen ] = useState( true ); + const [ prompts, setPrompts ] = useState( [] ); + const [ promptIndex, setPromptIndex ] = useState( 0 ); + + useEffect( () => { + const path = addQueryArgs( `/wpcom/v3/blogging-prompts`, { + per_page: 10, + after: moment().format( '--MM-DD' ), + order: 'desc', + force_year: new Date().getFullYear(), + } ); + apiFetch( { + path, + } ) + .then( result => { + wpcomTrackEvent( 'calypso_editor_writing_prompts_modal_viewed' ); + return setPrompts( result ); + } ) + // eslint-disable-next-line no-console + .catch( () => console.error( 'Unable to fetch writing prompts' ) ); + }, [] ); + + if ( ! isOpen || ! prompts.length ) { + return null; + } + + const selectPrompt = () => { + const promptId = prompts[ promptIndex ]?.id; + dispatch( 'core/editor' ).resetEditorBlocks( [ + createBlock( 'jetpack/blogging-prompt', { promptId } ), + ] ); + wpcomTrackEvent( 'calypso_editor_writing_prompts_modal_prompt_selected', { + prompt_id: promptId, + } ); + setIsOpen( false ); + }; + + const closeModal = () => { + wpcomTrackEvent( 'calypso_editor_writing_prompts_modal_closed' ); + setIsOpen( false ); + }; + + return ( + +
+
+ +

{ prompts[ promptIndex ]?.text }

+ +
+ +
+
+ ); +}; + +export const BloggingPromptsModal = () => { + const hasQueryArg = getQueryArg( window.location.href, 'new_prompt' ); + const editorType = select( 'core/editor' ).getCurrentPostType(); + + const shouldOpen = hasQueryArg && editorType === 'post'; + + if ( ! shouldOpen ) { + return null; + } + return ; +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/blogging-prompts-modal/style.scss b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/blogging-prompts-modal/style.scss new file mode 100644 index 0000000000000..67969ac4d706b --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/blogging-prompts-modal/style.scss @@ -0,0 +1,53 @@ +@import "@automattic/typography/styles/variables"; +@import "@wordpress/base-styles/breakpoints"; +@import "@wordpress/base-styles/mixins"; + +.blogging-prompts-modal { + @include break-small { + width: 80%; + } + @include break-large { + width: 60%; + } + max-width: 800px; + margin: auto; + + .components-modal__header-heading { + font-size: $font-body; + font-weight: 400; + } +} + +.blogging-prompts-modal__prompt { + display: flex; + flex-direction: column; + align-items: flex-end; + + .blogging-prompts-modal__prompt-navigation { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + gap: 24px; + width: 100%; + } + + .blogging-prompts-modal__prompt-navigation-button { + border-radius: 50%; + width: 44px; + height: 44px; + &.components-button:hover:not(:disabled,[aria-disabled="true"]) { + box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9)); + } + } + + .blogging-prompts-modal__prompt-text { + font-size: 1.25rem; + font-weight: 500; + line-height: 26px; + text-align: left; + width: 100%; + text-wrap: pretty; + } +} + diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/disable-core-nux.js b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/disable-core-nux.js new file mode 100644 index 0000000000000..6591d4e0b5c2d --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/disable-core-nux.js @@ -0,0 +1,43 @@ +import { select, dispatch, subscribe } from '@wordpress/data'; + +import '@wordpress/nux'; //ensure nux store loads + +// Disable nux and welcome guide features from core. +const unsubscribe = subscribe( () => { + dispatch( 'core/nux' ).disableTips(); + if ( select( 'core/edit-post' )?.isFeatureActive( 'welcomeGuide' ) ) { + dispatch( 'core/edit-post' ).toggleFeature( 'welcomeGuide' ); + unsubscribe(); + } + if ( select( 'core/edit-site' )?.isFeatureActive( 'welcomeGuide' ) ) { + dispatch( 'core/edit-site' ).toggleFeature( 'welcomeGuide' ); + unsubscribe(); + } +} ); + +// Listen for these features being triggered to call dotcom welcome guide instead. +// Note migration of areTipsEnabled: https://github.com/WordPress/gutenberg/blob/5c3a32dabe4393c45f7fe6ac5e4d78aebd5ee274/packages/data/src/plugins/persistence/index.js#L269 +subscribe( () => { + if ( select( 'core/nux' ).areTipsEnabled() ) { + dispatch( 'core/nux' ).disableTips(); + dispatch( 'automattic/wpcom-welcome-guide' ).setShowWelcomeGuide( true ); + } + if ( select( 'core/edit-post' )?.isFeatureActive( 'welcomeGuide' ) ) { + dispatch( 'core/edit-post' ).toggleFeature( 'welcomeGuide' ); + // On mounting, the welcomeGuide feature is turned on by default. This opens the welcome guide despite `welcomeGuideStatus` value. + // This check ensures that we only listen to `welcomeGuide` changes if the welcomeGuideStatus value is loaded and respected + if ( select( 'automattic/wpcom-welcome-guide' ).isWelcomeGuideStatusLoaded() ) { + dispatch( 'automattic/wpcom-welcome-guide' ).setShowWelcomeGuide( true, { + openedManually: true, + } ); + } + } + if ( select( 'core/edit-site' )?.isFeatureActive( 'welcomeGuide' ) ) { + dispatch( 'core/edit-site' ).toggleFeature( 'welcomeGuide' ); + if ( select( 'automattic/wpcom-welcome-guide' ).isWelcomeGuideStatusLoaded() ) { + dispatch( 'automattic/wpcom-welcome-guide' ).setShowWelcomeGuide( true, { + openedManually: true, + } ); + } + } +} ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/draft-post-modal/index.js b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/draft-post-modal/index.js new file mode 100644 index 0000000000000..62bade2379698 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/draft-post-modal/index.js @@ -0,0 +1,50 @@ +import { Button } from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import { doAction, hasAction } from '@wordpress/hooks'; +import { __ } from '@wordpress/i18n'; +import draftPostImage from '../../../../assets/images/draft-post.svg'; +import { wpcomTrackEvent } from '../../../../common/tracks'; +import NuxModal from '../nux-modal'; +import './style.scss'; + +const CLOSE_EDITOR_ACTION = 'a8c.wpcom-block-editor.closeEditor'; + +const DraftPostModal = () => { + const homeUrl = `/home/${ window.location.hostname }`; + const [ isOpen, setIsOpen ] = useState( true ); + const closeModal = () => setIsOpen( false ); + const closeEditor = () => { + if ( hasAction( CLOSE_EDITOR_ACTION ) ) { + doAction( CLOSE_EDITOR_ACTION, homeUrl ); + } else { + window.location.href = `https://wordpress.com${ homeUrl }`; + } + }; + + return ( + + + + + } + onRequestClose={ closeModal } + onOpen={ () => wpcomTrackEvent( 'calypso_editor_wpcom_draft_post_modal_show' ) } + /> + ); +}; + +export default DraftPostModal; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/draft-post-modal/style.scss b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/draft-post-modal/style.scss new file mode 100644 index 0000000000000..2317634ed12b7 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/draft-post-modal/style.scss @@ -0,0 +1,17 @@ +@import "@wordpress/base-styles/breakpoints"; +@import "@wordpress/base-styles/mixins"; + +.wpcom-block-editor-draft-post-modal { + .components-modal__content { + @include break-small { + padding: 48px 128px; + } + } + + .wpcom-block-editor-nux-modal__image-container { + img { + width: 209px; + height: 95px; + } + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/first-post-published-modal/index.tsx b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/first-post-published-modal/index.tsx new file mode 100644 index 0000000000000..039a89165ea51 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/first-post-published-modal/index.tsx @@ -0,0 +1,117 @@ +import { Button } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { useEffect, useRef, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { isURL } from '@wordpress/url'; +import React from 'react'; +import postPublishedImage from '../../../../assets/images/post-published.svg'; +import { useSiteIntent, useShouldShowFirstPostPublishedModal } from '../../../../common/tour-kit'; +import { wpcomTrackEvent } from '../../../../common/tracks'; +import NuxModal from '../nux-modal'; + +import './style.scss'; + +type CoreEditorPlaceholder = { + getCurrentPost: ( ...args: unknown[] ) => { link: string }; + getCurrentPostType: ( ...args: unknown[] ) => string; + isCurrentPostPublished: ( ...args: unknown[] ) => boolean; +}; + +/** + * Show the first post publish modal + */ +const FirstPostPublishedModalInner: React.FC = () => { + const { link } = useSelect( + select => ( select( 'core/editor' ) as CoreEditorPlaceholder ).getCurrentPost(), + [] + ); + const postType = useSelect( + select => ( select( 'core/editor' ) as CoreEditorPlaceholder ).getCurrentPostType(), + [] + ); + + const isCurrentPostPublished = useSelect( + select => ( select( 'core/editor' ) as CoreEditorPlaceholder ).isCurrentPostPublished(), + [] + ); + const previousIsCurrentPostPublished = useRef( isCurrentPostPublished ); + const shouldShowFirstPostPublishedModal = useShouldShowFirstPostPublishedModal(); + const [ isOpen, setIsOpen ] = useState( false ); + const closeModal = () => setIsOpen( false ); + + const { siteUrlOption, launchpadScreenOption, siteIntentOption } = window?.launchpadOptions || {}; + + let siteUrl = ''; + if ( isURL( siteUrlOption ) ) { + // https://mysite.wordpress.com/path becomes mysite.wordpress.com + siteUrl = new URL( siteUrlOption ).hostname; + } + + useEffect( () => { + // If the user is set to see the first post modal and current post status changes to publish, + // open the post publish modal + if ( + shouldShowFirstPostPublishedModal && + ! previousIsCurrentPostPublished.current && + isCurrentPostPublished && + postType === 'post' + ) { + previousIsCurrentPostPublished.current = isCurrentPostPublished; + + // When the post published panel shows, it is focused automatically. + // Thus, we need to delay open the modal so that the modal would not be close immediately + // because the outside of modal is focused + window.setTimeout( () => { + setIsOpen( true ); + } ); + } + }, [ postType, shouldShowFirstPostPublishedModal, isCurrentPostPublished ] ); + + const handleViewPostClick = ( event: React.MouseEvent ) => { + event.preventDefault(); + ( window.top as Window ).location.href = link; + }; + + const handleNextStepsClick = ( event: React.MouseEvent ) => { + event.preventDefault(); + ( + window.top as Window + ).location.href = `https://wordpress.com/setup/write/launchpad?siteSlug=${ siteUrl }`; + }; + return ( + + + { launchpadScreenOption === 'full' && siteIntentOption === 'write' && ( + + ) } + + } + onRequestClose={ closeModal } + onOpen={ () => wpcomTrackEvent( 'calypso_editor_wpcom_first_post_published_modal_show' ) } + /> + ); +}; + +const FirstPostPublishedModal = () => { + const { siteIntent: intent } = useSiteIntent(); + if ( intent === 'write' ) { + return ; + } + return null; +}; + +export default FirstPostPublishedModal; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/first-post-published-modal/style.scss b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/first-post-published-modal/style.scss new file mode 100644 index 0000000000000..ebbe84e61dfc8 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/first-post-published-modal/style.scss @@ -0,0 +1,23 @@ +@import "@wordpress/base-styles/breakpoints"; +@import "@wordpress/base-styles/mixins"; + +.wpcom-block-editor-post-published-modal { + .components-modal__content { + @include break-small { + padding: 48px 90px; + } + } + + .wpcom-block-editor-nux-modal__image-container { + img { + width: 158px; + height: 85px; + } + } + + .wpcom-block-editor-nux-modal__buttons { + .components-button { + min-width: 113px; + } + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/nux-modal/index.tsx b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/nux-modal/index.tsx new file mode 100644 index 0000000000000..e46656c0ce2e3 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/nux-modal/index.tsx @@ -0,0 +1,58 @@ +import { Modal } from '@wordpress/components'; +import { useEffect, useRef } from '@wordpress/element'; +import clsx from 'clsx'; +import React from 'react'; +import './style.scss'; + +interface Props { + isOpen: boolean; + className?: string; + title: string; + description: string; + imageSrc: string; + actionButtons: React.ReactElement; + onRequestClose: () => void; + onOpen?: () => void; +} + +const NuxModal: React.FC< Props > = ( { + isOpen, + className, + title, + description, + imageSrc, + actionButtons, + onRequestClose, + onOpen, +} ) => { + const prevIsOpen = useRef< boolean | null >( null ); + + useEffect( () => { + if ( ! prevIsOpen.current && isOpen ) { + onOpen?.(); + } + + prevIsOpen.current = isOpen; + }, [ prevIsOpen, isOpen, onOpen ] ); + + if ( ! isOpen ) { + return null; + } + + return ( + +
+ { +
+

{ title }

+

{ description }

+
{ actionButtons }
+
+ ); +}; + +export default NuxModal; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/nux-modal/style.scss b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/nux-modal/style.scss new file mode 100644 index 0000000000000..ee7ee1b10caef --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/nux-modal/style.scss @@ -0,0 +1,96 @@ +@import "@automattic/typography/styles/variables"; +@import "@wordpress/base-styles/breakpoints"; +@import "@wordpress/base-styles/mixins"; + +.wpcom-block-editor-nux-modal { + .components-modal__header { + height: auto; + padding: 10px; + border-bottom: 0; + + // Fix styles when Gutenberg is deactivated + position: absolute; + left: 0; + right: 0; + margin: 0; + background-color: transparent; + + button { + left: unset; + + svg path { + transform: scale(1.4); + transform-origin: center; + } + } + } + + .components-modal__content { + padding: 84px 20px 20px; + margin-top: 0; + + &::before { + margin: 0; + } + + @include break-mobile { + text-align: center; + } + } + + .wpcom-block-editor-nux-modal__image-container { + display: flex; + justify-content: center; + } + + .wpcom-block-editor-nux-modal__title { + margin: 34px 0 0; + font-size: $font-headline-small; + font-weight: 500; + line-height: 1.2; + + @include break-mobile { + margin-top: 24px; + } + } + + .wpcom-block-editor-nux-modal__description { + max-width: 352px; + margin: 16px 0 0; + font-size: $font-body; + + @include break-mobile { + margin: 20px auto 0; + font-size: $font-body-large; + } + } + + .wpcom-block-editor-nux-modal__buttons { + display: flex; + flex-direction: column; + justify-content: center; + margin-top: 24px; + + .components-button { + min-width: 130px; + height: 40px; + justify-content: center; + border-radius: 3px; + font-size: $font-body-small; + } + + .components-button + .components-button { + margin-top: 12px; + } + + @include break-mobile { + flex-direction: row; + margin-top: 28px; + + .components-button + .components-button { + margin-top: 0; + margin-left: 16px; + } + } + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/purchase-notice/index.jsx b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/purchase-notice/index.jsx new file mode 100644 index 0000000000000..b6ffafa4ab99d --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/purchase-notice/index.jsx @@ -0,0 +1,34 @@ +import { useDispatch } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { useEffect, useRef } from 'react'; +import './style.scss'; + +/** + * Display the purchase notice snackbar + */ +function PurchaseNotice() { + const hasPaymentNotice = useRef( false ); + const { createNotice } = useDispatch( noticesStore ); + + useEffect( () => { + const noticePattern = /[&?]notice=([\w_-]+)/; + const match = noticePattern.exec( document.location.search ); + const notice = match && match[ 1 ]; + if ( 'purchase-success' === notice && hasPaymentNotice.current === false ) { + hasPaymentNotice.current = true; + createNotice( + 'info', + __( 'Congrats! Premium blocks are now available to use.', 'jetpack-mu-wpcom' ), + { + isDismissible: true, + type: 'snackbar', + } + ); + } + }, [ createNotice ] ); + + return null; +} + +export default PurchaseNotice; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/purchase-notice/style.scss b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/purchase-notice/style.scss new file mode 100644 index 0000000000000..f9802fcb34e2b --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/purchase-notice/style.scss @@ -0,0 +1,15 @@ +@import "@wordpress/base-styles/breakpoints"; +@import "@wordpress/base-styles/mixins"; + +.wpcom-block-editor-purchase-notice { + position: fixed; + overflow: visible; + bottom: 0; + left: 0; + width: 100%; + z-index: 10000; + justify-content: center; + display: flex; + pointer-events: none; + padding-bottom: 1rem; +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/seller-celebration-modal/index.jsx b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/seller-celebration-modal/index.jsx new file mode 100644 index 0000000000000..b31b774ea4e6b --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/seller-celebration-modal/index.jsx @@ -0,0 +1,139 @@ +import { Button } from '@wordpress/components'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { useState, useRef, useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import contentSubmittedImage from '../../../../assets/images/product-published.svg'; +import { + useSiteIntent, + useShouldShowSellerCelebrationModal, + useHasSeenSellerCelebrationModal, +} from '../../../../common/tour-kit'; +import { wpcomTrackEvent } from '../../../../common/tracks'; +import NuxModal from '../nux-modal'; +import './style.scss'; + +/** + * Show the seller celebration modal + */ +const SellerCelebrationModalInner = () => { + const { addEntities } = useDispatch( 'core' ); + + useEffect( () => { + // @TODO - not sure if I actually need this; need to test with it removed. + // Teach core data about the status entity so we can use selectors like `getEntityRecords()` + addEntities( [ + { + baseURL: '/wp/v2/statuses', + key: 'slug', + kind: 'root', + name: 'status', + plural: 'statuses', + }, + ] ); + // Only register entity once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [] ); + // conditions to show: + // - user just finished saving (check) + // - editor has not yet displayed modal once (check) + // - user is a seller (check) + // - user has not saved site before + // - content includes product block, and a user has selected it at least once (check) + const [ isModalOpen, setIsModalOpen ] = useState( false ); + const [ hasDisplayedModal, setHasDisplayedModal ] = useState( false ); + + const isSiteEditor = useSelect( select => !! select( 'core/edit-site' ) ); + const previousIsEditorSaving = useRef( false ); + + const { updateHasSeenSellerCelebrationModal } = useHasSeenSellerCelebrationModal(); + + const linkUrl = useSelect( select => { + if ( isSiteEditor ) { + const page = select( 'core/edit-site' ).getPage(); + const pageId = parseInt( page?.context?.postId ); + const pageEntity = select( 'core' ).getEntityRecord( 'postType', 'page', pageId ); + return pageEntity?.link; + } + const currentPost = select( 'core/editor' ).getCurrentPost(); + return currentPost.link; + } ); + + const shouldShowSellerCelebrationModal = useShouldShowSellerCelebrationModal(); + + const isEditorSaving = useSelect( select => { + if ( isSiteEditor ) { + const page = select( 'core/edit-site' ).getPage(); + const pageId = parseInt( page?.context?.postId ); + const isSavingSite = + select( 'core' ).isSavingEntityRecord( 'root', 'site' ) && + ! select( 'core' ).isAutosavingEntityRecord( 'root', 'site' ); + const isSavingEntity = + select( 'core' ).isSavingEntityRecord( 'postType', 'page', pageId ) && + ! select( 'core' ).isAutosavingEntityRecord( 'postType', 'page', pageId ); + + return isSavingSite || isSavingEntity; + } + const currentPost = select( 'core/editor' ).getCurrentPost(); + const isSavingEntity = + select( 'core' ).isSavingEntityRecord( 'postType', currentPost?.type, currentPost?.id ) && + ! select( 'core' ).isAutosavingEntityRecord( 'postType', currentPost?.type, currentPost?.id ); + return isSavingEntity; + } ); + + useEffect( () => { + if ( + ! isEditorSaving && + previousIsEditorSaving.current && + ! hasDisplayedModal && + shouldShowSellerCelebrationModal + ) { + setIsModalOpen( true ); + setHasDisplayedModal( true ); + updateHasSeenSellerCelebrationModal( true ); + } + previousIsEditorSaving.current = isEditorSaving; + }, [ + isEditorSaving, + hasDisplayedModal, + shouldShowSellerCelebrationModal, + updateHasSeenSellerCelebrationModal, + ] ); + + // if save state has changed and was saving on last render + // then it has finished saving + // open modal if content has sell block, + + const closeModal = () => setIsModalOpen( false ); + return ( + + + + + } + onRequestClose={ closeModal } + onOpen={ () => wpcomTrackEvent( 'calypso_editor_wpcom_seller_celebration_modal_show' ) } + /> + ); +}; + +const SellerCelebrationModal = () => { + const { siteIntent: intent } = useSiteIntent(); + if ( intent === 'sell' ) { + return ; + } + return null; +}; + +export default SellerCelebrationModal; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/seller-celebration-modal/style.scss b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/seller-celebration-modal/style.scss new file mode 100644 index 0000000000000..c1576d9f74db5 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/seller-celebration-modal/style.scss @@ -0,0 +1,27 @@ +@import "@wordpress/base-styles/breakpoints"; +@import "@wordpress/base-styles/mixins"; + +.wpcom-site-editor-seller-celebration-modal { + .components-modal__content { + @include break-small { + padding: 48px 90px; + } + } + + .wpcom-block-editor-nux-modal__image-container { + img { + width: 158px; + height: 85px; + } + } + + .wpcom-block-editor-nux-modal__buttons { + .components-button { + min-width: 113px; + &:not(.is-primary) { + border: 1px solid #c3c4c7; + border-radius: 4px; + } + } + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/clipboard-button.tsx b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/clipboard-button.tsx new file mode 100644 index 0000000000000..bd077bf7625cc --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/clipboard-button.tsx @@ -0,0 +1,47 @@ +import { Button } from '@wordpress/components'; +import clsx from 'clsx'; +import { forwardRef } from 'react'; + +interface ClipboardButtonProps { + className?: string; + compact?: boolean; + disabled?: boolean; + primary?: boolean; + scary?: boolean; + busy?: boolean; + borderless?: boolean; + plain?: boolean; + transparent?: boolean; + text: string | null; + onCopy?: () => void; + onMouseLeave?: () => void; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const noop = () => {}; + +const ClipboardButton = forwardRef< + HTMLButtonElement, + React.PropsWithChildren< ClipboardButtonProps > +>( ( { className, text, onCopy = noop, ...rest }, ref ) => { + /** + * The copy handler + */ + function onCopyHandler() { + if ( text ) { + navigator.clipboard.writeText( text ); + onCopy(); + } + } + + return ( + + + + + +
+ + { + updateIsDismissed( ! isDismissed ); + } } + /> + { __( "Don't show again", 'jetpack-mu-wpcom' ) } + +
+
+
+ { shouldShowSuggestedTags ? ( + + ) : ( + { + ) } +
+
+ + ); +}; + +const SharingModal = () => { + const { siteIntent: intent } = useSiteIntent(); + if ( intent === START_WRITING_FLOW || intent === DESIGN_FIRST_FLOW ) { + return null; + } + return ; +}; +export default SharingModal; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/inline-social-logo.tsx b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/inline-social-logo.tsx new file mode 100644 index 0000000000000..b3d72b5eae882 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/inline-social-logo.tsx @@ -0,0 +1,48 @@ +import clsx from 'clsx'; +import * as React from 'react'; +import { Assign } from 'utility-types'; + +interface Props { + icon: string; + size?: number; +} + +/** + * InlineSocialLogo is a copy of client/components/social-logo that references an inline SVG sprite. + * This componenet is needed because: + * + * The XML element does not work with SVGs loaded from external domains. + * In the editor, images are loaded from the CDN (s0.wp.com) in production. + * useInline allows us to reference an svg sprite from the current page instead. + * see https://github.com/w3c/svgwg/issues/707 + * + * InlineSocialLogosSprite must be included on the page where this is used + * @param props - The props of the component, + * @returns A Social Logo SVG + */ +function InlineSocialLogo( props: Assign< React.SVGProps< SVGSVGElement >, Props > ) { + const { size = 24, icon, className, ...otherProps } = props; + + // Using a missing icon doesn't produce any errors, just a blank icon, which is the exact intended behaviour. + // This means we don't need to perform any checks on the icon name. + const iconName = `social-logo-${ icon }`; + // The current CSS expects individual icon classes in the form of e.g. `.twitter`, but the social-logos build + // appears to generate them in the form of `.social-logo-twitter` instead. + // We add both here, to ensure compatibility. + const iconClass = clsx( 'social-logo', iconName, icon, className ); + + return ( + + + + ); +} + +export default React.memo( InlineSocialLogo ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/inline-social-logos-sprite.tsx b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/inline-social-logos-sprite.tsx new file mode 100644 index 0000000000000..c0c5e9388cdad --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/inline-social-logos-sprite.tsx @@ -0,0 +1,286 @@ +/** + * A hidden inline svg sprite of social logos. + * + * Sprite was coppied from https://wordpress.com/calypso/images/social-logos-d55401f99bb02ebd6cf4.svg + * @returns see above. + */ +const InlineSocialLogosSprite = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; +export default InlineSocialLogosSprite; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/style.scss b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/style.scss new file mode 100644 index 0000000000000..c7738fc6fc0d5 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/style.scss @@ -0,0 +1,173 @@ +@import "@wordpress/base-styles/breakpoints"; +@import "@wordpress/base-styles/mixins"; + +.wpcom-block-editor-post-published-sharing-modal { + .components-modal__content { + margin-top: 0; + padding-bottom: 0; + .wpcom-block-editor-post-published-sharing-modal__inner { + display: flex; + .wpcom-block-editor-post-published-sharing-modal__left { + width: 50%; + padding: 52px 40px 52px 20px; + + p a { + color: var(--color-text); + text-decoration: underline; + white-space: nowrap; + + &:hover { + color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba)); + text-decoration: none; + } + + .components-external-link__icon { + margin-left: 4px; + } + } + + .wpcom-block-editor-post-published-buttons { + display: flex; + } + } + .wpcom-block-editor-post-published-sharing-modal__right { + width: 50%; + padding: 52px 20px 52px 40px; + border-left: 1px solid var(--studio-gray-5); + display: flex; + justify-content: center; + min-width: 300px; + } + + @media only screen and (max-width: 600px) { + flex-direction: column-reverse; + + .wpcom-block-editor-post-published-sharing-modal__left { + padding: 44px 0 20px; + width: 100%; + } + .wpcom-block-editor-post-published-sharing-modal__right { + border-left: none; + padding: 52px 0 0; + width: 100%; + } + .wpcom-block-editor-post-published-sharing-modal__image { + height: 140px; + } + } + } + h1 { + margin-top: 0; + /* stylelint-disable-next-line declaration-property-unit-allowed-list */ + font-size: 26px; + @media only screen and (max-width: 600px) { + font-size: 2.25rem; + font-weight: 500; + line-height: 1; + } + } + p { + /* stylelint-disable-next-line declaration-property-unit-allowed-list */ + font-size: 18px; + @media only screen and (max-width: 600px) { + font-size: 1rem; + } + } + .link-button { + /* stylelint-disable-next-line declaration-property-unit-allowed-list */ + font-size: 14px; + font-weight: 500; + display: inline-flex; + border: none; + background: transparent; + color: var(--color-text); + padding: 0 14px 0 0; + line-height: 2.71428571; + min-height: 40px; + margin-bottom: 4px; + align-items: center; + text-decoration: underline; + white-space: nowrap; + + &:hover { + background: transparent; + color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba)); + text-decoration: none; + } + + svg { + fill: currentColor; + } + } + hr { + margin-top: 20px; + border-top: 1px solid var(--studio-gray-5); + border-bottom: none; + } + h2 { + /* stylelint-disable-next-line declaration-property-unit-allowed-list */ + font-size: 18px; + font-weight: 600px; + } + .wpcom-block-editor-post-published-sharing-modal__checkbox-section { + margin-top: 40px; + color: var(--studio-gray-60); + } + .form-checkbox { + margin-top: 1px; + height: 1rem; + width: 1rem; + &::before { + width: 1.3125rem; + height: 1.3125rem; + } + } + .wpcom-block-editor-post-published-sharing-modal__suggest-tags { + width: 250px; + flex: fit-content; + } + } +} + +.wpcom-block-editor-post-published-buttons .link-button { + gap: 4px; + padding-left: 0; +} + +.wpcom-block-editor-post-published-sharing-modal__sharing-button { + display: inline-block; + position: relative; + width: 32px; + height: 32px; + top: -2px; + margin: 8px 8px 0 0; + border-radius: 50%; + color: var(--color-neutral-80); + background: var(--studio-white); + padding: 7px; + + &:hover { + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.22), 0 0 0 1px rgba(0, 0, 0, 0.22); + } + + .social-logo { + top: 0; + color: var(--color-text-inverted); + } +} + +@mixin sharing-button-service( $name, $color ) { + .wpcom-block-editor-post-published-sharing-modal__sharing-button.share-#{ $name } { + background: $color; + + &:hover { + background: $color; + } + } +} + +@include sharing-button-service( "facebook", var( --color-facebook ) ); +@include sharing-button-service( "twitter", var( --color-twitter ) ); +@include sharing-button-service( "linkedin", var( --color-linkedin ) ); +@include sharing-button-service( "tumblr", var( --color-tumblr ) ); +@include sharing-button-service( "pinterest", var( --color-pinterest ) ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/suggested-tags.tsx b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/suggested-tags.tsx new file mode 100644 index 0000000000000..8f47b85adb592 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/suggested-tags.tsx @@ -0,0 +1,132 @@ +import { useLocale } from '@automattic/i18n-utils'; +import { Button, FormTokenField } from '@wordpress/components'; +import { TokenItem } from '@wordpress/components/build-types/form-token-field/types'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useEffect } from '@wordpress/element'; +import { store as noticesStore } from '@wordpress/notices'; +import { useI18n } from '@wordpress/react-i18n'; +import * as React from 'react'; +import { wpcomTrackEvent } from '../../../../common/tracks'; +import useAddTagsToPost from './use-add-tags-to-post'; + +type PostMeta = { + reader_suggested_tags: string; +}; + +type CoreEditorPlaceholder = { + getCurrentPost: ( ...args: unknown[] ) => { + id: number; + meta: PostMeta; + }; +}; + +type SuggestedTagsEventProps = { + number_of_original_suggested_tags: number; + number_of_selected_tags: number; + number_of_suggested_tags_selected: number; + number_of_added_tags: number; +}; + +type SuggestedTagsProps = { + setShouldShowSuggestedTags: ( shouldShow: boolean ) => void; +}; + +/** + * Display the suggested tags. + * + * @param props - The props of the component. + */ +function SuggestedTags( props: SuggestedTagsProps ) { + const { __, _n } = useI18n(); + const localeSlug = useLocale(); + const { id: postId, meta: postMeta } = useSelect( + select => ( select( 'core/editor' ) as CoreEditorPlaceholder ).getCurrentPost(), + [] + ); + const { createNotice } = useDispatch( noticesStore ); + const origSuggestedTags = postMeta?.reader_suggested_tags + ? JSON.parse( postMeta.reader_suggested_tags ) + : []; + const [ selectedTags, setSelectedTags ] = React.useState( origSuggestedTags ); + const onAddTagsButtonClick = ( numAddedTags: number ) => { + // Compare origSuggestedTags and selectedTags and determine the number of tags that are different + const numSuggestedTags = origSuggestedTags.length; + const numSelectedTags = selectedTags.length; + const numSameTags = origSuggestedTags.filter( ( tag: string ) => + selectedTags.includes( tag ) + ).length; + const eventProps: SuggestedTagsEventProps = { + number_of_original_suggested_tags: numSuggestedTags, + number_of_selected_tags: numSelectedTags, + number_of_suggested_tags_selected: numSameTags, + number_of_added_tags: numAddedTags, + }; + wpcomTrackEvent( 'calypso_reader_post_publish_add_tags', eventProps ); + if ( numAddedTags > 0 ) { + createNotice( + 'success', + _n( 'Tag Added.', 'Tags Added.', numAddedTags, 'jetpack-mu-wpcom' ), + { + type: 'snackbar', + } + ); + } else { + createNotice( 'warning', __( 'No Tags Added.', 'jetpack-mu-wpcom' ), { + type: 'snackbar', + } ); + } + props.setShouldShowSuggestedTags( false ); + }; + const { saveTags } = useAddTagsToPost( postId, selectedTags, onAddTagsButtonClick ); + + useEffect( () => { + if ( origSuggestedTags?.length === 0 ) { + // Check if localeSlug begins with 'en' + if ( localeSlug && localeSlug.startsWith( 'en' ) ) { + wpcomTrackEvent( 'calypso_reader_post_publish_no_suggested_tags' ); + } + props.setShouldShowSuggestedTags( false ); + } else { + wpcomTrackEvent( 'calypso_reader_post_publish_show_suggested_tags', { + number_of_original_suggested_tags: origSuggestedTags.length, + } ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [] ); + + const onChangeSelectedTags = ( newTags: ( string | TokenItem )[] ) => { + setSelectedTags( newTags ); + wpcomTrackEvent( 'calypso_reader_post_publish_update_suggested_tags' ); + }; + + const tokenField = ( + + ); + + return ( +
+

{ __( 'Recommended tags:', 'jetpack-mu-wpcom' ) }

+

+ { __( + 'Based on the topics and themes in your post, here are some suggested tags to consider:', + 'jetpack-mu-wpcom' + ) } +

+ { tokenField } +

{ __( 'Adding tags can help drive more traffic to your post.', 'jetpack-mu-wpcom' ) }

+ +
+ ); +} + +export default React.memo( SuggestedTags ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/use-add-tags-to-post.ts b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/use-add-tags-to-post.ts new file mode 100644 index 0000000000000..afa6e8fe4f435 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/use-add-tags-to-post.ts @@ -0,0 +1,31 @@ +import apiFetch from '@wordpress/api-fetch'; + +type HasAddedTagsResult = { + added_tags: number; + success: boolean; +}; + +type OnSaveTagsCallback = ( addedTags: number ) => void; +const useAddTagsToPost = ( postId: number, tags: string[], onSaveTags: OnSaveTagsCallback ) => { + /** + * Save tags + */ + async function saveTags() { + let addedTags = 0; + try { + const result: HasAddedTagsResult = await apiFetch( { + method: 'POST', + path: `/wpcom/v2/read/posts/${ postId }/tags/add`, + data: { tags }, + } ); + addedTags = result.added_tags ?? 0; + } catch ( error ) { + // eslint-disable-next-line no-console + console.error( 'Error: Unable to add tags. Reason: %s', JSON.stringify( error ) ); + } + onSaveTags( addedTags ); + } + return { saveTags }; +}; + +export default useAddTagsToPost; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/use-sharing-modal-dismissed.ts b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/use-sharing-modal-dismissed.ts new file mode 100644 index 0000000000000..ef136077fb30d --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/use-sharing-modal-dismissed.ts @@ -0,0 +1,24 @@ +import apiFetch from '@wordpress/api-fetch'; +import { useState } from '@wordpress/element'; + +const useSharingModalDismissed = ( initial: boolean ) => { + const [ isDismissed, setSharingModalDismissed ] = useState( initial ); + + /** + * Update the value to dismiss the sharing modal + * + * @param value - The value to update. + */ + function updateIsDismissed( value: boolean ) { + apiFetch( { + method: 'PUT', + path: '/wpcom/v2/block-editor/sharing-modal-dismissed', + data: { wpcom_sharing_modal_dismissed: value }, + } ).finally( () => { + setSharingModalDismissed( value ); + } ); + } + return { isDismissed, updateIsDismissed }; +}; + +export default useSharingModalDismissed; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/store.js b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/store.js new file mode 100644 index 0000000000000..6cc4da7c641eb --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/store.js @@ -0,0 +1,150 @@ +import apiFetch from '@wordpress/api-fetch'; +import { combineReducers, registerStore } from '@wordpress/data'; +import { apiFetch as apiFetchControls, controls } from '@wordpress/data-controls'; + +export const DEFAULT_VARIANT = 'tour'; +export const BLANK_CANVAS_VARIANT = 'blank-canvas-tour'; + +const showWelcomeGuideReducer = ( state = undefined, action ) => { + switch ( action.type ) { + case 'WPCOM_WELCOME_GUIDE_FETCH_STATUS_SUCCESS': + return action.response.show_welcome_guide; + case 'WPCOM_WELCOME_GUIDE_SHOW_SET': + return action.show; + case 'WPCOM_WELCOME_GUIDE_RESET_STORE': + return undefined; + default: + return state; + } +}; + +const welcomeGuideManuallyOpenedReducer = ( state = false, action ) => { + switch ( action.type ) { + case 'WPCOM_WELCOME_GUIDE_SHOW_SET': + if ( typeof action.openedManually !== 'undefined' ) { + return action.openedManually; + } + return state; + + case 'WPCOM_WELCOME_GUIDE_RESET_STORE': + return false; + + default: + return state; + } +}; + +// TODO: next PR convert file to Typescript to ensure control of tourRating values: null, 'thumbs-up' 'thumbs-down' +const tourRatingReducer = ( state = undefined, action ) => { + switch ( action.type ) { + case 'WPCOM_WELCOME_GUIDE_TOUR_RATING_SET': + return action.tourRating; + case 'WPCOM_WELCOME_GUIDE_RESET_STORE': + return undefined; + default: + return state; + } +}; + +const welcomeGuideVariantReducer = ( state = DEFAULT_VARIANT, action ) => { + switch ( action.type ) { + case 'WPCOM_WELCOME_GUIDE_FETCH_STATUS_SUCCESS': + return action.response.variant; + case 'WPCOM_HAS_USED_PATTERNS_MODAL': + return state === BLANK_CANVAS_VARIANT ? DEFAULT_VARIANT : state; + case 'WPCOM_WELCOME_GUIDE_RESET_STORE': + return DEFAULT_VARIANT; + default: + return state; + } +}; + +const shouldShowFirstPostPublishedModalReducer = ( state = false, action ) => { + switch ( action.type ) { + case 'WPCOM_SET_SHOULD_SHOW_FIRST_POST_PUBLISHED_MODAL': + return action.value; + case 'WPCOM_WELCOME_GUIDE_RESET_STORE': + return false; + default: + return state; + } +}; + +const reducer = combineReducers( { + welcomeGuideManuallyOpened: welcomeGuideManuallyOpenedReducer, + showWelcomeGuide: showWelcomeGuideReducer, + tourRating: tourRatingReducer, + welcomeGuideVariant: welcomeGuideVariantReducer, + shouldShowFirstPostPublishedModal: shouldShowFirstPostPublishedModalReducer, +} ); + +export const actions = { + *fetchWelcomeGuideStatus() { + const response = yield apiFetchControls( { path: '/wpcom/v2/block-editor/nux' } ); + + return { + type: 'WPCOM_WELCOME_GUIDE_FETCH_STATUS_SUCCESS', + response, + }; + }, + *fetchShouldShowFirstPostPublishedModal() { + const response = yield apiFetchControls( { + path: '/wpcom/v2/block-editor/should-show-first-post-published-modal', + } ); + + return { + type: 'WPCOM_SET_SHOULD_SHOW_FIRST_POST_PUBLISHED_MODAL', + value: response.should_show_first_post_published_modal, + }; + }, + setShowWelcomeGuide: ( show, { openedManually, onlyLocal } = {} ) => { + if ( ! onlyLocal ) { + apiFetch( { + path: '/wpcom/v2/block-editor/nux', + method: 'POST', + data: { show_welcome_guide: show }, + } ); + } + + return { + type: 'WPCOM_WELCOME_GUIDE_SHOW_SET', + show, + openedManually, + }; + }, + setTourRating: tourRating => { + return { type: 'WPCOM_WELCOME_GUIDE_TOUR_RATING_SET', tourRating }; + }, + setUsedPageOrPatternsModal: () => { + return { type: 'WPCOM_HAS_USED_PATTERNS_MODAL' }; + }, + // The `resetStore` action is only used for testing to reset the + // store inbetween tests. + resetStore: () => ( { + type: 'WPCOM_WELCOME_GUIDE_RESET_STORE', + } ), +}; + +export const selectors = { + isWelcomeGuideManuallyOpened: state => state.welcomeGuideManuallyOpened, + isWelcomeGuideShown: state => !! state.showWelcomeGuide, + isWelcomeGuideStatusLoaded: state => typeof state.showWelcomeGuide !== 'undefined', + getTourRating: state => state.tourRating, + // the 'modal' variant previously used for mobile has been removed but its slug may still be persisted in local storage + getWelcomeGuideVariant: state => + state.welcomeGuideVariant === 'modal' ? DEFAULT_VARIANT : state.welcomeGuideVariant, + getShouldShowFirstPostPublishedModal: state => state.shouldShowFirstPostPublishedModal, +}; + +/** + * Register the wpcom-welcome-guide store + */ +export function register() { + return registerStore( 'automattic/wpcom-welcome-guide', { + reducer, + actions, + selectors, + controls, + persist: true, + } ); +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/test/store.test.js b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/test/store.test.js new file mode 100644 index 0000000000000..352fbd939e698 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/test/store.test.js @@ -0,0 +1,127 @@ +import { dispatch, select } from '@wordpress/data'; +import waitForExpect from 'wait-for-expect'; +import { register, DEFAULT_VARIANT } from '../store'; + +const STORE_KEY = 'automattic/wpcom-welcome-guide'; + +beforeAll( () => { + register(); + jest.useRealTimers(); // Required for wait-for-expect to work. +} ); + +let originalFetch; +beforeEach( () => { + dispatch( STORE_KEY ).resetStore(); + originalFetch = window.fetch; + jest.spyOn( window, 'fetch' ).mockImplementation(); +} ); + +afterEach( () => { + window.fetch = originalFetch; +} ); + +test( 'resetting the store', async () => { + window.fetch.mockResolvedValue( { + status: 200, + json: () => Promise.resolve( { show_welcome_guide: true, variant: 'modal' } ), + } ); + + dispatch( STORE_KEY ).fetchWelcomeGuideStatus(); + await waitForExpect( () => + expect( select( STORE_KEY ).isWelcomeGuideStatusLoaded() ).toBe( true ) + ); + dispatch( STORE_KEY ).setShowWelcomeGuide( true, { openedManually: true } ); + dispatch( STORE_KEY ).setTourRating( 'thumbs-up' ); + + dispatch( STORE_KEY ).resetStore(); + + expect( select( STORE_KEY ).isWelcomeGuideManuallyOpened() ).toBe( false ); + expect( select( STORE_KEY ).isWelcomeGuideShown() ).toBe( false ); + expect( select( STORE_KEY ).isWelcomeGuideStatusLoaded() ).toBe( false ); + expect( select( STORE_KEY ).getTourRating() ).toBeUndefined(); + expect( select( STORE_KEY ).getWelcomeGuideVariant() ).toBe( DEFAULT_VARIANT ); +} ); + +test( "by default the store isn't loaded", () => { + const isLoaded = select( STORE_KEY ).isWelcomeGuideStatusLoaded(); + expect( isLoaded ).toBe( false ); +} ); + +test( 'after fetching the guide status the store is loaded', async () => { + window.fetch.mockResolvedValue( { + status: 200, + json: () => Promise.resolve( { show_welcome_guide: true, variant: DEFAULT_VARIANT } ), + } ); + + dispatch( STORE_KEY ).fetchWelcomeGuideStatus(); + + await waitForExpect( () => { + const isLoaded = select( STORE_KEY ).isWelcomeGuideStatusLoaded(); + expect( isLoaded ).toBe( true ); + } ); + + expect( window.fetch ).toHaveBeenCalledWith( + '/wpcom/v2/block-editor/nux?_locale=user', + expect.anything() + ); + + // Check the store is loaded with the state that came from the server + const isWelcomeGuideShown = select( STORE_KEY ).isWelcomeGuideShown(); + expect( isWelcomeGuideShown ).toBe( true ); + const welcomeGuideVariant = select( STORE_KEY ).getWelcomeGuideVariant(); + expect( welcomeGuideVariant ).toBe( DEFAULT_VARIANT ); +} ); + +test( 'toggle welcome guide visibility', () => { + // setShowWelcomeGuide kicks off a save. This mock fixes unresolved promise + // rejection errors that appear in CLI output + window.fetch.mockResolvedValue( { status: 200, json: () => Promise.resolve( {} ) } ); + + dispatch( STORE_KEY ).setShowWelcomeGuide( true ); + expect( select( STORE_KEY ).isWelcomeGuideShown() ).toBe( true ); + + dispatch( STORE_KEY ).setShowWelcomeGuide( false ); + expect( select( STORE_KEY ).isWelcomeGuideShown() ).toBe( false ); +} ); + +test( 'guide manually opened flag is false by default', () => { + expect( select( STORE_KEY ).isWelcomeGuideManuallyOpened() ).toBe( false ); +} ); + +test( '"manually opened" flag can be set when opening welcome guide', () => { + // setShowWelcomeGuide kicks off a save. This mock fixes unresolved promise + // rejection errors that appear in CLI output + window.fetch.mockResolvedValue( { status: 200, json: () => Promise.resolve( {} ) } ); + + dispatch( STORE_KEY ).setShowWelcomeGuide( true, { openedManually: true } ); + expect( select( STORE_KEY ).isWelcomeGuideManuallyOpened() ).toBe( true ); + + dispatch( STORE_KEY ).setShowWelcomeGuide( true, { openedManually: false } ); + expect( select( STORE_KEY ).isWelcomeGuideManuallyOpened() ).toBe( false ); +} ); + +test( 'leaving `openedManually` unspecified leaves the flag unchanged', () => { + // setShowWelcomeGuide kicks off a save. This mock fixes unresolved promise + // rejection errors that appear in CLI output + window.fetch.mockResolvedValue( { status: 200, json: () => Promise.resolve( {} ) } ); + + expect( select( STORE_KEY ).isWelcomeGuideManuallyOpened() ).toBe( false ); + + dispatch( STORE_KEY ).setShowWelcomeGuide( true, { openedManually: true } ); + expect( select( STORE_KEY ).isWelcomeGuideManuallyOpened() ).toBe( true ); + + dispatch( STORE_KEY ).setShowWelcomeGuide( false ); + expect( select( STORE_KEY ).isWelcomeGuideManuallyOpened() ).toBe( true ); +} ); + +test( 'tour rating is "undefined" by default', () => { + expect( select( STORE_KEY ).getTourRating() ).toBeUndefined(); +} ); + +test( 'tour rating can be set to "thumbs-up" or "thumbs-down"', () => { + dispatch( STORE_KEY ).setTourRating( 'thumbs-up' ); + expect( select( STORE_KEY ).getTourRating() ).toBe( 'thumbs-up' ); + + dispatch( STORE_KEY ).setTourRating( 'thumbs-down' ); + expect( select( STORE_KEY ).getTourRating() ).toBe( 'thumbs-down' ); +} ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/video-celebration-modal/index.jsx b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/video-celebration-modal/index.jsx new file mode 100644 index 0000000000000..c773908a4cdc1 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/video-celebration-modal/index.jsx @@ -0,0 +1,113 @@ +import { Button } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { useState, useRef, useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import videoSuccessImage from '../../../../assets/images/video-success.svg'; +import { + useShouldShowVideoCelebrationModal, + useSiteIntent, + useHasSeenVideoCelebrationModal, +} from '../../../../common/tour-kit'; +import NuxModal from '../nux-modal'; +import './style.scss'; + +// Shows a celebration modal after a video is first uploaded to a site and the editor is saved. +const VideoCelebrationModalInner = () => { + const [ isModalOpen, setIsModalOpen ] = useState( false ); + const [ hasDisplayedModal, setHasDisplayedModal ] = useState( false ); + const isSiteEditor = useSelect( select => !! select( 'core/edit-site' ) ); + const previousIsEditorSaving = useRef( false ); + const { updateHasSeenVideoCelebrationModal } = useHasSeenVideoCelebrationModal(); + + const { isEditorSaving } = useSelect( select => { + if ( isSiteEditor ) { + const isSavingSite = + select( 'core' ).isSavingEntityRecord( 'root', 'site' ) && + ! select( 'core' ).isAutosavingEntityRecord( 'root', 'site' ); + + const page = select( 'core/edit-site' ).getPage(); + const pageId = parseInt( page?.context?.postId ); + const isSavingEntity = + select( 'core' ).isSavingEntityRecord( 'postType', 'page', pageId ) && + ! select( 'core' ).isAutosavingEntityRecord( 'postType', 'page', pageId ); + const pageEntity = select( 'core' ).getEntityRecord( 'postType', 'page', pageId ); + + return { + isEditorSaving: isSavingSite || isSavingEntity, + linkUrl: pageEntity?.link, + }; + } + + const currentPost = select( 'core/editor' ).getCurrentPost(); + const isSavingEntity = + select( 'core' ).isSavingEntityRecord( 'postType', currentPost?.type, currentPost?.id ) && + ! select( 'core' ).isAutosavingEntityRecord( 'postType', currentPost?.type, currentPost?.id ); + + return { + isEditorSaving: isSavingEntity, + }; + } ); + const shouldShowVideoCelebrationModal = useShouldShowVideoCelebrationModal( isEditorSaving ); + + useEffect( () => { + // Conditions to show modal: + // - user just finished saving + // - celebration modal hasn't been viewed/isn't visible + // - site intent is 'videopress' + // - site has uploaded a video + if ( + ! isEditorSaving && + previousIsEditorSaving.current && + ! hasDisplayedModal && + shouldShowVideoCelebrationModal + ) { + setIsModalOpen( true ); + setHasDisplayedModal( true ); + updateHasSeenVideoCelebrationModal( true ); + } + previousIsEditorSaving.current = isEditorSaving; + }, [ + isEditorSaving, + hasDisplayedModal, + shouldShowVideoCelebrationModal, + updateHasSeenVideoCelebrationModal, + ] ); + + const closeModal = () => setIsModalOpen( false ); + return ( + + + + + } + onRequestClose={ closeModal } + /> + ); +}; + +const VideoCelebrationModal = () => { + const { siteIntent: intent } = useSiteIntent(); + if ( 'videopress' === intent ) { + return ; + } + return null; +}; + +export default VideoCelebrationModal; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/video-celebration-modal/style.scss b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/video-celebration-modal/style.scss new file mode 100644 index 0000000000000..eca641aefdaa7 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/video-celebration-modal/style.scss @@ -0,0 +1,37 @@ +@import "@wordpress/base-styles/breakpoints"; +@import "@wordpress/base-styles/mixins"; + +.wpcom-site-editor-video-celebration-modal { + .components-modal__content { + @include break-small { + padding: 48px 90px; + } + } + + .wpcom-block-editor-nux-modal__image-container { + img { + width: 158px; + height: 85px; + } + } + + .wpcom-block-editor-nux-modal__buttons { + .components-button { + min-width: 113px; + &:not(.is-primary) { + border: 1px solid #c3c4c7; + border-radius: 4px; + } + } + } + + .components-modal__header { + button { + svg { + path { + transform: scale(1); + } + } + } + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-modal/style.scss b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-modal/style.scss new file mode 100644 index 0000000000000..5d16245ab222f --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-modal/style.scss @@ -0,0 +1,208 @@ +@import "@automattic/typography/styles/fonts"; + +$wpcom-modal-breakpoint: 660px; + +$wpcom-modal-padding-v: 40px; +$wpcom-modal-padding-h: 50px; +$wpcom-modal-content-min-height: 350px; +$wpcom-modal-footer-padding-v: 20px; +$wpcom-modal-footer-height: 30px + ( $wpcom-modal-footer-padding-v * 2 ); + +// Core modal style overrides +.wpcom-block-editor-nux { + &.components-modal__frame { + overflow: visible; + height: 65vh; + top: calc(17.5vh - #{$wpcom-modal-footer-height * 0.5}); + + @media (max-width: $wpcom-modal-breakpoint) { + width: 90vw; + min-width: 90vw; + left: 5vw; + right: 5vw; + } + + @media (min-width: $wpcom-modal-breakpoint) { + width: 720px; + height: $wpcom-modal-content-min-height; + top: calc(50% - #{$wpcom-modal-footer-height * 0.5}); + } + } + + .components-modal__header { + position: absolute; + max-width: 90%; + left: 5%; + @media (min-width: $wpcom-modal-breakpoint) { + display: none; + } + } + + .components-guide__container { + margin-top: 0; + } + + .components-guide__footer { + position: absolute; + width: 100%; + height: $wpcom-modal-footer-height; + bottom: $wpcom-modal-footer-height * -1; + left: 0; + padding: $wpcom-modal-footer-padding-v 0; + margin: 0; + display: flex; + justify-content: center; + background: var(--studio-white); + border-top: 1px solid #dcdcde; + + @media (min-width: $wpcom-modal-breakpoint) { + border-top: none; + } + } + + .components-guide__page { + position: absolute; + width: 100%; + max-width: 90vw; + height: 100%; + justify-content: start; + + @media (min-width: $wpcom-modal-breakpoint) { + max-width: 100%; + } + } + + .components-guide__page-control { + position: relative; + height: 0; + top: 100%; + overflow: visible; + margin: 0 auto; + z-index: 2; + + &::before { + display: inline-block; + content: ""; + height: $wpcom-modal-footer-height; + vertical-align: middle; + } + + li { + vertical-align: middle; + margin-bottom: 0; + } + + // Temporarily disable dots on mobile as alignment is wonky. + display: none; + @media (min-width: $wpcom-modal-breakpoint) { + display: block; + } + } +} + +.wpcom-block-editor-nux__page { + display: flex; + flex-direction: column-reverse; + justify-content: flex-end; + background: var(--studio-white); + width: 100%; + height: 90%; + max-width: 90vw; + + @media (min-width: $wpcom-modal-breakpoint) { + flex-direction: row; + justify-content: flex-start; + position: absolute; + max-width: 100%; + min-height: $wpcom-modal-content-min-height; + bottom: 0; + } +} + +.wpcom-block-editor-nux__text, +.wpcom-block-editor-nux__visual { + @media (min-width: $wpcom-modal-breakpoint) { + flex: 1 0 50%; + min-width: 290px; + } +} + +.wpcom-block-editor-nux__text { + padding: 0 25px 25px; + height: 60%; + + @media (min-width: $wpcom-modal-breakpoint) { + height: auto; + padding: $wpcom-modal-padding-v $wpcom-modal-padding-h; + } +} +.wpcom-block-editor-nux__visual { + height: 40%; + background: #1381d8; + text-align: center; + + @media (min-width: $wpcom-modal-breakpoint) { + height: auto; + } +} + +.wpcom-block-editor-nux__heading { + /* Gray / Gray 90 */ + color: #1d2327; + + font-family: $brand-serif; + font-weight: 400; + /* stylelint-disable-next-line declaration-property-unit-allowed-list */ + font-size: 32px; + line-height: 1.19; + letter-spacing: -0.4px; + + @media (min-width: $wpcom-modal-breakpoint) { + /* stylelint-disable-next-line declaration-property-unit-allowed-list */ + font-size: 42px; + } + + // TODO: remove this hack once the welcome editor deals better with + // overflowing text + body.locale-de & { + /* stylelint-disable-next-line declaration-property-unit-allowed-list */ + font-size: 24px; + + @media (min-width: $wpcom-modal-breakpoint) { + /* stylelint-disable-next-line declaration-property-unit-allowed-list */ + font-size: 28px; + } + } +} + +.wpcom-block-editor-nux__description { + /* stylelint-disable-next-line declaration-property-unit-allowed-list */ + font-size: 15px; + line-height: 22px; + + /* Gray / Gray 60 */ + color: #50575e; + + @media (min-width: $wpcom-modal-breakpoint) { + /* stylelint-disable-next-line declaration-property-unit-allowed-list */ + font-size: 17px; + line-height: 26px; + } +} + +.wpcom-block-editor-nux__image { + max-width: 100%; + height: auto; + flex: 1; + align-self: center; + + &.align-bottom { + align-self: flex-end; + } + + max-height: 100%; + + @media (min-width: $wpcom-modal-breakpoint) { + max-height: none; + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-modal/wpcom-nux.js b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-modal/wpcom-nux.js new file mode 100644 index 0000000000000..6fb72e578b66c --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-modal/wpcom-nux.js @@ -0,0 +1,153 @@ +import { Guide, GuidePage } from '@wordpress/components'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import blockPickerImage from '../../../../assets/images/block-picker.svg'; +import editorImage from '../../../../assets/images/editor.svg'; +import previewImage from '../../../../assets/images/preview.svg'; +import privateImage from '../../../../assets/images/private.svg'; +import { wpcomTrackEvent } from '../../../../common/tracks'; + +import './style.scss'; + +/** + * The nux component. + */ +function WpcomNux() { + const { show, isNewPageLayoutModalOpen, isManuallyOpened } = useSelect( select => ( { + show: select( 'automattic/wpcom-welcome-guide' ).isWelcomeGuideShown(), + isNewPageLayoutModalOpen: + select( 'automattic/starter-page-layouts' ) && // Handle the case where SPT is not initalized. + select( 'automattic/starter-page-layouts' ).isOpen(), + isManuallyOpened: select( 'automattic/wpcom-welcome-guide' ).isWelcomeGuideManuallyOpened(), + } ) ); + + const { setShowWelcomeGuide } = useDispatch( 'automattic/wpcom-welcome-guide' ); + + // Track opening of the welcome guide + useEffect( () => { + if ( show && ! isNewPageLayoutModalOpen ) { + wpcomTrackEvent( 'calypso_editor_wpcom_nux_open', { + is_gutenboarding: window.calypsoifyGutenberg?.isGutenboarding, + is_manually_opened: isManuallyOpened, + } ); + } + }, [ isManuallyOpened, isNewPageLayoutModalOpen, show ] ); + + if ( ! show || isNewPageLayoutModalOpen ) { + return null; + } + + const dismissWpcomNux = () => { + wpcomTrackEvent( 'calypso_editor_wpcom_nux_dismiss', { + is_gutenboarding: window.calypsoifyGutenberg?.isGutenboarding, + } ); + setShowWelcomeGuide( false, { openedManually: false } ); + }; + + const nuxPages = getWpcomNuxPages(); + + return ( + + { nuxPages.map( ( nuxPage, index ) => ( + + ) ) } + + ); +} + +/** + * This function returns a collection of NUX slide data + * @returns { Array } a collection of props + */ +function getWpcomNuxPages() { + return [ + { + heading: __( 'Welcome to your website', 'jetpack-mu-wpcom' ), + description: __( + 'Edit your homepage, add the pages you need, and change your site’s look and feel.', + 'jetpack-mu-wpcom' + ), + imgSrc: editorImage, + alignBottom: true, + }, + { + heading: __( 'Add or edit your content', 'jetpack-mu-wpcom' ), + description: __( + 'Edit the placeholder content we’ve started you off with, or click the plus sign to add more content.', + 'jetpack-mu-wpcom' + ), + imgSrc: blockPickerImage, + }, + { + heading: __( 'Preview your site as you go', 'jetpack-mu-wpcom' ), + description: __( + 'As you edit your site content, click “Preview” to see your site the way your visitors will.', + 'jetpack-mu-wpcom' + ), + imgSrc: previewImage, + alignBottom: true, + }, + { + heading: __( 'Hidden until you’re ready', 'jetpack-mu-wpcom' ), + description: __( + 'Your site will remain hidden until launched. Click “Launch” in the toolbar to share it with the world.', + 'jetpack-mu-wpcom' + ), + imgSrc: privateImage, + alignBottom: true, + }, + ]; +} + +/** + * Display the Nux page + * + * @param props - The props of the component. + * @param props.pageNumber - The number of page. + * @param props.isLastPage - Whether the current page is the last one. + * @param props.alignBottom - Whether to align bottom. + * @param props.heading - The text of heading. + * @param props.description - The text of description. + * @param props.imgSrc - The src of image. + */ +function NuxPage( { pageNumber, isLastPage, alignBottom = false, heading, description, imgSrc } ) { + useEffect( () => { + wpcomTrackEvent( 'calypso_editor_wpcom_nux_slide_view', { + slide_number: pageNumber, + is_last_slide: isLastPage, + is_gutenboarding: window.calypsoifyGutenberg?.isGutenboarding, + } ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [] ); + return ( + +
+

{ heading }

+
{ description }
+
+
+ +
+
+ ); +} + +export default WpcomNux; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-tour/get-editor-type.ts b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-tour/get-editor-type.ts new file mode 100644 index 0000000000000..876bd2e34a5d3 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-tour/get-editor-type.ts @@ -0,0 +1,41 @@ +import { select } from '@wordpress/data'; + +/** + * Post (Post Type: ‘post’) + * Page (Post Type: ‘page’) + * Attachment (Post Type: ‘attachment’) + * Revision (Post Type: ‘revision’) + * Navigation menu (Post Type: ‘nav_menu_item’) + * Block templates (Post Type: ‘wp_template’) + * Template parts (Post Type: ‘wp_template_part’) + * @see https://developer.wordpress.org/themes/basics/post-types/#default-post-types + */ + +type PostType = + | 'post' + | 'page' + | 'attachment' + | 'revision' + | 'nav_menu_item' + | 'wp_template' + | 'wp_template_part' + | null; + +type EditorType = 'site' | PostType; + +export const getEditorType = (): EditorType | undefined => { + /** + * Beware when using this method to figure out if we are in the site editor. + * @see https://github.com/WordPress/gutenberg/issues/46616#issuecomment-1355301090 + * @see https://github.com/Automattic/jetpack/blob/2e56d0d/projects/plugins/jetpack/extensions/shared/get-editor-type.js + */ + if ( select( 'core/edit-site' ) ) { + return 'site'; + } + + if ( select( 'core/editor' ) ) { + return select( 'core/editor' ).getCurrentPostType() as PostType; + } + + return undefined; +}; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-tour/style-tour.scss b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-tour/style-tour.scss new file mode 100644 index 0000000000000..51a9eaaf2407f --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-tour/style-tour.scss @@ -0,0 +1,51 @@ +@use "sass:math"; +@import "@wordpress/base-styles/colors"; +@import "@wordpress/base-styles/mixins"; +@import "@wordpress/base-styles/variables"; +@import "@wordpress/base-styles/z-index"; + +$welcome-tour-card-media-extra-padding: 14%; // temporary value, to match the padding of the desktop instructional graphics + +.wpcom-editor-welcome-tour { + .wpcom-editor-welcome-tour__step { + &.is-with-extra-padding { + .components-card__media { + background-color: #e7eaeb; // the color of the background used in desktop graphics + + img { + left: $welcome-tour-card-media-extra-padding; + top: $welcome-tour-card-media-extra-padding; + width: 100% - $welcome-tour-card-media-extra-padding; + } + } + } + } + + .wpcom-tour-kit-step-card-overlay-controls { + position: absolute; + } +} + +// @todo clk - it this used? +.wpcom-editor-welcome-tour-card-frame { + position: relative; + + .components-guide__page-control { + bottom: 0; + left: $grid-unit-20; + margin: 0; + position: absolute; + + li { + margin-bottom: 0; + } + } +} + +// Adding it to hide the WelcomeTour when the W-icon is pressed on mobile +#wpwrap.wp-responsive-open { + + .tour-kit.wpcom-tour-kit { + display: none; + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-tour/test/tour-steps.test.ts b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-tour/test/tour-steps.test.ts new file mode 100644 index 0000000000000..fac4d7e1547a1 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-tour/test/tour-steps.test.ts @@ -0,0 +1,110 @@ +import '../get-editor-type'; +import getTourSteps from '../tour-steps'; + +jest.mock( '../get-editor-type', () => { + return { getEditorType: () => 'post' }; +} ); + +describe( 'Welcome Tour', () => { + describe( 'Tour Steps', () => { + it( 'should retrieve the "Welcome to WordPress!" slide', () => { + expect( getTourSteps( 'en', true ) ).toEqual( + expect.arrayContaining( [ + expect.objectContaining( { + meta: expect.objectContaining( { heading: 'Welcome to WordPress!' } ), + } ), + ] ) + ); + } ); + it( 'should retrieve the "Everything is a block" slide', () => { + expect( getTourSteps( 'en', true ) ).toEqual( + expect.arrayContaining( [ + expect.objectContaining( { + meta: expect.objectContaining( { heading: 'Everything is a block' } ), + } ), + ] ) + ); + } ); + it( 'should retrieve the "Adding a new block" slide', () => { + expect( getTourSteps( 'en', true ) ).toEqual( + expect.arrayContaining( [ + expect.objectContaining( { + meta: expect.objectContaining( { heading: 'Adding a new block' } ), + } ), + ] ) + ); + } ); + it( 'should retrieve the "Click a block to change it" slide', () => { + expect( getTourSteps( 'en', true ) ).toEqual( + expect.arrayContaining( [ + expect.objectContaining( { + meta: expect.objectContaining( { heading: 'Click a block to change it' } ), + } ), + ] ) + ); + } ); + it( 'should retrieve the "More Options" slide', () => { + expect( getTourSteps( 'en', true ) ).toEqual( + expect.arrayContaining( [ + expect.objectContaining( { + meta: expect.objectContaining( { heading: 'More Options' } ), + } ), + ] ) + ); + } ); + it( 'should retrieve the "Find your way" slide', () => { + expect( getTourSteps( 'en', true ) ).toEqual( + expect.arrayContaining( [ + expect.objectContaining( { + meta: expect.objectContaining( { heading: 'Find your way' } ), + } ), + ] ) + ); + } ); + it( 'should retrieve the "Undo any mistake" slide', () => { + expect( getTourSteps( 'en', true ) ).toEqual( + expect.arrayContaining( [ + expect.objectContaining( { + meta: expect.objectContaining( { heading: 'Undo any mistake' } ), + } ), + ] ) + ); + } ); + it( 'should retrieve the "Drag & drop" slide', () => { + expect( getTourSteps( 'en', true ) ).toEqual( + expect.arrayContaining( [ + expect.objectContaining( { + meta: expect.objectContaining( { heading: 'Undo any mistake' } ), + } ), + ] ) + ); + } ); + it( 'should retrieve the "Edit your site" slide, when in site editor', () => { + expect( getTourSteps( 'en', true, true ) ).toEqual( + expect.arrayContaining( [ + expect.objectContaining( { + meta: expect.objectContaining( { heading: 'Edit your site' } ), + } ), + ] ) + ); + } ); + it( 'should not retrieve the "Edit your site" slide, when not in site editor', () => { + expect( getTourSteps( 'en', true, false ) ).not.toEqual( + expect.arrayContaining( [ + expect.objectContaining( { + meta: expect.objectContaining( { heading: 'Edit your site' } ), + } ), + ] ) + ); + } ); + it( 'should retrieve the "Congratulations!" slide, with correct url', () => { + expect( getTourSteps( 'en', true ) ).toEqual( + expect.arrayContaining( [ + expect.objectContaining( { + meta: expect.objectContaining( { heading: 'Congratulations!' } ), + } ), + ] ) + ); + } ); + } ); +} ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-tour/tour-launch.jsx b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-tour/tour-launch.jsx new file mode 100644 index 0000000000000..1df746ef992b5 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-tour/tour-launch.jsx @@ -0,0 +1,243 @@ +import { useLocale } from '@automattic/i18n-utils'; +import { useDispatch, useSelect, dispatch } from '@wordpress/data'; +import { useEffect, useMemo } from '@wordpress/element'; +import { + WpcomTourKit, + usePrefetchTourAssets, + START_WRITING_FLOW, + DESIGN_FIRST_FLOW, + useSiteIntent, + useSitePlan, +} from '../../../../common/tour-kit'; +import { wpcomTrackEvent } from '../../../../common/tracks'; +import { getEditorType } from './get-editor-type'; +import useTourSteps from './use-tour-steps'; +import './style-tour.scss'; + +/** + * The Welcome Tour of the Launch. + */ +function LaunchWpcomWelcomeTour() { + const { show, isNewPageLayoutModalOpen, isManuallyOpened } = useSelect( + select => ( { + show: select( 'automattic/wpcom-welcome-guide' ).isWelcomeGuideShown(), + // Handle the case where the new page pattern modal is initialized and open + isNewPageLayoutModalOpen: + select( 'automattic/starter-page-layouts' ) && + select( 'automattic/starter-page-layouts' ).isOpen(), + isManuallyOpened: select( 'automattic/wpcom-welcome-guide' ).isWelcomeGuideManuallyOpened(), + } ), + [] + ); + const { siteIntent, siteIntentFetched } = useSiteIntent(); + const localeSlug = useLocale(); + const editorType = getEditorType(); + const { siteIntent: intent } = useSiteIntent(); + // We check the URL param along with site intent because the param loads faster and prevents element flashing. + const isBlogOnboardingFlow = intent === START_WRITING_FLOW || intent === DESIGN_FIRST_FLOW; + + const tourSteps = useTourSteps( localeSlug, false, false, null, siteIntent ); + + // Preload first card image (others preloaded after open state confirmed) + usePrefetchTourAssets( [ tourSteps[ 0 ] ] ); + + useEffect( () => { + if ( isBlogOnboardingFlow ) { + return; + } + if ( ! show && ! isNewPageLayoutModalOpen ) { + return; + } + + if ( ! siteIntentFetched ) { + return; + } + + // Track opening of the Welcome Guide + wpcomTrackEvent( 'calypso_editor_wpcom_tour_open', { + is_gutenboarding: window.calypsoifyGutenberg?.isGutenboarding, + is_manually_opened: isManuallyOpened, + intent: siteIntent, + editor_type: editorType, + } ); + }, [ + isNewPageLayoutModalOpen, + isManuallyOpened, + show, + siteIntent, + siteIntentFetched, + editorType, + isBlogOnboardingFlow, + ] ); + + if ( ! show || isNewPageLayoutModalOpen || isBlogOnboardingFlow ) { + return null; + } + + return ; +} + +/** + * Display the welcome tour. + * + * @param props - The props of the component. + * @param props.siteIntent - The intent of the site. + */ +function WelcomeTour( { siteIntent } ) { + const sitePlan = useSitePlan( window._currentSiteId ); + const localeSlug = useLocale(); + const { setShowWelcomeGuide } = useDispatch( 'automattic/wpcom-welcome-guide' ); + const isGutenboarding = window.calypsoifyGutenberg?.isGutenboarding; + const isWelcomeTourNext = () => { + return new URLSearchParams( document.location.search ).has( 'welcome-tour-next' ); + }; + const isSiteEditor = useSelect( select => !! select( 'core/edit-site' ), [] ); + const currentTheme = useSelect( select => select( 'core' ).getCurrentTheme() ); + const themeName = currentTheme?.name?.raw?.toLowerCase() ?? null; + + const tourSteps = useTourSteps( + localeSlug, + isWelcomeTourNext(), + isSiteEditor, + themeName, + siteIntent + ); + + // Only keep Payment block step if user comes from seller simple flow + if ( ! ( 'sell' === siteIntent && sitePlan && 'ecommerce-bundle' !== sitePlan.product_slug ) ) { + const paymentBlockIndex = tourSteps.findIndex( step => step.slug === 'payment-block' ); + tourSteps.splice( paymentBlockIndex, 1 ); + } + const { isInserterOpened, isSidebarOpened, isSettingsOpened } = useSelect( + select => ( { + isInserterOpened: select( 'core/edit-post' ).isInserterOpened(), + isSidebarOpened: select( 'automattic/block-editor-nav-sidebar' )?.isSidebarOpened() ?? false, // The sidebar store may not always be loaded. + isSettingsOpened: + select( 'core/interface' ).getActiveComplementaryArea( 'core/edit-post' ) === + 'edit-post/document', + } ), + [] + ); + + const isTourMinimized = + isSidebarOpened || + ( window.matchMedia( `(max-width: 782px)` ).matches && + ( isInserterOpened || isSettingsOpened ) ); + + const editorType = getEditorType(); + + const tourConfig = { + steps: tourSteps, + closeHandler: ( _steps, currentStepIndex, source ) => { + wpcomTrackEvent( 'calypso_editor_wpcom_tour_dismiss', { + is_gutenboarding: isGutenboarding, + slide_number: currentStepIndex + 1, + action: source, + intent: siteIntent, + editor_type: editorType, + } ); + setShowWelcomeGuide( false, { openedManually: false } ); + }, + isMinimized: isTourMinimized, + options: { + tourRating: { + enabled: true, + useTourRating: () => { + return useSelect( + select => select( 'automattic/wpcom-welcome-guide' ).getTourRating(), + [] + ); + }, + onTourRate: rating => { + dispatch( 'automattic/wpcom-welcome-guide' ).setTourRating( rating ); + wpcomTrackEvent( 'calypso_editor_wpcom_tour_rate', { + thumbs_up: rating === 'thumbs-up', + is_gutenboarding: false, + intent: siteIntent, + editor_type: editorType, + } ); + }, + }, + callbacks: { + onMinimize: currentStepIndex => { + wpcomTrackEvent( 'calypso_editor_wpcom_tour_minimize', { + is_gutenboarding: isGutenboarding, + slide_number: currentStepIndex + 1, + intent: siteIntent, + editor_type: editorType, + } ); + }, + onMaximize: currentStepIndex => { + wpcomTrackEvent( 'calypso_editor_wpcom_tour_maximize', { + is_gutenboarding: isGutenboarding, + slide_number: currentStepIndex + 1, + intent: siteIntent, + editor_type: editorType, + } ); + }, + onStepViewOnce: currentStepIndex => { + const lastStepIndex = tourSteps.length - 1; + const { heading } = tourSteps[ currentStepIndex ].meta; + + wpcomTrackEvent( 'calypso_editor_wpcom_tour_slide_view', { + slide_number: currentStepIndex + 1, + is_last_slide: currentStepIndex === lastStepIndex, + slide_heading: heading, + is_gutenboarding: isGutenboarding, + intent: siteIntent, + editor_type: editorType, + } ); + }, + }, + effects: { + spotlight: isWelcomeTourNext() + ? { + styles: { + minWidth: '50px', + minHeight: '50px', + borderRadius: '2px', + }, + } + : undefined, + arrowIndicator: false, + }, + popperModifiers: [ + useMemo( + () => ( { + name: 'offset', + options: { + offset: ( { placement, reference } ) => { + if ( placement === 'bottom' ) { + const boundary = document.querySelector( '.edit-post-header' ); + + if ( ! boundary ) { + return; + } + + const boundaryRect = boundary.getBoundingClientRect(); + const boundaryBottomY = boundaryRect.height + boundaryRect.y; + const referenceBottomY = reference.height + reference.y; + + return [ 0, boundaryBottomY - referenceBottomY + 16 ]; + } + return [ 0, 0 ]; + }, + }, + } ), + [] + ), + ], + classNames: 'wpcom-editor-welcome-tour', + portalParentElement: document.getElementById( 'wpwrap' ), + }, + }; + + // Theme isn't immediately available, so we prevent rendering so the content doesn't switch after it is presented, since some content is based on theme + if ( null === themeName ) { + return null; + } + + return ; +} + +export default LaunchWpcomWelcomeTour; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-tour/use-tour-steps.tsx b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-tour/use-tour-steps.tsx new file mode 100644 index 0000000000000..6d0ad267c70c1 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/welcome-tour/use-tour-steps.tsx @@ -0,0 +1,445 @@ +import { localizeUrl } from '@automattic/i18n-utils'; +import { ExternalLink } from '@wordpress/components'; +import { useViewportMatch } from '@wordpress/compose'; +import { createInterpolateElement } from '@wordpress/element'; +import { __, _x } from '@wordpress/i18n'; +import { getQueryArg } from '@wordpress/url'; +import { wpcomTrackEvent } from '../../../../common/tracks'; +import { getEditorType } from './get-editor-type'; +import type { WpcomStep } from '../../../../common/tour-kit'; + +interface TourAsset { + desktop?: { src: string; type: string }; + mobile?: { src: string; type: string }; +} + +/** + * Get the tour asset by the key. + * + * @param key - The key of the tour asset. + */ +function getTourAssets( key: string ): TourAsset | undefined { + const CDN_PREFIX = 'https://s0.wp.com/i/editor-welcome-tour'; + const tourAssets = { + addBlock: { + desktop: { src: `${ CDN_PREFIX }/slide-add-block.gif`, type: 'image/gif' }, + mobile: { src: `${ CDN_PREFIX }/slide-add-block_mobile.gif`, type: 'image/gif' }, + }, + allBlocks: { desktop: { src: `${ CDN_PREFIX }/slide-all-blocks.gif`, type: 'image/gif' } }, + finish: { desktop: { src: `${ CDN_PREFIX }/slide-finish.png`, type: 'image/gif' } }, + makeBold: { desktop: { src: `${ CDN_PREFIX }/slide-make-bold.gif`, type: 'image/gif' } }, + moreOptions: { + desktop: { src: `${ CDN_PREFIX }/slide-more-options.gif`, type: 'image/gif' }, + mobile: { src: `${ CDN_PREFIX }/slide-more-options_mobile.gif`, type: 'image/gif' }, + }, + moveBlock: { + desktop: { src: `${ CDN_PREFIX }/slide-move-block.gif`, type: 'image/gif' }, + mobile: { src: `${ CDN_PREFIX }/slide-move-block_mobile.gif`, type: 'image/gif' }, + }, + findYourWay: { + desktop: { src: `${ CDN_PREFIX }/slide-find-your-way.gif`, type: 'image/gif' }, + }, + undo: { desktop: { src: `${ CDN_PREFIX }/slide-undo.gif`, type: 'image/gif' } }, + welcome: { + desktop: { src: `${ CDN_PREFIX }/slide-welcome.png`, type: 'image/png' }, + mobile: { src: `${ CDN_PREFIX }/slide-welcome_mobile.jpg`, type: 'image/jpeg' }, + }, + editYourSite: { + desktop: { + src: `https://s.w.org/images/block-editor/edit-your-site.gif?1`, + type: 'image/gif', + }, + mobile: { + src: `https://s.w.org/images/block-editor/edit-your-site.gif?1`, + type: 'image/gif', + }, + }, + videomakerWelcome: { + desktop: { src: `${ CDN_PREFIX }/slide-videomaker-welcome.png`, type: 'image/png' }, + }, + videomakerEdit: { + desktop: { src: `${ CDN_PREFIX }/slide-videomaker-edit.png`, type: 'image/png' }, + }, + } as { [ key: string ]: TourAsset }; + + return tourAssets[ key ]; +} + +/** + * Get the steps of the tour + * + * @param localeSlug - The slug of the locale. + * @param referencePositioning - The reference positioning. + * @param isSiteEditor - Whether is the site editor. + * @param themeName - The name of the theme. + * @param siteIntent - The intent of the current site. + */ +function useTourSteps( + localeSlug: string, + referencePositioning = false, + isSiteEditor = false, + themeName: string | null = null, + siteIntent: string | undefined = undefined +): WpcomStep[] { + const isVideoMaker = 'videomaker' === ( themeName ?? '' ); + const isPatternAssembler = !! getQueryArg( window.location.href, 'assembler' ); + const isMobile = useViewportMatch( 'mobile', '<' ); + const siteEditorCourseUrl = `https://wordpress.com/home/${ window.location.hostname }?courseSlug=site-editor-quick-start`; + const editorType = getEditorType(); + const onSiteEditorCourseLinkClick = () => { + wpcomTrackEvent( 'calypso_editor_wpcom_tour_site_editor_course_link_click', { + is_pattern_assembler: isPatternAssembler, + intent: siteIntent, + editor_type: editorType, + } ); + }; + + return [ + { + slug: 'welcome', + meta: { + heading: isPatternAssembler + ? __( 'Nice job! Your new page is set up.', 'jetpack-mu-wpcom' ) + : _x( 'Welcome to WordPress!', 'jetpack-mu-wpcom', 'jetpack-mu-wpcom' ), + descriptions: { + desktop: ( () => { + if ( isPatternAssembler ) { + return createInterpolateElement( + __( + 'This is the Site Editor, where you can change everything about your site, including adding content to your homepage. Watch these short videos and take this tour to get started.', + 'jetpack-mu-wpcom' + ), + { + link_to_site_editor_course: ( + + ), + } + ); + } + + return isSiteEditor + ? __( + 'Take this short, interactive tour to learn the fundamentals of the WordPress Site Editor.', + 'jetpack-mu-wpcom' + ) + : _x( + 'Take this short, interactive tour to learn the fundamentals of the WordPress editor.', + 'jetpack-mu-wpcom', + 'jetpack-mu-wpcom' + ); + } )(), + mobile: null, + }, + imgSrc: getTourAssets( isVideoMaker ? 'videomakerWelcome' : 'welcome' ), + imgLink: isPatternAssembler + ? { + href: siteEditorCourseUrl, + playable: true, + onClick: onSiteEditorCourseLinkClick, + } + : undefined, + }, + options: { + classNames: { + desktop: 'wpcom-editor-welcome-tour__step', + mobile: [ 'is-with-extra-padding', 'calypso_editor_wpcom_draft_post_modal_show' ], + }, + }, + }, + { + slug: 'everything-is-a-block', + meta: { + heading: __( 'Everything is a block', 'jetpack-mu-wpcom' ), + descriptions: { + desktop: __( + 'In the WordPress Editor, paragraphs, images, and videos are all blocks.', + 'jetpack-mu-wpcom' + ), + mobile: null, + }, + imgSrc: getTourAssets( 'allBlocks' ), + }, + options: { + classNames: { + desktop: 'wpcom-editor-welcome-tour__step', + mobile: 'wpcom-editor-welcome-tour__step', + }, + }, + }, + { + slug: 'add-block', + ...( referencePositioning && { + referenceElements: { + mobile: + '.edit-post-header .edit-post-header__toolbar .components-button.edit-post-header-toolbar__inserter-toggle', + desktop: + '.edit-post-header .edit-post-header__toolbar .components-button.edit-post-header-toolbar__inserter-toggle', + }, + } ), + meta: { + heading: __( 'Adding a new block', 'jetpack-mu-wpcom' ), + descriptions: { + desktop: __( + 'Click + to open the inserter. Then click the block you want to add.', + 'jetpack-mu-wpcom' + ), + mobile: __( + 'Tap + to open the inserter. Then tap the block you want to add.', + 'jetpack-mu-wpcom' + ), + }, + imgSrc: getTourAssets( 'addBlock' ), + }, + options: { + classNames: { + desktop: 'wpcom-editor-welcome-tour__step', + mobile: [ 'is-with-extra-padding', 'wpcom-editor-welcome-tour__step' ], + }, + }, + }, + { + slug: 'edit-block', + meta: { + heading: __( 'Click a block to change it', 'jetpack-mu-wpcom' ), + descriptions: { + desktop: isVideoMaker + ? __( + 'Use the toolbar to change the appearance of a selected block. Try replacing a video!', + 'jetpack-mu-wpcom' + ) + : _x( + 'Use the toolbar to change the appearance of a selected block. Try making it bold.', + 'jetpack-mu-wpcom', + 'jetpack-mu-wpcom' + ), + mobile: null, + }, + imgSrc: getTourAssets( isVideoMaker ? 'videomakerEdit' : 'makeBold' ), + }, + options: { + classNames: { + desktop: 'wpcom-editor-welcome-tour__step', + mobile: 'wpcom-editor-welcome-tour__step', + }, + }, + }, + { + slug: 'settings', + ...( referencePositioning && { + referenceElements: { + mobile: + '.edit-post-header .edit-post-header__settings .interface-pinned-items > button:nth-child(1)', + desktop: + '.edit-post-header .edit-post-header__settings .interface-pinned-items > button:nth-child(1)', + }, + } ), + meta: { + heading: __( 'More Options', 'jetpack-mu-wpcom' ), + descriptions: { + desktop: __( 'Click the settings icon to see even more options.', 'jetpack-mu-wpcom' ), + mobile: __( 'Tap the settings icon to see even more options.', 'jetpack-mu-wpcom' ), + }, + imgSrc: getTourAssets( 'moreOptions' ), + }, + options: { + classNames: { + desktop: 'wpcom-editor-welcome-tour__step', + mobile: [ 'is-with-extra-padding', 'wpcom-editor-welcome-tour__step' ], + }, + }, + }, + ...( ! isMobile + ? [ + { + slug: 'find-your-way', + meta: { + heading: __( 'Find your way', 'jetpack-mu-wpcom' ), + descriptions: { + desktop: __( + "Use List View to see all the blocks you've added. Click and drag any block to move it around.", + 'jetpack-mu-wpcom' + ), + mobile: null, + }, + imgSrc: getTourAssets( 'findYourWay' ), + }, + options: { + classNames: { + desktop: [ 'is-with-extra-padding', 'wpcom-editor-welcome-tour__step' ], + mobile: 'wpcom-editor-welcome-tour__step', + }, + }, + }, + ] + : [] ), + ...( ! isMobile + ? [ + { + slug: 'undo', + ...( referencePositioning && { + referenceElements: { + desktop: + '.edit-post-header .edit-post-header__toolbar .components-button.editor-history__undo', + }, + } ), + meta: { + heading: __( 'Undo any mistake', 'jetpack-mu-wpcom' ), + descriptions: { + desktop: __( + "Click the Undo button if you've made a mistake.", + 'jetpack-mu-wpcom' + ), + mobile: null, + }, + imgSrc: getTourAssets( 'undo' ), + }, + options: { + classNames: { + desktop: 'wpcom-editor-welcome-tour__step', + mobile: 'wpcom-editor-welcome-tour__step', + }, + }, + }, + ] + : [] ), + { + slug: 'drag-drop', + meta: { + heading: __( 'Drag & drop', 'jetpack-mu-wpcom' ), + descriptions: { + desktop: __( 'To move blocks around, click and drag the handle.', 'jetpack-mu-wpcom' ), + mobile: __( 'To move blocks around, tap the up and down arrows.', 'jetpack-mu-wpcom' ), + }, + imgSrc: getTourAssets( 'moveBlock' ), + }, + options: { + classNames: { + desktop: 'wpcom-editor-welcome-tour__step', + mobile: [ 'is-with-extra-padding', 'wpcom-editor-welcome-tour__step' ], + }, + }, + }, + { + slug: 'payment-block', + meta: { + heading: __( 'The Payments block', 'jetpack-mu-wpcom' ), + descriptions: { + desktop: ( + <> + { __( + 'The Payments block allows you to accept payments for one-time, monthly recurring, or annual payments on your website', + 'jetpack-mu-wpcom' + ) } +
+ + { __( 'Learn more', 'jetpack-mu-wpcom' ) } + + + ), + mobile: null, + }, + imgSrc: getTourAssets( 'welcome' ), + }, + options: { + classNames: { + desktop: 'wpcom-editor-welcome-tour__step', + mobile: 'wpcom-editor-welcome-tour__step', + }, + }, + }, + ...( isSiteEditor + ? [ + { + slug: 'edit-your-site', + meta: { + heading: __( 'Edit your site', 'jetpack-mu-wpcom' ), + descriptions: { + desktop: createInterpolateElement( + __( + 'Design everything on your site - from the header right down to the footer - in the Site Editor. Learn more', + 'jetpack-mu-wpcom' + ), + { + link_to_fse_docs: ( + + ), + } + ), + mobile: __( + 'Design everything on your site - from the header right down to the footer - in the Site Editor.', + 'jetpack-mu-wpcom' + ), + }, + imgSrc: getTourAssets( 'editYourSite' ), + }, + options: { + classNames: { + desktop: 'wpcom-editor-welcome-tour__step', + mobile: [ 'is-with-extra-padding', 'wpcom-editor-welcome-tour__step' ], + }, + }, + }, + ] + : [] ), + { + slug: 'congratulations', + meta: { + heading: __( 'Congratulations!', 'jetpack-mu-wpcom' ), + descriptions: { + desktop: createInterpolateElement( + __( + "You've learned the basics. Remember, your site is private until you decide to launch. View the block editing docs to learn more.", + 'jetpack-mu-wpcom' + ), + { + link_to_launch_site_docs: ( + + ), + link_to_editor_docs: ( + + ), + } + ), + mobile: null, + }, + imgSrc: getTourAssets( 'finish' ), + }, + options: { + classNames: { + desktop: 'wpcom-editor-welcome-tour__step', + mobile: 'wpcom-editor-welcome-tour__step', + }, + }, + }, + ]; +} + +export default useTourSteps; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-documentation-links/wpcom-documentation-links.ts b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-documentation-links/wpcom-documentation-links.ts index 8bbe84b3f6503..de02c9c2284ce 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-documentation-links/wpcom-documentation-links.ts +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-documentation-links/wpcom-documentation-links.ts @@ -2,13 +2,6 @@ import { localizeUrl } from '@automattic/i18n-utils'; import { addFilter } from '@wordpress/hooks'; import './wpcom-documentation-links.css'; -declare global { - interface Window { - _currentSiteId: number; - _currentSiteType: string; - } -} - /** * Override Core documentation that has matching WordPress.com documentation. * diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/blocks/navigation-menu/index.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/blocks/navigation-menu/index.php new file mode 100644 index 0000000000000..50697e6eb39b2 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/blocks/navigation-menu/index.php @@ -0,0 +1,129 @@ + 'a8c/navigation-menu' ) ); + + $styles = ''; + + $container_class = 'menu-primary-container'; + $toggle_class = 'button'; + if ( isset( $attributes['className'] ) ) { + $container_class .= ' ' . $attributes['className']; + $toggle_class .= ' ' . $attributes['className']; + } + + $align = ' alignwide'; + if ( isset( $attributes['align'] ) ) { + $align = empty( $attributes['align'] ) ? '' : ' align' . $attributes['align']; + } + $class = $align; + + if ( isset( $attributes['textAlign'] ) ) { + $class .= ' has-text-align-' . $attributes['textAlign']; + } else { + $class .= ' has-text-align-center'; + } + + if ( isset( $attributes['textColor'] ) ) { + $class .= ' has-text-color'; + $class .= ' has-' . $attributes['textColor'] . '-color'; + } elseif ( isset( $attributes['customTextColor'] ) ) { + $class .= ' has-text-color'; + $styles .= ' color: ' . $attributes['customTextColor'] . ';'; + } + + if ( isset( $attributes['backgroundColor'] ) ) { + $class .= ' has-background'; + $class .= ' has-' . $attributes['backgroundColor'] . '-background-color'; + } elseif ( isset( $attributes['customBackgroundColor'] ) ) { + $class .= ' has-background'; + $styles .= ' background-color: ' . $attributes['customBackgroundColor'] . ';'; + } + + if ( isset( $attributes['customFontSize'] ) ) { + $styles .= ' font-size: ' . $attributes['customFontSize'] . 'px;'; + } elseif ( isset( $attributes['fontSize'] ) ) { + $class .= ' has-' . $attributes['fontSize'] . '-font-size'; + } else { + $class .= ' has-small-font-size'; + } + + $container_class .= $class; + $toggle_class .= $class; + + $menu = wp_nav_menu( + array( + 'echo' => false, + 'fallback_cb' => 'get_fallback_navigation_menu', + 'items_wrap' => '
    %3$s
', + 'menu_class' => 'main-menu footer-menu', + 'theme_location' => 'menu-1', + 'container' => '', + ) + ); + + ob_start(); + // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped + ?> + + + false, + 'before' => false, + 'container' => 'ul', + 'echo' => false, + 'menu_class' => 'main-menu footer-menu', + 'sort_column' => 'menu_order, post_date', + ) + ); + + /** + * Filter the fallback page menu to use the same + * CSS class structure as a regularly built menu + * so we don't have to duplicate CSS selectors everywhere. + */ + $original_classes = array( 'children', 'page_item_has_sub-menu' ); + $replacement_classes = array( 'sub-menu', 'menu-item-has-children' ); + + return str_replace( $original_classes, $replacement_classes, $menu ); +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/blocks/post-content/index.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/blocks/post-content/index.php new file mode 100644 index 0000000000000..b2e8f00a0b846 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/blocks/post-content/index.php @@ -0,0 +1,43 @@ + 'a8c/post-content' ) ); + + $align = isset( $attributes['align'] ) ? ' align' . $attributes['align'] : ''; + + ob_start(); + ?> + +
+ +
+ + 'a8c/site-description' ) ); + + ob_start(); + + $styles = ''; + + $class = 'site-description wp-block-a8c-site-description'; + if ( isset( $attributes['className'] ) ) { + $class .= ' ' . $attributes['className']; + } + + $align = ' alignwide'; + if ( isset( $attributes['align'] ) ) { + $align = empty( $attributes['align'] ) ? '' : ' align' . $attributes['align']; + } + $class .= $align; + + if ( isset( $attributes['textAlign'] ) ) { + $class .= ' has-text-align-' . $attributes['textAlign']; + } else { + $class .= ' has-text-align-center'; + } + + if ( isset( $attributes['textColor'] ) ) { + $class .= ' has-text-color'; + $class .= ' has-' . $attributes['textColor'] . '-color'; + } elseif ( isset( $attributes['customTextColor'] ) ) { + $class .= ' has-text-color'; + $styles .= ' color: ' . $attributes['customTextColor'] . ';'; + } + + if ( isset( $attributes['backgroundColor'] ) ) { + $class .= ' has-background'; + $class .= ' has-' . $attributes['backgroundColor'] . '-background-color'; + } elseif ( isset( $attributes['customBackgroundColor'] ) ) { + $class .= ' has-background'; + $styles .= ' background-color: ' . $attributes['customBackgroundColor'] . ';'; + } + + if ( isset( $attributes['fontSize'] ) ) { + $class .= ' has-' . $attributes['fontSize'] . '-font-size'; + } elseif ( isset( $attributes['customFontSize'] ) ) { + $styles .= ' font-size: ' . $attributes['customFontSize'] . 'px;'; + } else { + $class .= ' has-small-font-size'; + } + + ?> +

+ +

+ 'a8c/site-title' ) ); + + ob_start(); + + $styles = ''; + + $class = 'site-title wp-block-a8c-site-title'; + if ( isset( $attributes['className'] ) ) { + $class .= ' ' . $attributes['className']; + } + + $align = ' alignwide'; + if ( isset( $attributes['align'] ) ) { + $align = empty( $attributes['align'] ) ? '' : ' align' . $attributes['align']; + } + $class .= $align; + + if ( isset( $attributes['textAlign'] ) ) { + $class .= ' has-text-align-' . $attributes['textAlign']; + } else { + $class .= ' has-text-align-center'; + } + + if ( isset( $attributes['textColor'] ) ) { + $class .= ' has-text-color'; + $class .= ' has-' . $attributes['textColor'] . '-color'; + } elseif ( isset( $attributes['customTextColor'] ) ) { + $class .= ' has-text-color'; + $styles .= ' color: ' . $attributes['customTextColor'] . ';'; + } + + if ( isset( $attributes['fontSize'] ) ) { + $class .= ' has-' . $attributes['fontSize'] . '-font-size'; + } elseif ( isset( $attributes['customFontSize'] ) ) { + $styles .= ' font-size: ' . $attributes['customFontSize'] . 'px;'; + } else { + $class .= ' has-normal-font-size'; + } + + ?> +

+ +

+ + 'a8c/template' ) ); + + $template = get_post( $attributes['templateId'] ); + + $align = isset( $attributes['align'] ) ? ' align' . $attributes['align'] : ''; + + setup_postdata( $template ); + ob_start(); + ?> + +
+ +
+ + errors() && in_array( $theme_slug, get_supported_themes(), true ); +} + +/** + * Hardcoded list of themes we support. + * Once upon a time, we relied on the `full-site-editing` tag in themes, + * but that conflicted with Core FSE and this project has been deprecated + * in favour of Core. + * + * @return array List of supported themes. + */ +function get_supported_themes() { + return array( + 'alves', + 'exford', + 'hever', + 'maywood', + 'morden', + 'shawburn', + 'stow', + 'varia', + ); +} + +/** + * Determines if the template parts have been inserted for the current theme. + * + * We want to gate on this check in is_full_site_editing_active so that we don't + * load FSE for sites which did not get template parts for some reason or another. + * + * For example, if a user activates theme A on their site and gets FSE, but then + * activates theme B which does not have FSE, they will not get FSE flows. If we + * retroactively add FSE support to theme B, the user should not get FSE flows + * because their site would be modified. Instead, FSE flows would become active + * when they specifically take action to re-activate the theme. + * + * @return bool True if the template parts have been inserted. False otherwise. + */ +function did_insert_template_parts() { + require_once dirname( __DIR__ ) . '/templates/class-wp-template-inserter.php'; + + $theme_slug = normalize_theme_slug( get_theme_slug() ); + $inserter = new WP_Template_Inserter( $theme_slug ); + return $inserter->is_template_data_inserted(); +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/templates/class-wp-template-inserter.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/templates/class-wp-template-inserter.php new file mode 100644 index 0000000000000..69407b112573b --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/templates/class-wp-template-inserter.php @@ -0,0 +1,573 @@ +theme_slug = $theme_slug; + $this->header_content = ''; + $this->footer_content = ''; + $this->loading_strategy = $loading_strategy; + + /* + * Previously the option suffix was '-fse-template-data'. Bumping this to '-fse-template-data-v1' + * to differentiate it from the old data that was not provided by the API. Note that we don't want + * to tie this to plugin version constant, because that would trigger the insertion on each plugin + * update, even when it's not necessary (it would duplicate existing data). + */ + $this->fse_template_data_option = $this->theme_slug . '-fse-template-data-v1'; + } + + /** + * Retrieves template parts content. + */ + public function fetch_template_parts() { + // Use default data if we don't want to fetch from the API. + if ( 'use-local' === $this->loading_strategy ) { + $this->header_content = $this->get_default_header(); + $this->footer_content = $this->get_default_footer(); + return; + } + + $request_url = 'https://public-api.wordpress.com/wpcom/v2/full-site-editing/templates'; + + $request_args = array( + 'body' => array( 'theme_slug' => $this->theme_slug ), + ); + + $response = $this->fetch_retry( $request_url, $request_args ); + + if ( ! $response ) { + do_action( + 'a8c_fse_log', + 'template_population_failure', + array( + 'context' => 'WP_Template_Inserter->fetch_template_parts', + 'error' => 'Fetch retry timeout', + 'theme_slug' => $this->theme_slug, + ) + ); + $this->header_content = $this->get_default_header(); + $this->footer_content = $this->get_default_footer(); + return; + } + + $api_response = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( ! empty( $api_response['code'] ) && 'not_found' === $api_response['code'] ) { + do_action( + 'a8c_fse_log', + 'template_population_failure', + array( + 'context' => 'WP_Template_Inserter->fetch_template_parts', + 'error' => 'Did not find remote template data for the given theme.', + 'theme_slug' => $this->theme_slug, + ) + ); + return; + } + + // Default to first returned header for now. Support for multiple headers will be added in future iterations. + if ( ! empty( $api_response['headers'] ) ) { + $this->header_content = $api_response['headers'][0]; + } + + // Default to first returned footer for now. Support for multiple footers will be added in future iterations. + if ( ! empty( $api_response['footers'] ) ) { + $this->footer_content = $api_response['footers'][0]; + } + + // This should contain all image URLs for images in any header or footer. + if ( ! empty( $api_response['image_urls'] ) ) { + $this->image_urls = $api_response['image_urls']; + } + } + + /** + * Retries a call to wp_remote_get on error. + * + * @param string $request_url Url of the api call to make. + * @param array $request_args Additional arguments for the api call. + * @param int $attempt The number of the attempt being made. + * @return array|null wp_remote_get response array + */ + private function fetch_retry( $request_url, $request_args = null, $attempt = 1 ) { + $max_retries = 3; + + $response = wp_remote_get( $request_url, $request_args ); + + if ( ! is_wp_error( $response ) ) { + return $response; + } + + if ( $attempt > $max_retries ) { + return null; + } + + sleep( pow( 2, $attempt ) ); + ++$attempt; + return $this->fetch_retry( $request_url, $request_args, $attempt ); + } + + /** + * Returns a default header if call to template api fails for some reason. + * + * @return string Content of a default header + */ + public function get_default_header() { + return ' + + '; + } + + /** + * Returns a default footer if call to template api fails for some reason. + * + * @return string Content of a default footer + */ + public function get_default_footer() { + return ''; + } + + /** + * Determines whether FSE data has already been inserted. + * + * @return bool True if FSE data has already been inserted, false otherwise. + */ + public function is_template_data_inserted() { + return get_option( $this->fse_template_data_option ) ? true : false; + } + + /** + * This function will be called on plugin activation hook. + */ + public function insert_default_template_data() { + do_action( + 'a8c_fse_log', + 'before_template_population', + array( + 'context' => 'WP_Template_Inserter->insert_default_template_data', + 'theme_slug' => $this->theme_slug, + ) + ); + + if ( $this->is_template_data_inserted() ) { + /* + * Bail here to prevent inserting the FSE data twice for any given theme. + * Multiple themes will still be able to insert different templates. + */ + do_action( + 'a8c_fse_log', + 'template_population_failure', + array( + 'context' => 'WP_Template_Inserter->insert_default_template_data', + 'error' => 'Data already exist', + 'theme_slug' => $this->theme_slug, + ) + ); + return; + } + + // Set header and footer content based on data fetched from the WP.com API. + $this->fetch_template_parts(); + + // Avoid creating template parts if data hasn't been fetched properly. + if ( empty( $this->header_content ) || empty( $this->footer_content ) ) { + return; + } + + $this->register_template_post_types(); + + $header_id = wp_insert_post( + array( + 'post_title' => 'Header', + 'post_content' => $this->header_content, + 'post_status' => 'publish', + 'post_type' => 'wp_template_part', + 'comment_status' => 'closed', + 'ping_status' => 'closed', + ) + ); + + if ( ! term_exists( "$this->theme_slug-header", 'wp_template_part_type' ) ) { + wp_insert_term( "$this->theme_slug-header", 'wp_template_part_type' ); + } + + wp_set_object_terms( $header_id, "$this->theme_slug-header", 'wp_template_part_type' ); + + $footer_id = wp_insert_post( + array( + 'post_title' => 'Footer', + 'post_content' => $this->footer_content, + 'post_status' => 'publish', + 'post_type' => 'wp_template_part', + 'comment_status' => 'closed', + 'ping_status' => 'closed', + ) + ); + + if ( ! term_exists( "$this->theme_slug-footer", 'wp_template_part_type' ) ) { + wp_insert_term( "$this->theme_slug-footer", 'wp_template_part_type' ); + } + + wp_set_object_terms( $footer_id, "$this->theme_slug-footer", 'wp_template_part_type' ); + + add_option( $this->fse_template_data_option, true ); + + // Note: we set the option before doing the image upload because the template + // parts can work with the remote URLs even if this fails. + $image_urls = $this->image_urls; + if ( ! empty( $image_urls ) ) { + // Uploading images locally does not work in the WordPress.com environment, + // so we use an action to handle it with Headstart there. + if ( has_action( 'a8c_fse_upload_template_part_images' ) ) { + do_action( 'a8c_fse_upload_template_part_images', $image_urls, array( $header_id, $footer_id ) ); + } + } + + do_action( + 'a8c_fse_log', + 'template_population_success', + array( + 'context' => 'WP_Template_Inserter->insert_default_template_data', + 'theme_slug' => $this->theme_slug, + ) + ); + } + + /** + * Determines whether default pages have already been created. + * + * @return bool True if default pages have already been created, false otherwise. + */ + public function is_pages_data_inserted() { + return get_option( $this->fse_page_data_option ) ? true : false; + } + + /** + * Retrieves a page given its title. + * + * If more than one post uses the same title, the post with the smallest ID will be returned. + * Be careful: in case of more than one post having the same title, it will check the oldest + * publication date, not the smallest ID. + * + * Because this function uses the MySQL '=' comparison, $page_title will usually be matched + * as case-insensitive with default collation. + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $page_title Page title. + * @param string $output Optional. The required return type. One of OBJECT, ARRAY_A, or ARRAY_N, which + * correspond to a WP_Post object, an associative array, or a numeric array, + * respectively. Default OBJECT. + * @param string|array $post_type Optional. Post type or array of post types. Default 'page'. + * @return \WP_Post|array|null WP_Post (or array) on success, or null on failure. + */ + public function get_page_by_title( $page_title, $output = OBJECT, $post_type = 'page' ) { + global $wpdb; + + if ( is_array( $post_type ) ) { + $post_type = esc_sql( $post_type ); + $post_type_in_string = "'" . implode( "','", $post_type ) . "'"; + $sql = $wpdb->prepare( + "SELECT ID + FROM $wpdb->posts + WHERE post_title = %s + AND post_type IN ($post_type_in_string)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $page_title + ); + } else { + $sql = $wpdb->prepare( + "SELECT ID + FROM $wpdb->posts + WHERE post_title = %s + AND post_type = %s", + $page_title, + $post_type + ); + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared + $page = $wpdb->get_var( $sql ); + + if ( $page ) { + return get_post( (int) $page, $output ); + } + + return null; + } + + /** + * Inserts default About and Contact pages based on Starter Page Templates content. + * + * The insertion will not happen if this data has been already inserted or if pages + * with 'About' and 'Contact' titles already exist. + */ + public function insert_default_pages() { + do_action( + 'a8c_fse_log', + 'before_pages_population', + array( + 'context' => 'WP_Template_Inserter->insert_default_pages', + 'theme_slug' => $this->theme_slug, + ) + ); + + // Bail if this data has already been inserted. + if ( $this->is_pages_data_inserted() ) { + do_action( + 'a8c_fse_log', + 'pages_population_failure', + array( + 'context' => 'WP_Template_Inserter->insert_default_pages', + 'error' => 'Data already exist', + 'theme_slug' => $this->theme_slug, + ) + ); + return; + } + + $request_url = add_query_arg( + array( + '_locale' => $this->get_template_locale(), + ), + 'https://public-api.wordpress.com/wpcom/v2/verticals/m1/templates' + ); + + $response = $this->fetch_retry( $request_url ); + + if ( ! $response ) { + do_action( + 'a8c_fse_log', + 'pages_population_failure', + array( + 'context' => 'WP_Template_Inserter->insert_default_pages', + 'error' => 'Fetch retry timeout', + 'theme_slug' => $this->theme_slug, + ) + ); + return; + } + + $api_response = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( empty( $api_response ) ) { + return; + } + + // Convert templates response to [ slug => content ] pairs to extract required content more easily. + $template_content_by_slug = wp_list_pluck( $api_response['templates'], 'content', 'slug' ); + + if ( empty( $this->get_page_by_title( 'About' ) ) && ! empty( $template_content_by_slug['about'] ) ) { + wp_insert_post( + array( + 'post_title' => _x( 'About', 'Default page title', 'jetpack-mu-wpcom' ), + 'post_content' => $template_content_by_slug['about'], + 'post_status' => 'publish', + 'post_type' => 'page', + 'menu_order' => 1, + ) + ); + } + + if ( empty( $this->get_page_by_title( 'Contact' ) ) && ! empty( $template_content_by_slug['contact'] ) ) { + wp_insert_post( + array( + 'post_title' => _x( 'Contact', 'Default page title', 'jetpack-mu-wpcom' ), + 'post_content' => $template_content_by_slug['contact'], + 'post_status' => 'publish', + 'post_type' => 'page', + 'menu_order' => 1, + ) + ); + } + + update_option( $this->fse_page_data_option, true ); + + do_action( + 'a8c_fse_log', + 'pages_population_success', + array( + 'context' => 'WP_Template_Inserter->insert_default_pages', + 'theme_slug' => $this->theme_slug, + ) + ); + } + + /** + * Returns the locale to be used for page templates + */ + private function get_template_locale() { + $language = get_locale(); + return Common\get_iso_639_locale( $language ); + } + + /** + * Register post types. + */ + public function register_template_post_types() { + register_post_type( + 'wp_template_part', // phpcs:ignore WordPress.NamingConventions.ValidPostTypeSlug.Reserved + array( + 'labels' => array( + 'name' => _x( 'Template Parts', 'post type general name', 'jetpack-mu-wpcom' ), + 'singular_name' => _x( 'Template Part', 'post type singular name', 'jetpack-mu-wpcom' ), + 'menu_name' => _x( 'Template Parts', 'admin menu', 'jetpack-mu-wpcom' ), + 'name_admin_bar' => _x( 'Template Part', 'add new on admin bar', 'jetpack-mu-wpcom' ), + 'add_new' => _x( 'Add New', 'Template', 'jetpack-mu-wpcom' ), + 'add_new_item' => __( 'Add New Template Part', 'jetpack-mu-wpcom' ), + 'new_item' => __( 'New Template Part', 'jetpack-mu-wpcom' ), + 'edit_item' => __( 'Edit Template Part', 'jetpack-mu-wpcom' ), + 'view_item' => __( 'View Template Part', 'jetpack-mu-wpcom' ), + 'all_items' => __( 'All Template Parts', 'jetpack-mu-wpcom' ), + 'search_items' => __( 'Search Template Parts', 'jetpack-mu-wpcom' ), + 'not_found' => __( 'No template parts found.', 'jetpack-mu-wpcom' ), + 'not_found_in_trash' => __( 'No template parts found in Trash.', 'jetpack-mu-wpcom' ), + 'filter_items_list' => __( 'Filter template parts list', 'jetpack-mu-wpcom' ), + 'items_list_navigation' => __( 'Template parts list navigation', 'jetpack-mu-wpcom' ), + 'items_list' => __( 'Template parts list', 'jetpack-mu-wpcom' ), + 'item_published' => __( 'Template part published.', 'jetpack-mu-wpcom' ), + 'item_published_privately' => __( 'Template part published privately.', 'jetpack-mu-wpcom' ), + 'item_reverted_to_draft' => __( 'Template part reverted to draft.', 'jetpack-mu-wpcom' ), + 'item_scheduled' => __( 'Template part scheduled.', 'jetpack-mu-wpcom' ), + 'item_updated' => __( 'Template part updated.', 'jetpack-mu-wpcom' ), + ), + 'menu_icon' => 'dashicons-layout', + 'public' => false, + 'show_ui' => true, // Otherwise we'd get permission error when trying to edit them. + 'show_in_menu' => false, + 'rewrite' => false, + 'capability_type' => 'template_part', + 'capabilities' => array( + // You need to be able to edit posts, in order to read templates in their raw form. + 'read' => 'edit_posts', + // You need to be able to customize, in order to create templates. + 'create_posts' => 'edit_theme_options', + 'edit_posts' => 'edit_theme_options', + 'delete_posts' => 'edit_theme_options', + 'edit_published_posts' => 'edit_theme_options', + 'delete_published_posts' => 'edit_theme_options', + 'edit_others_posts' => 'edit_theme_options', + 'delete_others_posts' => 'edit_theme_options', + 'publish_posts' => 'edit_theme_options', + ), + 'map_meta_cap' => true, + 'supports' => array( + 'title', + 'editor', + 'revisions', + ), + ) + ); + + register_taxonomy( + 'wp_template_part_type', + 'wp_template_part', + array( + 'labels' => array( + 'name' => _x( 'Template Part Types', 'taxonomy general name', 'jetpack-mu-wpcom' ), + 'singular_name' => _x( 'Template Part Type', 'taxonomy singular name', 'jetpack-mu-wpcom' ), + 'menu_name' => _x( 'Template Part Types', 'admin menu', 'jetpack-mu-wpcom' ), + 'all_items' => __( 'All Template Part Types', 'jetpack-mu-wpcom' ), + 'edit_item' => __( 'Edit Template Part Type', 'jetpack-mu-wpcom' ), + 'view_item' => __( 'View Template Part Type', 'jetpack-mu-wpcom' ), + 'update_item' => __( 'Update Template Part Type', 'jetpack-mu-wpcom' ), + 'add_new_item' => __( 'Add New Template Part Type', 'jetpack-mu-wpcom' ), + 'new_item_name' => __( 'New Template Part Type', 'jetpack-mu-wpcom' ), + 'parent_item' => __( 'Parent Template Part Type', 'jetpack-mu-wpcom' ), + 'parent_item_colon' => __( 'Parent Template Part Type:', 'jetpack-mu-wpcom' ), + 'search_items' => __( 'Search Template Part Types', 'jetpack-mu-wpcom' ), + 'not_found' => __( 'No template part types found.', 'jetpack-mu-wpcom' ), + 'back_to_items' => __( 'Back to template part types', 'jetpack-mu-wpcom' ), + ), + 'public' => false, + 'publicly_queryable' => false, + 'show_ui' => false, + 'show_in_menu' => false, + 'show_in_nav_menu' => false, + 'show_in_rest' => true, + 'rest_base' => 'template_part_types', + 'show_tagcloud' => false, + 'hierarchical' => true, + 'rewrite' => false, + 'capabilities' => array( + 'manage_terms' => 'edit_theme_options', + 'edit_terms' => 'edit_theme_options', + 'delete_terms' => 'edit_theme_options', + 'assign_terms' => 'edit_theme_options', + ), + ) + ); + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/templates/class-wp-template.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/templates/class-wp-template.php new file mode 100644 index 0000000000000..3daa5751b9223 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/templates/class-wp-template.php @@ -0,0 +1,204 @@ +current_theme_name = normalize_theme_slug( $theme ); + } + + /** + * Checks whether the provided template type is supported in FSE. + * + * @param string $template_type String representing the template type. + * + * @return bool True if provided template type is supported in FSE, false otherwise. + */ + public function is_supported_template_type( $template_type ) { + return in_array( $template_type, $this->supported_template_types, true ); + } + + /** + * Returns the post ID of the default template CPT for a given template type. + * + * @param string $template_type String representing the template type. + * + * @return null|int Template ID if it exists or null otherwise. + */ + public function get_template_id( $template_type ) { + if ( ! $this->is_supported_template_type( $template_type ) ) { + return null; + } + + $term = get_term_by( 'name', "$this->current_theme_name-$template_type", 'wp_template_part_type', ARRAY_A ); + + // Bail if current site doesn't have this term registered. + if ( ! isset( $term['term_id'] ) ) { + return null; + } + + $template_ids = get_objects_in_term( $term['term_id'], $term['taxonomy'], array( 'order' => 'DESC' ) ); + + // Bail if we haven't found any post instances for this template type. + if ( empty( $template_ids ) ) { + return null; + } + + /* + * Assuming that we'll have just one default template for now. + * We'll add support for multiple header and footer variations in future iterations. + */ + return (int) $template_ids[0]; + } + + /** + * Returns template content for given template type. + * + * @param string $template_type String representing the template type. + * + * @return null|string Template content if it exists or null otherwise. + */ + public function get_template_content( $template_type ) { + if ( ! $this->is_supported_template_type( $template_type ) ) { + return null; + } + + $template_id = $this->get_template_id( $template_type ); + + if ( null === $template_id ) { + return null; + } + + $template_post = get_post( $template_id ); + + if ( null === $template_post ) { + return; + } + + return $template_post->post_content; + } + + /** + * Returns full page template content. + * + * We only support one page template for now with header at the top and footer at the bottom. + * + * @return null|string + */ + public function get_page_template_content() { + $header_id = $this->get_template_id( self::HEADER ); + $footer_id = $this->get_template_id( self::FOOTER ); + + /* + * Bail if we are missing header or footer. Otherwise this would cause us to + * always return some page template content and show template parts (with empty IDs), + * even for themes that don't support FSE. + */ + if ( ! $header_id || ! $footer_id ) { + return null; + } + + return "' . + '' . + "'; + } + + /** + * Returns array of blocks that represent the template. + * + * @return array + */ + public function get_template_blocks() { + $template_content = $this->get_page_template_content(); + $template_blocks = parse_blocks( $template_content ); + return is_array( $template_blocks ) ? $template_blocks : array(); + } + + /** + * Output FSE template markup. + * + * @param string $template_type String representing the template type. + * + * @return null|void Null if unsupported template type is passed, outputs content otherwise. + */ + public function output_template_content( $template_type ) { + if ( ! $this->is_supported_template_type( $template_type ) ) { + return null; + } + + // Things that follow are from wp-includes/default-filters.php + // not everything is appropriate for template content as opposed to post content. + global $wp_embed; + $content = $this->get_template_content( $template_type ); + + // 8 priority + $content = $wp_embed->run_shortcode( $content ); + $content = $wp_embed->autoembed( $content ); + + // 9 priority + $content = do_blocks( $content ); + + // 10 priority + $content = wptexturize( $content ); + + // 11 priority + $content = do_shortcode( $content ); + + $content = prepend_attachment( $content ); + + if ( has_filter( 'a8c_fse_make_content_images_responsive' ) ) { + $content = apply_filters( 'a8c_fse_make_content_images_responsive', $content ); + } else { + $content = wp_filter_content_tags( $content ); + } + + // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped + echo $content; + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/wpcom-legacy-fse.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/wpcom-legacy-fse.php new file mode 100644 index 0000000000000..34b906393e953 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/wpcom-legacy-fse.php @@ -0,0 +1,137 @@ +insert_default_template_data(); + $template_inserter->insert_default_pages(); +} +register_activation_hook( __FILE__, __NAMESPACE__ . '\populate_wp_template_data' ); +add_action( 'switch_theme', __NAMESPACE__ . '\populate_wp_template_data' ); + +/** + * Register wpcom fse template post types. + */ +function wpcom_fse_register_template_post_types() { + $theme_slug = normalize_theme_slug( get_stylesheet() ); + $wp_template_inserter = new WP_Template_Inserter( $theme_slug ); + $wp_template_inserter->register_template_post_types(); +} + +/** + * Register wpcom fse blocks. + */ +function wpcom_fse_register_blocks() { + register_block_type( + 'a8c/navigation-menu', + array( + 'attributes' => array( + 'className' => array( + 'type' => 'string', + 'default' => '', + ), + 'align' => array( + 'type' => 'string', + 'default' => 'wide', + ), + 'textAlign' => array( + 'type' => 'string', + 'default' => 'center', + ), + 'textColor' => array( + 'type' => 'string', + ), + 'customTextColor' => array( + 'type' => 'string', + ), + 'backgroundColor' => array( + 'type' => 'string', + ), + 'customBackgroundColor' => array( + 'type' => 'string', + ), + 'fontSize' => array( + 'type' => 'string', + 'default' => 'normal', + ), + 'customFontSize' => array( + 'type' => 'number', + ), + ), + 'render_callback' => __NAMESPACE__ . '\render_navigation_menu_block', + ) + ); + + register_block_type( + 'a8c/post-content', + array( + 'render_callback' => __NAMESPACE__ . '\render_post_content_block', + ) + ); + + register_block_type( + 'a8c/site-description', + array( + 'render_callback' => __NAMESPACE__ . '\render_site_description_block', + ) + ); + + register_block_type( + 'a8c/template', + array( + 'render_callback' => __NAMESPACE__ . '\render_template_block', + ) + ); + + register_block_type( + 'a8c/site-title', + array( + 'render_callback' => __NAMESPACE__ . '\render_site_title_block', + ) + ); +} + +/** + * Load wpcom FSE. + */ +function load_wpcom_fse() { + // Bail if FSE should not be active on the site. We do not + // want to load FSE functionality on non-supported sites! + if ( ! is_full_site_editing_active() ) { + return; + } + + add_action( 'init', __NAMESPACE__ . '\wpcom_fse_register_blocks', 100 ); + add_action( 'init', __NAMESPACE__ . '\wpcom_fse_register_template_post_types' ); +} +add_action( 'plugins_loaded', __NAMESPACE__ . '\load_wpcom_fse' ); diff --git a/projects/packages/jetpack-mu-wpcom/src/index.d.ts b/projects/packages/jetpack-mu-wpcom/src/index.d.ts new file mode 100644 index 0000000000000..6bbda9390ed31 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/index.d.ts @@ -0,0 +1,8 @@ +declare module '*.svg' { + const url: string; +} + +interface Window { + _currentSiteId: number; + _currentSiteType: string; +} diff --git a/projects/packages/jetpack-mu-wpcom/src/utils.php b/projects/packages/jetpack-mu-wpcom/src/utils.php index ca94419d770ec..5a55dcf9d8d81 100644 --- a/projects/packages/jetpack-mu-wpcom/src/utils.php +++ b/projects/packages/jetpack-mu-wpcom/src/utils.php @@ -97,7 +97,7 @@ function jetpack_mu_wpcom_enqueue_assets( $asset_name, $asset_types = array() ) if ( in_array( 'js', $asset_types, true ) ) { $js_file = "build/$asset_name/$asset_name.js"; wp_enqueue_script( - "jetpack-mu-wpcom-$asset_name", + $asset_handle, plugins_url( $js_file, Jetpack_Mu_Wpcom::BASE_FILE ), $asset_file['dependencies'] ?? array(), $asset_file['version'] ?? filemtime( Jetpack_Mu_Wpcom::BASE_DIR . $js_file ), @@ -109,7 +109,7 @@ function jetpack_mu_wpcom_enqueue_assets( $asset_name, $asset_types = array() ) $css_ext = is_rtl() ? 'rtl.css' : 'css'; $css_file = "build/$asset_name/$asset_name.$css_ext"; wp_enqueue_style( - "jetpack-mu-wpcom-$asset_name", + $asset_handle, plugins_url( $css_file, Jetpack_Mu_Wpcom::BASE_FILE ), array(), filemtime( Jetpack_Mu_Wpcom::BASE_DIR . $css_file ) diff --git a/projects/packages/jetpack-mu-wpcom/webpack.config.js b/projects/packages/jetpack-mu-wpcom/webpack.config.js index ea855ad9da300..cfdb59d970f19 100644 --- a/projects/packages/jetpack-mu-wpcom/webpack.config.js +++ b/projects/packages/jetpack-mu-wpcom/webpack.config.js @@ -37,6 +37,7 @@ module.exports = [ 'wpcom-blocks-timeline-editor': './src/features/wpcom-blocks/timeline/editor.js', 'wpcom-blocks-timeline-view': './src/features/wpcom-blocks/timeline/view.js', 'wpcom-block-description-links': './src/features/wpcom-block-description-links/index.tsx', + 'wpcom-block-editor-nux': './src/features/wpcom-block-editor-nux/index.js', 'wpcom-global-styles-editor': './src/features/wpcom-global-styles/index.js', 'wpcom-global-styles-frontend': './src/features/wpcom-global-styles/wpcom-global-styles-view.js', diff --git a/projects/packages/my-jetpack/CHANGELOG.md b/projects/packages/my-jetpack/CHANGELOG.md index e8e2ccf731929..711c57072b06e 100644 --- a/projects/packages/my-jetpack/CHANGELOG.md +++ b/projects/packages/my-jetpack/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.31.0] - 2024-08-01 +### Added +- Update Welcome Banner and set async site-only connection [#38534] + +### Changed +- Fixup versions [#38612] +- My Jetpack: modify Jetpack AI product class and interstitial links [#38602] +- React: Changing global JSX namespace to React.JSX [#38585] + ## [4.30.0] - 2024-07-29 ### Added - Async card update after async site connection [#38549] @@ -1598,6 +1607,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Created package +[4.31.0]: https://github.com/Automattic/jetpack-my-jetpack/compare/4.30.0...4.31.0 [4.30.0]: https://github.com/Automattic/jetpack-my-jetpack/compare/4.29.0...4.30.0 [4.29.0]: https://github.com/Automattic/jetpack-my-jetpack/compare/4.28.0...4.29.0 [4.28.0]: https://github.com/Automattic/jetpack-my-jetpack/compare/4.27.2...4.28.0 diff --git a/projects/packages/my-jetpack/_inc/components/product-interstitial/jetpack-ai/product-page.jsx b/projects/packages/my-jetpack/_inc/components/product-interstitial/jetpack-ai/product-page.jsx index 7ad62ee7965b9..a1c9ecb3c5d38 100644 --- a/projects/packages/my-jetpack/_inc/components/product-interstitial/jetpack-ai/product-page.jsx +++ b/projects/packages/my-jetpack/_inc/components/product-interstitial/jetpack-ai/product-page.jsx @@ -84,8 +84,7 @@ export default function () { 'jetpack-ai-product-page-content-feedback-link' ); - // TODO: switch this to a proper link when the page is ready - const jetpackAiLink = getRedirectUrl( 'org-ai' ); + const videoLinkBreve = getRedirectUrl( 'jetpack-ai-product-page-breve' ); // isRegistered works as a flag to know if the page can link to a post creation or not const ctaURL = isRegistered @@ -310,7 +309,7 @@ export default function () { className={ styles[ 'product-interstitial__usage-videos-link' ] } icon={ help } target="_blank" - href={ jetpackAiLink } + href={ videoLinkBreve } > { __( 'Learn more', 'jetpack-my-jetpack' ) } diff --git a/projects/packages/my-jetpack/changelog/change-jetpack-ai-product-page-breve-link b/projects/packages/my-jetpack/changelog/change-jetpack-ai-product-page-breve-link new file mode 100644 index 0000000000000..b824f081d8775 --- /dev/null +++ b/projects/packages/my-jetpack/changelog/change-jetpack-ai-product-page-breve-link @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +change Jetpack AI product page link redirect diff --git a/projects/packages/my-jetpack/changelog/change-my-jetpack-ai-card b/projects/packages/my-jetpack/changelog/change-my-jetpack-ai-card deleted file mode 100644 index b21ad58866eeb..0000000000000 --- a/projects/packages/my-jetpack/changelog/change-my-jetpack-ai-card +++ /dev/null @@ -1,4 +0,0 @@ -Significance: minor -Type: changed - -My Jetpack: modify Jetpack AI product class and interstitial links diff --git a/projects/packages/my-jetpack/changelog/fix-eslint-no-empty-catch-blocks b/projects/packages/my-jetpack/changelog/fix-eslint-no-empty-catch-blocks deleted file mode 100644 index fbb57fc4b2c1b..0000000000000 --- a/projects/packages/my-jetpack/changelog/fix-eslint-no-empty-catch-blocks +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: changed -Comment: Added version bumps. - - diff --git a/projects/packages/my-jetpack/changelog/fix-mastodon-fediverse-creator-og-fatals b/projects/packages/my-jetpack/changelog/fix-mastodon-fediverse-creator-og-fatals deleted file mode 100644 index f379a9a899dab..0000000000000 --- a/projects/packages/my-jetpack/changelog/fix-mastodon-fediverse-creator-og-fatals +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: changed - -Fixup versions diff --git a/projects/packages/my-jetpack/changelog/update-jsx-namespace-usage b/projects/packages/my-jetpack/changelog/update-jsx-namespace-usage deleted file mode 100644 index b8ccf72e8ff1b..0000000000000 --- a/projects/packages/my-jetpack/changelog/update-jsx-namespace-usage +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: changed - -React: Changing global JSX namespace to React.JSX diff --git a/projects/packages/my-jetpack/changelog/update-welcome-banner-connect b/projects/packages/my-jetpack/changelog/update-welcome-banner-connect deleted file mode 100644 index 2f5c0598037fb..0000000000000 --- a/projects/packages/my-jetpack/changelog/update-welcome-banner-connect +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: added - -Update Welcome Banner and set async site-only connection diff --git a/projects/packages/my-jetpack/changelog/update-welcome-flow-copy b/projects/packages/my-jetpack/changelog/update-welcome-flow-copy deleted file mode 100644 index 31b24e2127661..0000000000000 --- a/projects/packages/my-jetpack/changelog/update-welcome-flow-copy +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: changed -Comment: copy changes - - diff --git a/projects/packages/my-jetpack/package.json b/projects/packages/my-jetpack/package.json index 93d66ab9e69c5..624f7d3bbd22b 100644 --- a/projects/packages/my-jetpack/package.json +++ b/projects/packages/my-jetpack/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@automattic/jetpack-my-jetpack", - "version": "4.31.0-alpha", + "version": "4.31.1-alpha", "description": "WP Admin page with information and configuration shared among all Jetpack stand-alone plugins", "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/packages/my-jetpack/#readme", "bugs": { diff --git a/projects/packages/my-jetpack/src/class-initializer.php b/projects/packages/my-jetpack/src/class-initializer.php index e48e23f32e1be..b3a904e60badc 100644 --- a/projects/packages/my-jetpack/src/class-initializer.php +++ b/projects/packages/my-jetpack/src/class-initializer.php @@ -41,7 +41,7 @@ class Initializer { * * @var string */ - const PACKAGE_VERSION = '4.31.0-alpha'; + const PACKAGE_VERSION = '4.31.1-alpha'; /** * HTML container ID for the IDC screen on My Jetpack page. diff --git a/projects/packages/publicize/CHANGELOG.md b/projects/packages/publicize/CHANGELOG.md index f2c83afe441f4..0dbe2889c4922 100644 --- a/projects/packages/publicize/CHANGELOG.md +++ b/projects/packages/publicize/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.47.4] - 2024-08-01 +### Removed +- Removed Fediverse og filters to fix fatals [#38612] + +### Fixed +- Fixed Threads connections not having a profile_url [#38611] + ## [0.47.3] - 2024-07-15 ### Added - Mastodon: display a Fediverse Creator tag when the post author has connected their account to a Mastodon account. [#38198] @@ -625,6 +632,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated package dependencies. - Update package.json metadata. +[0.47.4]: https://github.com/Automattic/jetpack-publicize/compare/v0.47.3...v0.47.4 [0.47.3]: https://github.com/Automattic/jetpack-publicize/compare/v0.47.2...v0.47.3 [0.47.2]: https://github.com/Automattic/jetpack-publicize/compare/v0.47.1...v0.47.2 [0.47.1]: https://github.com/Automattic/jetpack-publicize/compare/v0.47.0...v0.47.1 diff --git a/projects/packages/publicize/changelog/add-social-feature-flag-management-fixed b/projects/packages/publicize/changelog/add-social-feature-flag-management-fixed new file mode 100644 index 0000000000000..43e3086f87315 --- /dev/null +++ b/projects/packages/publicize/changelog/add-social-feature-flag-management-fixed @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Added feature flag management for social diff --git a/projects/packages/publicize/changelog/fix-mastodon-fediverse-creator-og-fatals b/projects/packages/publicize/changelog/fix-mastodon-fediverse-creator-og-fatals deleted file mode 100644 index b96a56198a3c4..0000000000000 --- a/projects/packages/publicize/changelog/fix-mastodon-fediverse-creator-og-fatals +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: removed - -Removed Fediverse og filters to fix fatals diff --git a/projects/packages/publicize/changelog/fix-social-threads-profile-url b/projects/packages/publicize/changelog/fix-social-threads-profile-url deleted file mode 100644 index ef064f1ae1f19..0000000000000 --- a/projects/packages/publicize/changelog/fix-social-threads-profile-url +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fixed - -Fixed Threads connections not having a profile_url diff --git a/projects/packages/publicize/src/class-publicize-base.php b/projects/packages/publicize/src/class-publicize-base.php index e4195b728af71..278ffe10b9655 100644 --- a/projects/packages/publicize/src/class-publicize-base.php +++ b/projects/packages/publicize/src/class-publicize-base.php @@ -289,6 +289,26 @@ public function use_admin_ui_v1(): bool { || $this->has_connections_management_feature(); } + /** + * Whether the site has the feature flag enabled. + * + * @param string $flag_name The feature flag to check. Will be prefixed with 'jetpack_social_has_' for the option. + * @param string $feature_name The feature name to check for for the Current_Plan check, without the social- prefix. + * @return bool + */ + public function has_feature_flag( $flag_name, $feature_name ): bool { + // If the option is set, use it. + if ( get_option( 'jetpack_social_has_' . $flag_name, false ) ) { + return true; + } + // If the constant is set, use it. + if ( defined( 'JETPACK_SOCIAL_HAS_' . strtoupper( $flag_name ) ) && constant( 'JETPACK_SOCIAL_HAS_' . strtoupper( $flag_name ) ) ) { + return true; + } + + return Current_Plan::supports( 'social-' . $feature_name ); + } + /** * Does the given user have a connection to the service on the given blog? * diff --git a/projects/packages/publicize/src/jetpack-social-settings/class-settings.php b/projects/packages/publicize/src/jetpack-social-settings/class-settings.php index fb35561fa55b8..ba80820206a49 100644 --- a/projects/packages/publicize/src/jetpack-social-settings/class-settings.php +++ b/projects/packages/publicize/src/jetpack-social-settings/class-settings.php @@ -38,6 +38,22 @@ class Settings { 'enabled' => true, ); + /** + * Feature flags. Each item has 3 keys because of the naming conventions: + * - flag_name: The name of the feature flag for the option check. + * - feature_name: The name of the feature that enables the feature. Will be checked with Current_Plan. + * - variable_name: The name of the variable that will be used in the front-end. + * + * @var array + */ + const FEATURE_FLAGS = array( + array( + 'flag_name' => 'editor_preview', + 'feature_name' => 'editor-preview', + 'variable_name' => 'useEditorPreview', + ), + ); + /** * Migrate old options to the new settings. Previously SIG settings were stored in the * jetpack_social_image_generator_settings option. Now they are stored in the jetpack_social_settings @@ -183,6 +199,7 @@ public function get_initial_state() { $settings = $this->get_settings( true ); $settings['useAdminUiV1'] = false; + $settings['featureFlags'] = array(); $settings['is_publicize_enabled'] = false; $settings['hasPaidFeatures'] = false; @@ -199,6 +216,10 @@ public function get_initial_state() { $settings['is_publicize_enabled'] = true; $settings['hasPaidFeatures'] = $publicize->has_paid_features(); + + foreach ( self::FEATURE_FLAGS as $feature_flag ) { + $settings['featureFlags'][ $feature_flag['variable_name'] ] = $publicize->has_feature_flag( $feature_flag['flag_name'], $feature_flag['feature_name'] ); + } } else { $settings['connectionData'] = array( 'connections' => array(), diff --git a/projects/packages/search/changelog/update-react-19-compat-ReactDOM-render b/projects/packages/search/changelog/update-react-19-compat-ReactDOM-render new file mode 100644 index 0000000000000..174e4e7d14779 --- /dev/null +++ b/projects/packages/search/changelog/update-react-19-compat-ReactDOM-render @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +React compatibility: Changing ReactDOM.render usage to be via ReactDOM.createRoot. diff --git a/projects/packages/search/package.json b/projects/packages/search/package.json index f90cc0402ca41..8949647a9905b 100644 --- a/projects/packages/search/package.json +++ b/projects/packages/search/package.json @@ -1,6 +1,6 @@ { "name": "jetpack-search", - "version": "0.44.13", + "version": "0.44.14-alpha", "description": "Package for Jetpack Search products", "main": "main.js", "directories": { diff --git a/projects/packages/search/src/class-package.php b/projects/packages/search/src/class-package.php index d09f58f240f00..61b6ea20b934a 100644 --- a/projects/packages/search/src/class-package.php +++ b/projects/packages/search/src/class-package.php @@ -11,7 +11,7 @@ * Search package general information */ class Package { - const VERSION = '0.44.13'; + const VERSION = '0.44.14-alpha'; const SLUG = 'search'; /** diff --git a/projects/packages/search/src/customberg/index.jsx b/projects/packages/search/src/customberg/index.jsx index de8bfcfce6c03..7eb1b8814e20e 100644 --- a/projects/packages/search/src/customberg/index.jsx +++ b/projects/packages/search/src/customberg/index.jsx @@ -1,4 +1,4 @@ -import { render } from '@wordpress/element'; +import { createRoot } from '@wordpress/element'; import Layout from 'components/layout'; import 'styles.scss'; @@ -16,7 +16,8 @@ function collapseWpAdminSidebar() { */ function initialize( id ) { collapseWpAdminSidebar(); - render( , document.getElementById( id ) ); + const root = createRoot( document.getElementById( id ) ); + root.render( ); } global.jetpackSearchConfigureInit = initialize; diff --git a/projects/packages/waf/CHANGELOG.md b/projects/packages/waf/CHANGELOG.md index f40777cf0a88d..2755de0c5209f 100644 --- a/projects/packages/waf/CHANGELOG.md +++ b/projects/packages/waf/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.18.0] - 2024-08-01 +### Added +- Adds global statistics [#38388] + +### Fixed +- Fix global stats type check [#38634] + ## [0.17.0] - 2024-07-22 ### Added - Added the ability to toggle IP block and allow lists individually. [#38184] @@ -333,6 +340,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Core: do not ship .phpcs.dir.xml in production builds. +[0.18.0]: https://github.com/Automattic/jetpack-waf/compare/v0.17.0...v0.18.0 [0.17.0]: https://github.com/Automattic/jetpack-waf/compare/v0.16.10...v0.17.0 [0.16.10]: https://github.com/Automattic/jetpack-waf/compare/v0.16.9...v0.16.10 [0.16.9]: https://github.com/Automattic/jetpack-waf/compare/v0.16.8...v0.16.9 diff --git a/projects/packages/waf/changelog/add-protect-global-waf-stats b/projects/packages/waf/changelog/add-protect-global-waf-stats deleted file mode 100644 index 8016100ea4055..0000000000000 --- a/projects/packages/waf/changelog/add-protect-global-waf-stats +++ /dev/null @@ -1,4 +0,0 @@ -Significance: minor -Type: added - -Adds global statistics diff --git a/projects/packages/waf/changelog/add-sync-waf-options b/projects/packages/waf/changelog/add-sync-waf-options deleted file mode 100644 index 0facd8da053ce..0000000000000 --- a/projects/packages/waf/changelog/add-sync-waf-options +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: changed -Comment: Janitorial: improved type consistency in WAF settings API - - diff --git a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/memberships.php b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/memberships.php index 7a6be6a24f1ac..2bdc2d218fe40 100644 --- a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/memberships.php +++ b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/memberships.php @@ -58,6 +58,7 @@ public function register_routes() { 'gutenberg', 'gutenberg-wpcom', 'launchpad', + 'import-paid-subscribers', ), true ); diff --git a/projects/plugins/jetpack/changelog/add-jetpack-ai-breve-disable-controls-on-toggle b/projects/plugins/jetpack/changelog/add-jetpack-ai-breve-disable-controls-on-toggle new file mode 100644 index 0000000000000..cc803a34b8f3e --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-jetpack-ai-breve-disable-controls-on-toggle @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Jetpack AI Breve: disable feature toggles on main toggle diff --git a/projects/plugins/jetpack/changelog/add-paid-importer-connect-memebership-flow b/projects/plugins/jetpack/changelog/add-paid-importer-connect-memebership-flow new file mode 100644 index 0000000000000..0a1ad1cc3324b --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-paid-importer-connect-memebership-flow @@ -0,0 +1,4 @@ +Significance: minor +Type: other + +Newsletter: add source for the paid importer diff --git a/projects/plugins/jetpack/changelog/add-social-feature-flag-management-fixed b/projects/plugins/jetpack/changelog/add-social-feature-flag-management-fixed new file mode 100644 index 0000000000000..56db631e3bb63 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-social-feature-flag-management-fixed @@ -0,0 +1,4 @@ +Significance: minor +Type: other + +Added feature flag management for Social diff --git a/projects/plugins/jetpack/changelog/add-social-links-to-classic-theme-helper-package b/projects/plugins/jetpack/changelog/add-social-links-to-classic-theme-helper-package new file mode 100644 index 0000000000000..c7320ff0ac6ee --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-social-links-to-classic-theme-helper-package @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Social Links: Adding a function_exists check within the social-links.php file, to preventconflicts with package version. diff --git a/projects/plugins/jetpack/changelog/add-theme-errors-api b/projects/plugins/jetpack/changelog/add-theme-errors-api new file mode 100644 index 0000000000000..d6ad3dad2caa1 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-theme-errors-api @@ -0,0 +1,4 @@ +Significance: minor +Type: other + +WP.com API: Include errors listed for broken themes. diff --git a/projects/plugins/jetpack/changelog/fix-wpcom-icon-color b/projects/plugins/jetpack/changelog/fix-wpcom-icon-color new file mode 100644 index 0000000000000..608c4c91cb9e7 --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-wpcom-icon-color @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Admin bar: help center and notification icons now follow color scheme diff --git a/projects/plugins/jetpack/changelog/mu-wpcom-a8c-fse b/projects/plugins/jetpack/changelog/mu-wpcom-a8c-fse new file mode 100644 index 0000000000000..2f65984732a87 --- /dev/null +++ b/projects/plugins/jetpack/changelog/mu-wpcom-a8c-fse @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Jetpack: Port FSE feature from ETK diff --git a/projects/plugins/jetpack/changelog/update-ai-logo-generator-move-to-production b/projects/plugins/jetpack/changelog/update-ai-logo-generator-move-to-production new file mode 100644 index 0000000000000..eb20420fcf5bb --- /dev/null +++ b/projects/plugins/jetpack/changelog/update-ai-logo-generator-move-to-production @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Jetpack AI: enable the logo block AI logo generator extension in production. diff --git a/projects/plugins/jetpack/changelog/update-ai-logo-generator-release-to-simple-sites b/projects/plugins/jetpack/changelog/update-ai-logo-generator-release-to-simple-sites new file mode 100644 index 0000000000000..1886a3471c50c --- /dev/null +++ b/projects/plugins/jetpack/changelog/update-ai-logo-generator-release-to-simple-sites @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +AI Logo Generator: Release site logo extension to 10% of sites. diff --git a/projects/plugins/jetpack/changelog/update-jetpack-ai-breve-production b/projects/plugins/jetpack/changelog/update-jetpack-ai-breve-production new file mode 100644 index 0000000000000..3a40a2e473751 --- /dev/null +++ b/projects/plugins/jetpack/changelog/update-jetpack-ai-breve-production @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +AI Assistant: Release Breve to production diff --git a/projects/plugins/jetpack/changelog/update-react-19-compat-ReactDOM-render b/projects/plugins/jetpack/changelog/update-react-19-compat-ReactDOM-render new file mode 100644 index 0000000000000..50cf3a66c95be --- /dev/null +++ b/projects/plugins/jetpack/changelog/update-react-19-compat-ReactDOM-render @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +React Compatibility: Changing ReactDOM.render usage to be via ReactDOM.createRoot. diff --git a/projects/plugins/jetpack/changelog/update-react-19-compat-unmountComponentAtNode b/projects/plugins/jetpack/changelog/update-react-19-compat-unmountComponentAtNode new file mode 100644 index 0000000000000..70c3459ff55f2 --- /dev/null +++ b/projects/plugins/jetpack/changelog/update-react-19-compat-unmountComponentAtNode @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Podcast Player Block: Update component unmount method to be compatible with React 19. diff --git a/projects/plugins/jetpack/class.jetpack-gutenberg.php b/projects/plugins/jetpack/class.jetpack-gutenberg.php index 4dd6b9473878f..366d816356562 100644 --- a/projects/plugins/jetpack/class.jetpack-gutenberg.php +++ b/projects/plugins/jetpack/class.jetpack-gutenberg.php @@ -772,6 +772,8 @@ public static function enqueue_block_editor_assets() { } } + $initial_state['social']['featureFlags'] = $social_initial_state['featureFlags']; + wp_localize_script( 'jetpack-publicize', 'Jetpack_Editor_Initial_State', diff --git a/projects/plugins/jetpack/extensions/blocks/ai-assistant/ai-assistant.php b/projects/plugins/jetpack/extensions/blocks/ai-assistant/ai-assistant.php index 7d68a2eedc77b..6760166a5f66e 100644 --- a/projects/plugins/jetpack/extensions/blocks/ai-assistant/ai-assistant.php +++ b/projects/plugins/jetpack/extensions/blocks/ai-assistant/ai-assistant.php @@ -180,7 +180,7 @@ function () { add_action( 'jetpack_register_gutenberg_extensions', function () { - if ( apply_filters( 'jetpack_ai_enabled', true ) && apply_filters( 'breve_enabled', false ) ) { + if ( apply_filters( 'jetpack_ai_enabled', true ) && apply_filters( 'breve_enabled', true ) ) { \Jetpack_Gutenberg::set_extension_available( 'ai-proofread-breve' ); } } diff --git a/projects/plugins/jetpack/extensions/blocks/ai-chat/view.js b/projects/plugins/jetpack/extensions/blocks/ai-chat/view.js index 9bcc242461739..60451caf4532e 100644 --- a/projects/plugins/jetpack/extensions/blocks/ai-chat/view.js +++ b/projects/plugins/jetpack/extensions/blocks/ai-chat/view.js @@ -1,5 +1,5 @@ import domReady from '@wordpress/dom-ready'; -import { render } from '@wordpress/element'; +import { createRoot } from '@wordpress/element'; import { DEFAULT_ASK_BUTTON_LABEL, DEFAULT_PLACEHOLDER } from './constants'; import QuestionAnswer from './question-answer'; import './view.scss'; @@ -34,7 +34,8 @@ domReady( function () { const placeholder = container.getAttribute( 'data-placeholder' ); const blogId = container.getAttribute( 'data-blog-id' ); const blogType = container.getAttribute( 'data-blog-type' ); - render( + const root = createRoot( container ); + root.render( , - container + /> ); } ); diff --git a/projects/plugins/jetpack/extensions/blocks/podcast-player/view.js b/projects/plugins/jetpack/extensions/blocks/podcast-player/view.js index f66e28e186605..8ed494c37ee7b 100644 --- a/projects/plugins/jetpack/extensions/blocks/podcast-player/view.js +++ b/projects/plugins/jetpack/extensions/blocks/podcast-player/view.js @@ -1,4 +1,4 @@ -import { render, createElement, unmountComponentAtNode } from '@wordpress/element'; +import { createElement, createRoot } from '@wordpress/element'; import debugFactory from 'debug'; import '../../store/media-source'; import PodcastPlayer from './components/podcast-player'; @@ -32,6 +32,7 @@ const initializeBlock = function ( id ) { if ( ! block ) { return; } + const root = createRoot( block ); if ( block.getAttribute( 'data-jetpack-block-initialized' ) === 'true' ) { return; @@ -57,7 +58,7 @@ const initializeBlock = function ( id ) { const fallbackHTML = block.innerHTML; // Abort if not tracks found. - if ( ! data || ! data.tracks.length ) { + if ( ! data?.tracks?.length ) { debug( 'no tracks found' ); downgradeBlockToStatic( block ); return; @@ -69,14 +70,17 @@ const initializeBlock = function ( id ) { ...data, onError: function () { // Unmount React version and bring back the static HTML. - unmountComponentAtNode( block ); - block.innerHTML = fallbackHTML; - downgradeBlockToStatic( block ); + requestAnimationFrame( () => { + root.unmount(); + block.innerHTML = fallbackHTML; + downgradeBlockToStatic( block ); + } ); }, } ); // Render and save instance to the list of active ones. - playerInstances[ id ] = render( component, block ); + root.render( component ); + playerInstances[ id ] = root; } catch ( err ) { debug( 'unable to render', err ); downgradeBlockToStatic( block ); diff --git a/projects/plugins/jetpack/extensions/blocks/story/view.js b/projects/plugins/jetpack/extensions/blocks/story/view.js index a7e4f0d7bf395..54a623c4dcb59 100644 --- a/projects/plugins/jetpack/extensions/blocks/story/view.js +++ b/projects/plugins/jetpack/extensions/blocks/story/view.js @@ -1,5 +1,5 @@ import domReady from '@wordpress/dom-ready'; -import { render } from '@wordpress/element'; +import { createRoot } from '@wordpress/element'; import StoryPlayer from './player'; function renderPlayer( rootElement, settings ) { @@ -21,17 +21,20 @@ function renderPlayer( rootElement, settings ) { } const id = parseId( rootElement ); - - render( - , - rootElement - ); + const container = document.querySelector( `[data-id='${ id }']` ); + + if ( container ) { + const root = createRoot( container ); + root.render( + + ); + } } function parseSlides( slidesWrapper ) { diff --git a/projects/plugins/jetpack/extensions/extended-blocks/core-site-logo/index.tsx b/projects/plugins/jetpack/extensions/extended-blocks/core-site-logo/index.tsx index e3bb7b5f4e72b..8afc764262936 100644 --- a/projects/plugins/jetpack/extensions/extended-blocks/core-site-logo/index.tsx +++ b/projects/plugins/jetpack/extensions/extended-blocks/core-site-logo/index.tsx @@ -143,6 +143,15 @@ const siteLogoEditWithAiComponents = createHigherOrderComponent( BlockEdit => { }; }, 'SiteLogoEditWithAiComponents' ); +/** + * Function to check if the feature is available depending on the site ID. + * + * @returns {boolean} True if the feature is available. + */ +function isFeatureAvailable() { + return getFeatureAvailability( SITE_LOGO_BLOCK_AI_EXTENSION ); +} + /** * Function to check if the block can be extended. * @@ -162,7 +171,7 @@ function canExtendBlock( name: string ): boolean { } // Disable if the feature is not available. - if ( ! getFeatureAvailability( SITE_LOGO_BLOCK_AI_EXTENSION ) ) { + if ( ! isFeatureAvailable() ) { return false; } diff --git a/projects/plugins/jetpack/extensions/index.json b/projects/plugins/jetpack/extensions/index.json index 551d3ed6fd8b2..b4eb62b73c648 100644 --- a/projects/plugins/jetpack/extensions/index.json +++ b/projects/plugins/jetpack/extensions/index.json @@ -68,7 +68,9 @@ "ai-featured-image-generator", "ai-title-optimization", "ai-assistant-experimental-image-generation-support", - "ai-general-purpose-image-generator" + "ai-general-purpose-image-generator", + "ai-proofread-breve", + "ai-assistant-site-logo-support" ], "beta": [ "google-docs-embed", @@ -77,9 +79,7 @@ "v6-video-frame-poster", "videopress/video-chapters", "ai-assistant-backend-prompts", - "ai-assistant-extensions-support", - "ai-proofread-breve", - "ai-assistant-site-logo-support" + "ai-assistant-extensions-support" ], "experimental": [ "ai-image", "ai-paragraph" ], "no-post-editor": [ diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/breve.scss b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/breve.scss index dea15a04bde7b..a0434a796cf8c 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/breve.scss +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/breve.scss @@ -4,12 +4,16 @@ margin-bottom: 24px; .components-checkbox-control { - &__input { + &__input:not(:disabled) { @include features-colors( ( 'border-color' ) ); &:checked { @include features-colors( ( 'background-color' ) ); } } + &__input:disabled { + border-color: #ddd; + background-color: #ddd; + } } .components-toggle-control { diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/controls.js b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/controls.js index 9c0dc17bdd03f..23fabb1f885ea 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/controls.js +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/controls.js @@ -118,6 +118,8 @@ const Controls = ( { blocks, disabledFeatures } ) => {
{ features.map( feature => ( get_theme_slug(); break; + case 'theme_errors': + $options[ $key ] = $site->get_theme_errors(); + break; case 'header_image': $options[ $key ] = $site->get_header_image(); break; diff --git a/projects/plugins/jetpack/modules/notes.php b/projects/plugins/jetpack/modules/notes.php index 3c07163dc4a3f..0b1f3bed6938f 100644 --- a/projects/plugins/jetpack/modules/notes.php +++ b/projects/plugins/jetpack/modules/notes.php @@ -212,7 +212,7 @@ public function admin_bar_menu() { */ private static function get_notes_markup() { return ' - + ' . esc_html__( 'Notifications', 'jetpack' ) . ''; } diff --git a/projects/plugins/jetpack/modules/theme-tools/social-links.php b/projects/plugins/jetpack/modules/theme-tools/social-links.php index 46ffd1583d088..75c1fb0e04a86 100644 --- a/projects/plugins/jetpack/modules/theme-tools/social-links.php +++ b/projects/plugins/jetpack/modules/theme-tools/social-links.php @@ -15,15 +15,17 @@ // phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- TODO: Move classes to appropriately-named class files. -/** - * Init Social_Links if the theme declares support. - */ -function jetpack_theme_supports_social_links() { - if ( ! wp_is_block_theme() && current_theme_supports( 'social-links' ) && function_exists( 'publicize_init' ) ) { - new Social_Links(); +if ( ! function_exists( 'jetpack_theme_supports_social_links' ) ) { + /** + * Init Social_Links if the theme declares support. + */ + function jetpack_theme_supports_social_links() { + if ( ! wp_is_block_theme() && current_theme_supports( 'social-links' ) && function_exists( 'publicize_init' ) ) { + new Social_Links(); + } } + add_action( 'init', 'jetpack_theme_supports_social_links', 30 ); } -add_action( 'init', 'jetpack_theme_supports_social_links', 30 ); if ( ! class_exists( 'Social_Links' ) ) { diff --git a/projects/plugins/jetpack/sal/class.json-api-site-base.php b/projects/plugins/jetpack/sal/class.json-api-site-base.php index bc4ac5a7de61b..40925db17215e 100644 --- a/projects/plugins/jetpack/sal/class.json-api-site-base.php +++ b/projects/plugins/jetpack/sal/class.json-api-site-base.php @@ -1057,6 +1057,29 @@ public function get_theme_slug() { return get_option( 'stylesheet' ); } + /** + * Returns a list of errors for broken themes on the site. + * + * @return array + */ + public function get_theme_errors() { + $themes_with_errors = wp_get_themes( array( 'errors' => true ) ); + $theme_errors = array(); + + foreach ( $themes_with_errors as $theme ) { + $errors = $theme->errors(); + + if ( is_wp_error( $errors ) && ! empty( $errors->get_error_messages() ) ) { + $theme_errors[] = array( + 'name' => sanitize_title( $theme->get( 'Name' ) ), + 'errors' => (array) $errors->get_error_messages(), + ); + } + } + + return $theme_errors; + } + /** * Gets the header image data. * diff --git a/projects/plugins/jetpack/sal/class.json-api-site-jetpack.php b/projects/plugins/jetpack/sal/class.json-api-site-jetpack.php index 152a0d661b2b6..1dbdc69cc3f6d 100644 --- a/projects/plugins/jetpack/sal/class.json-api-site-jetpack.php +++ b/projects/plugins/jetpack/sal/class.json-api-site-jetpack.php @@ -566,6 +566,10 @@ public function is_fse_active() { if ( ! Jetpack::is_plugin_active( 'full-site-editing/full-site-editing-plugin.php' ) ) { return false; } + if ( function_exists( '\Automattic\Jetpack\Jetpack_Mu_Wpcom\Wpcom_Legacy_FSE\is_full_site_editing_active' ) ) { + // @phan-suppress-next-line PhanUndeclaredFunction + return \Automattic\Jetpack\Jetpack_Mu_Wpcom\Wpcom_Legacy_FSE\is_full_site_editing_active(); + } return function_exists( '\A8C\FSE\is_full_site_editing_active' ) && \A8C\FSE\is_full_site_editing_active(); } @@ -583,6 +587,10 @@ public function is_fse_eligible() { if ( ! Jetpack::is_plugin_active( 'full-site-editing/full-site-editing-plugin.php' ) ) { return false; } + if ( function_exists( '\Automattic\Jetpack\Jetpack_Mu_Wpcom\Wpcom_Legacy_FSE\is_site_eligible_for_full_site_editing' ) ) { + // @phan-suppress-next-line PhanUndeclaredFunction + return \Automattic\Jetpack\Jetpack_Mu_Wpcom\Wpcom_Legacy_FSE\is_site_eligible_for_full_site_editing(); + } return function_exists( '\A8C\FSE\is_site_eligible_for_full_site_editing' ) && \A8C\FSE\is_site_eligible_for_full_site_editing(); } diff --git a/projects/plugins/social/CHANGELOG.md b/projects/plugins/social/CHANGELOG.md index c3303d195ccba..4c2c606bd5757 100644 --- a/projects/plugins/social/CHANGELOG.md +++ b/projects/plugins/social/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 5.0.0 - 2024-08-01 +### Changed +- Social: Removed unnecessary feature checks for social connections [#38216] +- Updated package dependencies. [#38228] [#38235] [#38464] + +### Removed +- General: update WordPress version requirements to WordPress 6.5. [#38382] +- Removed the unused code for image auto-conversion from social store [#38609] +- Social | Removed the media auto-conversion UI [#38497] + ## 4.5.2 - 2024-07-03 ### Changed - General: indicate compatibility with the upcoming version of WordPress - 6.6. [#37962] diff --git a/projects/plugins/social/changelog/add-mj-protect-card-auto-firewall-status b/projects/plugins/social/changelog/add-mj-protect-card-auto-firewall-status deleted file mode 100644 index 66603f18e7ca1..0000000000000 --- a/projects/plugins/social/changelog/add-mj-protect-card-auto-firewall-status +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: changed -Comment: update lock file - - diff --git a/projects/plugins/social/changelog/add-mj-protect-card-last-scan-time b/projects/plugins/social/changelog/add-mj-protect-card-last-scan-time deleted file mode 100644 index 9aa70e3ec1f75..0000000000000 --- a/projects/plugins/social/changelog/add-mj-protect-card-last-scan-time +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: changed -Comment: Updated composer.lock. - - diff --git a/projects/plugins/social/changelog/add-mj-protect-card-last-scan-time#2 b/projects/plugins/social/changelog/add-mj-protect-card-last-scan-time#2 deleted file mode 100644 index 9aa70e3ec1f75..0000000000000 --- a/projects/plugins/social/changelog/add-mj-protect-card-last-scan-time#2 +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: changed -Comment: Updated composer.lock. - - diff --git a/projects/plugins/social/changelog/add-mj-protect-card-last-scan-time#3 b/projects/plugins/social/changelog/add-mj-protect-card-last-scan-time#3 deleted file mode 100644 index 9aa70e3ec1f75..0000000000000 --- a/projects/plugins/social/changelog/add-mj-protect-card-last-scan-time#3 +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: changed -Comment: Updated composer.lock. - - diff --git a/projects/plugins/social/changelog/add-my-jetpack-aa-experiment b/projects/plugins/social/changelog/add-my-jetpack-aa-experiment deleted file mode 100644 index 9aa70e3ec1f75..0000000000000 --- a/projects/plugins/social/changelog/add-my-jetpack-aa-experiment +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: changed -Comment: Updated composer.lock. - - diff --git a/projects/plugins/social/changelog/add-my-jetpack-aa-experiment#2 b/projects/plugins/social/changelog/add-my-jetpack-aa-experiment#2 deleted file mode 100644 index 9aa70e3ec1f75..0000000000000 --- a/projects/plugins/social/changelog/add-my-jetpack-aa-experiment#2 +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: changed -Comment: Updated composer.lock. - - diff --git a/projects/plugins/social/changelog/add-protect-global-waf-stats b/projects/plugins/social/changelog/add-protect-global-waf-stats deleted file mode 100644 index 9aa70e3ec1f75..0000000000000 --- a/projects/plugins/social/changelog/add-protect-global-waf-stats +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: changed -Comment: Updated composer.lock. - - diff --git a/projects/plugins/social/changelog/add-segmentation-to-jetpack-products-my-jetpack b/projects/plugins/social/changelog/add-segmentation-to-jetpack-products-my-jetpack deleted file mode 100644 index 9aa70e3ec1f75..0000000000000 --- a/projects/plugins/social/changelog/add-segmentation-to-jetpack-products-my-jetpack +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: changed -Comment: Updated composer.lock. - - diff --git a/projects/plugins/social/changelog/add-social-feature-flag-management-fixed b/projects/plugins/social/changelog/add-social-feature-flag-management-fixed new file mode 100644 index 0000000000000..43e3086f87315 --- /dev/null +++ b/projects/plugins/social/changelog/add-social-feature-flag-management-fixed @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Added feature flag management for social diff --git a/projects/plugins/social/changelog/add-sync-waf-options b/projects/plugins/social/changelog/add-sync-waf-options deleted file mode 100644 index 9aa70e3ec1f75..0000000000000 --- a/projects/plugins/social/changelog/add-sync-waf-options +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: changed -Comment: Updated composer.lock. - - diff --git a/projects/plugins/social/changelog/change-my-jetpack-ai-card b/projects/plugins/social/changelog/change-my-jetpack-ai-card deleted file mode 100644 index 9aa70e3ec1f75..0000000000000 --- a/projects/plugins/social/changelog/change-my-jetpack-ai-card +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: changed -Comment: Updated composer.lock. - - diff --git a/projects/plugins/social/changelog/fix-jsx-runtime-react-19-polyfill b/projects/plugins/social/changelog/fix-jsx-runtime-react-19-polyfill deleted file mode 100644 index 9aa70e3ec1f75..0000000000000 --- a/projects/plugins/social/changelog/fix-jsx-runtime-react-19-polyfill +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: changed -Comment: Updated composer.lock. - - diff --git a/projects/plugins/social/changelog/fix-mu-wpcom-scssphp b/projects/plugins/social/changelog/fix-mu-wpcom-scssphp deleted file mode 100644 index 427aa2192f0dc..0000000000000 --- a/projects/plugins/social/changelog/fix-mu-wpcom-scssphp +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: added -Comment: Add production-include for wikimedia/aho-corasick, now required via my-jetpack in #38332. - - diff --git a/projects/plugins/social/changelog/fix-sync-hpos-checksum-support b/projects/plugins/social/changelog/fix-sync-hpos-checksum-support deleted file mode 100644 index 9aa70e3ec1f75..0000000000000 --- a/projects/plugins/social/changelog/fix-sync-hpos-checksum-support +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: changed -Comment: Updated composer.lock. - - diff --git a/projects/plugins/social/changelog/remove-social-auto-conversion-logic-from-data-store b/projects/plugins/social/changelog/remove-social-auto-conversion-logic-from-data-store deleted file mode 100644 index 8f024ecc67e92..0000000000000 --- a/projects/plugins/social/changelog/remove-social-auto-conversion-logic-from-data-store +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: removed - -Removed the unused code for image auto-conversion from social store diff --git a/projects/plugins/social/changelog/renovate-lock-file-maintenance b/projects/plugins/social/changelog/renovate-lock-file-maintenance deleted file mode 100644 index c47cb18e82997..0000000000000 --- a/projects/plugins/social/changelog/renovate-lock-file-maintenance +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: changed - -Updated package dependencies. diff --git a/projects/plugins/social/changelog/renovate-playwright-monorepo b/projects/plugins/social/changelog/renovate-playwright-monorepo deleted file mode 100644 index c47cb18e82997..0000000000000 --- a/projects/plugins/social/changelog/renovate-playwright-monorepo +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: changed - -Updated package dependencies. diff --git a/projects/plugins/social/changelog/update-minimum-wp-to-6.5 b/projects/plugins/social/changelog/update-minimum-wp-to-6.5 deleted file mode 100644 index 324db53ba465c..0000000000000 --- a/projects/plugins/social/changelog/update-minimum-wp-to-6.5 +++ /dev/null @@ -1,4 +0,0 @@ -Significance: major -Type: removed - -General: update WordPress version requirements to WordPress 6.5. diff --git a/projects/plugins/social/changelog/update-remove-social-connection-feature-checks b/projects/plugins/social/changelog/update-remove-social-connection-feature-checks deleted file mode 100644 index 52e5c87b3b699..0000000000000 --- a/projects/plugins/social/changelog/update-remove-social-connection-feature-checks +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: changed - -Social: Removed unnecessary feature checks for social connections diff --git a/projects/plugins/social/changelog/update-remove-stats-from-connection-interstitial b/projects/plugins/social/changelog/update-remove-stats-from-connection-interstitial deleted file mode 100644 index 9aa70e3ec1f75..0000000000000 --- a/projects/plugins/social/changelog/update-remove-stats-from-connection-interstitial +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: changed -Comment: Updated composer.lock. - - diff --git a/projects/plugins/social/changelog/update-social-Remove-the-auto-conversion-toggle-UI b/projects/plugins/social/changelog/update-social-Remove-the-auto-conversion-toggle-UI deleted file mode 100644 index 4de390bcd2d2b..0000000000000 --- a/projects/plugins/social/changelog/update-social-Remove-the-auto-conversion-toggle-UI +++ /dev/null @@ -1,4 +0,0 @@ -Significance: minor -Type: removed - -Social | Removed the media auto-conversion UI diff --git a/projects/plugins/social/changelog/update-sync-must-sync-data-settings b/projects/plugins/social/changelog/update-sync-must-sync-data-settings deleted file mode 100644 index 9aa70e3ec1f75..0000000000000 --- a/projects/plugins/social/changelog/update-sync-must-sync-data-settings +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: changed -Comment: Updated composer.lock. - - diff --git a/projects/plugins/social/composer.json b/projects/plugins/social/composer.json index a6fede8af54da..64d0f38b150db 100644 --- a/projects/plugins/social/composer.json +++ b/projects/plugins/social/composer.json @@ -84,6 +84,6 @@ "automattic/jetpack-autoloader": true, "automattic/jetpack-composer-plugin": true }, - "autoloader-suffix": "c4802e05bbcf59fd3b6350e8d3e5482c_socialⓥ5_0_0_alpha" + "autoloader-suffix": "c4802e05bbcf59fd3b6350e8d3e5482c_socialⓥ5_1_0_alpha" } } diff --git a/projects/plugins/social/jetpack-social.php b/projects/plugins/social/jetpack-social.php index 47bfb4fe1d3f5..cb0c28d8c469c 100644 --- a/projects/plugins/social/jetpack-social.php +++ b/projects/plugins/social/jetpack-social.php @@ -4,7 +4,7 @@ * Plugin Name: Jetpack Social * Plugin URI: https://wordpress.org/plugins/jetpack-social * Description: Share your site’s posts on several social media networks automatically when you publish a new post. - * Version: 5.0.0-alpha + * Version: 5.1.0-alpha * Author: Automattic - Jetpack Social team * Author URI: https://jetpack.com/social/ * License: GPLv2 or later diff --git a/projects/plugins/social/readme.txt b/projects/plugins/social/readme.txt index a7d2c02f520cd..19f2fda149801 100644 --- a/projects/plugins/social/readme.txt +++ b/projects/plugins/social/readme.txt @@ -102,15 +102,15 @@ The easiest way is to use the Custom Message option in the publishing options bo 6. Managing Social media accounts in the post editor == Changelog == -### 4.5.2 - 2024-07-03 +### 5.0.0 - 2024-08-01 #### Changed -- General: indicate compatibility with the upcoming version of WordPress - 6.6. +- Social: Removed unnecessary feature checks for social connections - Updated package dependencies. -#### Fixed -- Fixed E2E tests navigating to block editor -- Fixed the admin page pricing table not shown -- Social: Fixed broken connections reconnect link to point it to new connections UI +#### Removed +- General: update WordPress version requirements to WordPress 6.5. +- Removed the unused code for image auto-conversion from social store +- Social | Removed the media auto-conversion UI == Upgrade Notice == diff --git a/projects/plugins/social/src/class-jetpack-social.php b/projects/plugins/social/src/class-jetpack-social.php index 66dbe324864bf..a0cde2f985900 100644 --- a/projects/plugins/social/src/class-jetpack-social.php +++ b/projects/plugins/social/src/class-jetpack-social.php @@ -353,6 +353,8 @@ class_exists( 'Jetpack' ) || $initial_state['connectionRefreshPath'] = $social_state['connectionRefreshPath']; } + $initial_state['featureFlags'] = $social_state['featureFlags']; + wp_localize_script( 'jetpack-publicize', 'Jetpack_Editor_Initial_State', diff --git a/projects/plugins/wpcomsh/.phan/config.php b/projects/plugins/wpcomsh/.phan/config.php index a236f81beecd5..e060ea95c3951 100644 --- a/projects/plugins/wpcomsh/.phan/config.php +++ b/projects/plugins/wpcomsh/.phan/config.php @@ -15,7 +15,7 @@ array( 'exclude_file_regex' => array( 'tests/lib/mocks' ), 'exclude_file_list' => array( - __DIR__ . '/../../../plugins/jetpack/_inc/lib/class.color.php', + __DIR__ . '/../../../packages/classic-theme-helper/_inc/lib/class.color.php', ), 'parse_file_list' => array( // Reference files to handle code checking for stuff from Jetpack-the-plugin or other in-monorepo plugins. @@ -26,7 +26,7 @@ // other in 'require-dev' and `extra.dependencies.test-only' instead. See packages/config for an example. // -- // class.color.php provides the definition of the Jetpack_Color class. - __DIR__ . '/../../../plugins/jetpack/_inc/lib/class.color.php', + __DIR__ . '/../../../packages/classic-theme-helper/_inc/lib/class.color.php', // class.jetpack.php provides the definition of the Jetpack megaclass. __DIR__ . '/../../../plugins/jetpack/class.jetpack.php', // class.jetpack-gutenberg.php provides the definition of the Jetpack_Gutenberg class. diff --git a/projects/plugins/wpcomsh/changelog/add-aiowp-target-blog-id b/projects/plugins/wpcomsh/changelog/add-aiowp-target-blog-id new file mode 100644 index 0000000000000..3def85e081bad --- /dev/null +++ b/projects/plugins/wpcomsh/changelog/add-aiowp-target-blog-id @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Add target_blog_id prop to AIOWP tracks events diff --git a/projects/plugins/wpcomsh/changelog/update-jetpack-color-tonesque-paths b/projects/plugins/wpcomsh/changelog/update-jetpack-color-tonesque-paths new file mode 100644 index 0000000000000..349f3997aa8a8 --- /dev/null +++ b/projects/plugins/wpcomsh/changelog/update-jetpack-color-tonesque-paths @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Update path to Jetpack Color & Tonesque + + diff --git a/projects/plugins/wpcomsh/changelog/update-wpcom-plan-name-revert b/projects/plugins/wpcomsh/changelog/update-wpcom-plan-name-revert new file mode 100644 index 0000000000000..44d93e9cd5bbd --- /dev/null +++ b/projects/plugins/wpcomsh/changelog/update-wpcom-plan-name-revert @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Plan names: Revert plan names to Personal/Premium/Business/Commerce diff --git a/projects/plugins/wpcomsh/composer.json b/projects/plugins/wpcomsh/composer.json index b031c12a2c918..1ebd0a1bf3546 100644 --- a/projects/plugins/wpcomsh/composer.json +++ b/projects/plugins/wpcomsh/composer.json @@ -128,7 +128,7 @@ "composer/installers": true, "roots/wordpress-core-installer": true }, - "autoloader-suffix": "26841ac2064774301cbe06d174833bfc_wpcomshⓥ5_1_3_alpha" + "autoloader-suffix": "26841ac2064774301cbe06d174833bfc_wpcomshⓥ5_2_0_alpha" }, "extra": { "mirror-repo": "Automattic/wpcom-site-helper", diff --git a/projects/plugins/wpcomsh/lib/class.color.php b/projects/plugins/wpcomsh/lib/class.color.php index 023276097e11b..43419401fc27d 100644 --- a/projects/plugins/wpcomsh/lib/class.color.php +++ b/projects/plugins/wpcomsh/lib/class.color.php @@ -8,4 +8,4 @@ // phpcs:ignoreFile WordPress.Files.FileName.NotHyphenatedLowercase // Dummy comment to make phpcs happy. -require_once JETPACK__PLUGIN_DIR . '_inc/lib/class.color.php'; +require_once WPCOMSH__PLUGIN_DIR_PATH . '/vendor/automattic/jetpack-classic-theme-helper/_inc/lib/class.color.php'; \ No newline at end of file diff --git a/projects/plugins/wpcomsh/lib/tonesque.php b/projects/plugins/wpcomsh/lib/tonesque.php index 93bf7affdc22a..cc95ebcc45442 100644 --- a/projects/plugins/wpcomsh/lib/tonesque.php +++ b/projects/plugins/wpcomsh/lib/tonesque.php @@ -6,4 +6,4 @@ */ // Dummy comment to make phpcs happy. -require_once JETPACK__PLUGIN_DIR . '_inc/lib/tonesque.php'; +require_once WPCOMSH__PLUGIN_DIR_PATH . '/vendor/automattic/jetpack-classic-theme-helper/_inc/lib/tonesque.php'; diff --git a/projects/plugins/wpcomsh/notices/plan-notices.php b/projects/plugins/wpcomsh/notices/plan-notices.php index 3b03e1e5570fd..26828711bb231 100644 --- a/projects/plugins/wpcomsh/notices/plan-notices.php +++ b/projects/plugins/wpcomsh/notices/plan-notices.php @@ -86,22 +86,22 @@ function ( $purchase1, $purchase2 ) { $plan_messages = array( /* translators: %1$s is a link for plan renewal, %2$s human readable time e.g. January 1, 2021, %3$s site URL */ 'personal' => __( - 'The Starter plan for %3$s expires on %2$s. Renew your plan to retain Starter plan features such as 6 GB storage space, no WordPress.com ads, and Subscriber-only content.', + 'The Personal plan for %3$s expires on %2$s. Renew your plan to retain Personal plan features such as 6 GB storage space, no WordPress.com ads, and Subscriber-only content.', 'wpcomsh' ), /* translators: %1$s is a link for plan renewal, %2$s human readable time e.g. January 1, 2021, %3$s site URL */ 'premium' => __( - 'The Explorer plan for %3$s expires on %2$s. Renew your plan to retain Explorer plan features such as site monetization, VideoPress, and Google Analytics support.', + 'The Premium plan for %3$s expires on %2$s. Renew your plan to retain Premium plan features such as site monetization, VideoPress, and Google Analytics support.', 'wpcomsh' ), /* translators: %1$s is a link for plan renewal, %2$s human readable time e.g. January 1, 2021, %3$s site URL */ 'business' => __( - 'The Creator plan for %3$s expires on %2$s. Renew your plan to retain Creator plan features such as custom plugins and themes, SFTP, and phpMyAdmin access.', + 'The Business plan for %3$s expires on %2$s. Renew your plan to retain Business plan features such as custom plugins and themes, SFTP, and phpMyAdmin access.', 'wpcomsh' ), /* translators: %1$s is a link for plan renewal, %2$s human readable time e.g. January 1, 2021, %3$s site URL */ 'ecommerce' => __( - 'The Entrepreneur plan for %3$s expires on %2$s. Renew your plan to retain Entrepreneur plan features such as custom plugins and themes, SFTP, and phpMyAdmin access.', + 'The Commerce plan for %3$s expires on %2$s. Renew your plan to retain Commerce plan features such as custom plugins and themes, SFTP, and phpMyAdmin access.', 'wpcomsh' ), /* translators: %1$s is a link for plan renewal, %2$s human readable time e.g. January 1, 2021, %3$s site URL */ @@ -117,22 +117,22 @@ function ( $purchase1, $purchase2 ) { $plan_messages = array( /* translators: %1$s is a link for plan renewal, %2$s human readable time e.g. January 1, 2021, %3$s site URL */ 'personal' => __( - 'The Starter plan for %3$s expired on %2$s. Reactivate your plan to retain Starter plan features such as 6 GB storage space, no WordPress.com ads, and Subscriber-only content.', + 'The Personal plan for %3$s expired on %2$s. Reactivate your plan to retain Personal plan features such as 6 GB storage space, no WordPress.com ads, and Subscriber-only content.', 'wpcomsh' ), /* translators: %1$s is a link for plan renewal, %2$s human readable time e.g. January 1, 2021, %3$s site URL */ 'premium' => __( - 'The Explorer plan for %3$s expired on %2$s. Reactivate your plan to retain Explorer plan features such as site monetization, VideoPress, and Google Analytics support.', + 'The Premium plan for %3$s expired on %2$s. Reactivate your plan to retain Premium plan features such as site monetization, VideoPress, and Google Analytics support.', 'wpcomsh' ), /* translators: %1$s is a link for plan renewal, %2$s human readable time e.g. January 1, 2021, %3$s site URL */ 'business' => __( - 'The Creator plan for %3$s expired on %2$s. Reactivate your plan to retain Creator plan features such as custom plugins and themes, SFTP, and phpMyAdmin access.', + 'The Business plan for %3$s expired on %2$s. Reactivate your plan to retain Business plan features such as custom plugins and themes, SFTP, and phpMyAdmin access.', 'wpcomsh' ), /* translators: %1$s is a link for plan renewal, %2$s human readable time e.g. January 1, 2021, %3$s site URL */ 'ecommerce' => __( - 'The Entrepreneur plan for %3$s expired on %2$s. Reactivate your plan to retain Entrepreneur plan features such as custom plugins and themes, SFTP, and phpMyAdmin access.', + 'The Commerce plan for %3$s expired on %2$s. Reactivate your plan to retain Commerce plan features such as custom plugins and themes, SFTP, and phpMyAdmin access.', 'wpcomsh' ), /* translators: %1$s is a link for plan renewal, %2$s human readable time e.g. January 1, 2021, %3$s site URL */ diff --git a/projects/plugins/wpcomsh/package.json b/projects/plugins/wpcomsh/package.json index ea13d416f7d21..4c488eeeac439 100644 --- a/projects/plugins/wpcomsh/package.json +++ b/projects/plugins/wpcomsh/package.json @@ -3,7 +3,7 @@ "name": "@automattic/jetpack-wpcomsh", "description": "A helper for connecting WordPress.com sites to external host infrastructure.", "homepage": "https://jetpack.com", - "version": "5.1.3-alpha", + "version": "5.2.0-alpha", "bugs": { "url": "https://github.com/Automattic/jetpack/labels/[Plugin] Wpcomsh" }, diff --git a/projects/plugins/wpcomsh/wpcom-migration-helpers/site-migration-helpers.php b/projects/plugins/wpcomsh/wpcom-migration-helpers/site-migration-helpers.php index 8471424e542db..4ab07dc95c126 100644 --- a/projects/plugins/wpcomsh/wpcom-migration-helpers/site-migration-helpers.php +++ b/projects/plugins/wpcomsh/wpcom-migration-helpers/site-migration-helpers.php @@ -67,14 +67,17 @@ function aiowp_migration_logging_helper() { return; } + $target_blog_id = _wpcom_get_current_blog_id(); + // Filter that gets called when import starts add_filter( 'ai1wm_import', - function ( $params = array() ) { + function ( $params = array() ) use ( $target_blog_id ) { wpcomsh_record_tracks_event( 'wpcom_site_migration_start', array( 'migration_tool' => 'aiowp', + 'target_blog_id' => $target_blog_id, ) ); return $params; @@ -85,11 +88,12 @@ function ( $params = array() ) { // Filter that gets called when import finishes or is cancelled by the user add_filter( 'ai1wm_import', - function ( $params = array() ) { + function ( $params = array() ) use ( $target_blog_id ) { wpcomsh_record_tracks_event( 'wpcom_site_migration_done', array( 'migration_tool' => 'aiowp', + 'target_blog_id' => $target_blog_id, ) ); return $params; diff --git a/projects/plugins/wpcomsh/wpcomsh.php b/projects/plugins/wpcomsh/wpcomsh.php index d43e47618f670..35f773d19d55d 100644 --- a/projects/plugins/wpcomsh/wpcomsh.php +++ b/projects/plugins/wpcomsh/wpcomsh.php @@ -2,14 +2,14 @@ /** * Plugin Name: WordPress.com Site Helper * Description: A helper for connecting WordPress.com sites to external host infrastructure. - * Version: 5.1.3-alpha + * Version: 5.2.0-alpha * Author: Automattic * Author URI: http://automattic.com/ * * @package wpcomsh */ -define( 'WPCOMSH_VERSION', '5.1.3-alpha' ); +define( 'WPCOMSH_VERSION', '5.2.0-alpha' ); // If true, Typekit fonts will be available in addition to Google fonts add_filter( 'jetpack_fonts_enable_typekit', '__return_true' ); diff --git a/tools/includes/send_tracks_event.sh b/tools/includes/send_tracks_event.sh index 457f7eb0be8fa..101f91212a664 100644 --- a/tools/includes/send_tracks_event.sh +++ b/tools/includes/send_tracks_event.sh @@ -14,7 +14,7 @@ function send_tracks_event { USER_AGENT='jetpack-monorepo-cli' PAYLOAD=$(jq -nr \ --arg email "$(git config --get user.email)" \ - '.commonProps = {"_ul": $email, "_ut": "anon", "_rt": ( now * 1000 | round ) }' + '.commonProps = {"_ui": $email, "_ut": "anon", "_rt": ( now * 1000 | round ) }' ) # Add event name to payload.