+ >
+ );
+};
+
+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 ? (
+
+
+ { __( 'Skip', 'jetpack-mu-wpcom' ) }
+
+
+ { __( 'Take the tour', 'jetpack-mu-wpcom' ) }
+
+
+ ) : (
+
+
+ { __( 'Back', 'jetpack-mu-wpcom' ) }
+
+
+ { __( 'Next', 'jetpack-mu-wpcom' ) }
+
+
+ ) }
+
+ >
+ );
+};
+
+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 && (
+
+
+ { imgLink && (
+
+
+
+
+ }
+ size={ 27 }
+ />
+
+ ) }
+
+ ) }
+
+ { heading }
+
+ { description }
+ { isLastStep ? (
+ onGoToStep( 0 ) }
+ ref={ setInitialFocusedElement }
+ >
+ { __( 'Restart tour', 'jetpack-mu-wpcom' ) }
+
+ ) : 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 (
+
+
+
+
{
+ if ( promptIndex - 1 < 0 ) {
+ return setPromptIndex( prompts.length - 1 );
+ }
+ return setPromptIndex( promptIndex - 1 );
+ } }
+ aria-label={ __( 'Show previous prompt', 'jetpack-mu-wpcom' ) }
+ variant="secondary"
+ className="blogging-prompts-modal__prompt-navigation-button"
+ >
+
+
+
{ prompts[ promptIndex ]?.text }
+
setPromptIndex( ( promptIndex + 1 ) % prompts.length ) }
+ aria-label={ __( 'Show next prompt', 'jetpack-mu-wpcom' ) }
+ variant="secondary"
+ className="blogging-prompts-modal__prompt-navigation-button"
+ >
+
+
+
+
+ { __( 'Post Answer', 'jetpack-mu-wpcom' ) }
+
+
+
+ );
+};
+
+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 (
+
+
+ { __( 'Start writing', 'jetpack-mu-wpcom' ) }
+
+
+ { __( "I'm not ready", 'jetpack-mu-wpcom' ) }
+
+ >
+ }
+ 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 (
+
+
+ { __( 'View Post', 'jetpack-mu-wpcom' ) }
+
+ { launchpadScreenOption === 'full' && siteIntentOption === 'write' && (
+
+ { __( 'Next Steps', 'jetpack-mu-wpcom' ) }
+
+ ) }
+ >
+ }
+ 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 (
+
+ { __( 'Continue editing', 'jetpack-mu-wpcom' ) }
+
+ { __( 'View your product', 'jetpack-mu-wpcom' ) }
+
+ >
+ }
+ 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 (
+
+ );
+} );
+
+export default ClipboardButton;
diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/form-checkbox.tsx b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/form-checkbox.tsx
new file mode 100644
index 0000000000000..ee9f65a1329ae
--- /dev/null
+++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/form-checkbox.tsx
@@ -0,0 +1,19 @@
+import clsx from 'clsx';
+import { InputHTMLAttributes, forwardRef } from 'react';
+
+import './style.scss';
+
+type CheckboxProps = InputHTMLAttributes< HTMLInputElement >;
+
+const FormInputCheckbox = forwardRef< HTMLInputElement | null, CheckboxProps >(
+ ( { className, ...otherProps }, ref ) => (
+
+ )
+);
+
+export default FormInputCheckbox;
diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/form-label.tsx b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/form-label.tsx
new file mode 100644
index 0000000000000..a39f35baa8911
--- /dev/null
+++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/form-label.tsx
@@ -0,0 +1,37 @@
+import { __ } from '@wordpress/i18n';
+import clsx from 'clsx';
+import { Children, FunctionComponent, LabelHTMLAttributes } from 'react';
+
+import './style.scss';
+
+export interface Props {
+ optional?: boolean;
+ required?: boolean;
+}
+
+type LabelProps = LabelHTMLAttributes< HTMLLabelElement >;
+
+const FormLabel: FunctionComponent< Props & LabelProps > = ( {
+ children,
+ required,
+ optional,
+ className, // Via LabelProps
+ ...labelProps
+} ) => {
+ const hasChildren: boolean = Children.count( children ) > 0;
+
+ return (
+ // eslint-disable-next-line jsx-a11y/label-has-for
+
+ );
+};
+
+export default FormLabel;
diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/index.tsx b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/index.tsx
new file mode 100644
index 0000000000000..e94883d699491
--- /dev/null
+++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-block-editor-nux/src/sharing-modal/index.tsx
@@ -0,0 +1,291 @@
+import { Modal, Button } from '@wordpress/components';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { useEffect, useRef, useState } from '@wordpress/element';
+import { Icon, globe, link as linkIcon } from '@wordpress/icons';
+import { store as noticesStore } from '@wordpress/notices';
+import { useI18n } from '@wordpress/react-i18n';
+import clsx from 'clsx';
+import React from 'react';
+import postPublishedImage from '../../../../assets/images/illo-share.svg';
+import {
+ START_WRITING_FLOW,
+ DESIGN_FIRST_FLOW,
+ useSiteIntent,
+ useShouldShowSellerCelebrationModal,
+ useShouldShowVideoCelebrationModal,
+ useShouldShowFirstPostPublishedModal,
+} from '../../../../common/tour-kit';
+import { wpcomTrackEvent } from '../../../../common/tracks';
+import ClipboardButton from './clipboard-button';
+import FormInputCheckbox from './form-checkbox';
+import FormLabel from './form-label';
+import InlineSocialLogo from './inline-social-logo';
+import InlineSocialLogosSprite from './inline-social-logos-sprite';
+import SuggestedTags from './suggested-tags';
+import useSharingModalDismissed from './use-sharing-modal-dismissed';
+
+import './style.scss';
+
+type CoreEditorPlaceholder = {
+ getCurrentPost: ( ...args: unknown[] ) => {
+ link: string;
+ title: string;
+ status: string;
+ password: string;
+ };
+ getCurrentPostType: ( ...args: unknown[] ) => string;
+ isCurrentPostPublished: ( ...args: unknown[] ) => boolean;
+};
+const FB_APP_ID = '249643311490';
+
+const SharingModalInner: React.FC = () => {
+ const isDismissedDefault = window?.sharingModalOptions?.isDismissed || false;
+ const { launchpadScreenOption } = window?.launchpadOptions || {};
+ const { isDismissed, updateIsDismissed } = useSharingModalDismissed( isDismissedDefault );
+ const { __ } = useI18n();
+ const isPrivateBlog = window?.wpcomGutenberg?.blogPublic === '-1';
+
+ const {
+ link,
+ title,
+ status: postStatus,
+ password: postPassword,
+ } = 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 shouldShowSellerCelebrationModal = useShouldShowSellerCelebrationModal();
+ const shouldShowVideoCelebrationModal =
+ useShouldShowVideoCelebrationModal( isCurrentPostPublished );
+
+ const [ isOpen, setIsOpen ] = useState( false );
+ const closeModal = () => setIsOpen( false );
+ const { createNotice } = useDispatch( noticesStore );
+ const [ shouldShowSuggestedTags, setShouldShowSuggestedTags ] = React.useState( true );
+
+ useEffect( () => {
+ // The first post will show a different modal.
+ if (
+ ! shouldShowFirstPostPublishedModal &&
+ ! shouldShowSellerCelebrationModal &&
+ ! shouldShowVideoCelebrationModal &&
+ launchpadScreenOption !== 'full' &&
+ ! previousIsCurrentPostPublished.current &&
+ isCurrentPostPublished &&
+ // Ensure post is published publicly and not private or password protected.
+ postStatus === 'publish' &&
+ ! postPassword &&
+ postType === 'post'
+ ) {
+ previousIsCurrentPostPublished.current = isCurrentPostPublished;
+ wpcomTrackEvent( 'calypso_editor_sharing_dialog_show' );
+
+ // 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,
+ postPassword,
+ postStatus,
+ shouldShowFirstPostPublishedModal,
+ shouldShowSellerCelebrationModal,
+ shouldShowVideoCelebrationModal,
+ isCurrentPostPublished,
+ launchpadScreenOption,
+ ] );
+
+ if ( ! isOpen || isDismissedDefault || isPrivateBlog ) {
+ return null;
+ }
+
+ const shareTwitter = () => {
+ const baseUrl = new URL( 'https://twitter.com/intent/tweet' );
+ const params = new URLSearchParams( {
+ text: title,
+ url: link,
+ } );
+ baseUrl.search = params.toString();
+ const twitterUrl = baseUrl.href;
+
+ wpcomTrackEvent( 'calypso_editor_sharing_twitter' );
+ window.open( twitterUrl, 'twitter', 'width=550,height=420,resizeable,scrollbars' );
+ };
+ const shareFb = () => {
+ const baseUrl = new URL( 'https://www.facebook.com/sharer.php' );
+ const params = new URLSearchParams( {
+ u: link,
+ app_id: FB_APP_ID,
+ } );
+ baseUrl.search = params.toString();
+ const facebookUrl = baseUrl.href;
+
+ wpcomTrackEvent( 'calypso_editor_sharing_facebook' );
+ window.open( facebookUrl, 'facebook', 'width=626,height=436,resizeable,scrollbars' );
+ };
+ const shareLinkedin = () => {
+ const baseUrl = new URL( 'https://www.linkedin.com/shareArticle' );
+ const params = new URLSearchParams( {
+ title,
+ url: link,
+ } );
+ baseUrl.search = params.toString();
+ const linkedinUrl = baseUrl.href;
+
+ wpcomTrackEvent( 'calypso_editor_sharing_linkedin' );
+ window.open( linkedinUrl, 'linkedin', 'width=626,height=436,resizeable,scrollbars' );
+ };
+ const shareTumblr = () => {
+ const baseUrl = new URL( 'https://www.tumblr.com/widgets/share/tool' );
+ const params = new URLSearchParams( {
+ canonicalUrl: link,
+ title: title,
+ } );
+ baseUrl.search = params.toString();
+ const tumblrUrl = baseUrl.href;
+
+ wpcomTrackEvent( 'calypso_editor_sharing_tumblr' );
+ window.open( tumblrUrl, 'tumblr', 'width=626,height=436,resizeable,scrollbars' );
+ };
+ const sharePinterest = () => {
+ const baseUrl = new URL( 'https://pinterest.com/pin/create/button/' );
+ const params = new URLSearchParams( {
+ url: link,
+ description: title,
+ } );
+ baseUrl.search = params.toString();
+ const pinterestUrl = baseUrl.href;
+
+ wpcomTrackEvent( 'calypso_editor_sharing_pinterest' );
+ window.open( pinterestUrl, 'pinterest', 'width=626,height=436,resizeable,scrollbars' );
+ };
+ const copyLinkClick = () => {
+ wpcomTrackEvent( 'calypso_editor_sharing_link_copy' );
+ createNotice( 'success', __( 'Link copied to clipboard.', 'jetpack-mu-wpcom' ), {
+ type: 'snackbar',
+ } );
+ };
+ return (
+
+
+
+
+
{ __( 'Post published!', 'jetpack-mu-wpcom' ) }
+
+
+
{ __( 'Get more traffic to your post by sharing:', 'jetpack-mu-wpcom' ) }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ 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