{ __( 'These blocks can be enabled in the Stackable settings page. Do you want to keep the disabled blocks or substitute them with other Stackable or core blocks?', i18n ) }
- onClickPrimary() }
- >
- { __( 'Add patterns and substitute blocks', i18n ) }
-
- onClickSecondary() }
- >
- { __( 'Add patterns only (no substitutes)', i18n ) }
-
- onClickTertiary() }
- >
- { __( 'Enable blocks in settings', i18n ) }
-
+ { isInserting ? : <>
+ onClickPrimary() }
+ >
+ { __( 'Add patterns and substitute blocks', i18n ) }
+
+ onClickSecondary() }
+ >
+ { __( 'Add patterns only (no substitutes)', i18n ) }
+
+ onClickTertiary() }
+ >
+ { __( 'Enable blocks in settings', i18n ) }
+
+ > }
+
> }
diff --git a/src/components/color-scheme-preview/index.js b/src/components/color-scheme-preview/index.js
index 6bab3122a1..035a56ad0b 100644
--- a/src/components/color-scheme-preview/index.js
+++ b/src/components/color-scheme-preview/index.js
@@ -1,5 +1,21 @@
+import { i18n } from 'stackable'
+
import { Button, BaseControl } from '@wordpress/components'
+import { __ } from '@wordpress/i18n'
+
import classnames from 'classnames'
+
+export const COLOR_SCHEME_PROPERTY_LABELS = {
+ backgroundColor: __( 'Background Color', i18n ),
+ headingColor: __( 'Heading Color', i18n ),
+ textColor: __( 'Text Color', i18n ),
+ linkColor: __( 'Link Color', i18n ),
+ accentColor: __( 'Accent Color', i18n ),
+ buttonBackgroundColor: __( 'Button Color', i18n ),
+ buttonTextColor: __( 'Button Text Color', i18n ),
+ buttonOutlineColor: __( 'Button Outline Color', i18n ),
+}
+
export const DEFAULT_COLOR_SCHEME_COLORS = {
backgroundColor: { desktop: '' },
headingColor: { desktop: '' },
@@ -11,6 +27,17 @@ export const DEFAULT_COLOR_SCHEME_COLORS = {
buttonOutlineColor: { desktop: '' },
}
+export const ALTERNATE_COLOR_SCHEME_COLORS = {
+ backgroundColor: { desktop: '#0f0e17' },
+ headingColor: { desktop: '#fffffe' },
+ textColor: { desktop: '#fffffe' },
+ linkColor: { desktop: '#f00069' },
+ accentColor: { desktop: '#f00069' },
+ buttonBackgroundColor: { desktop: '#f00069' },
+ buttonTextColor: { desktop: '#fffffe' },
+ buttonOutlineColor: { desktop: '#fffffe' },
+}
+
const NOOP = () => {}
const ColorSchemePreview = ( {
diff --git a/src/components/design-library-list/use-preview-renderer.js b/src/components/design-library-list/use-preview-renderer.js
index 05f4f066de..c4aba688f9 100644
--- a/src/components/design-library-list/use-preview-renderer.js
+++ b/src/components/design-library-list/use-preview-renderer.js
@@ -30,6 +30,7 @@ import {
} from '@wordpress/element'
import { useSelect } from '@wordpress/data'
import { serialize } from '@wordpress/blocks'
+import { cleanSerializedBlock } from '~stackable/util'
const DEFAULT_CONTENT = { ...DEFAULT }
@@ -169,7 +170,7 @@ export const usePreviewRenderer = (
preview = replaceImages( preview )
- const cleanedBlock = preview.replace( //g, '' ) // removes comment
+ const cleanedBlock = cleanSerializedBlock( preview ) // removes comment
setBlocks( {
parsed: parsedBlocks,
diff --git a/src/components/design-library-list/util.js b/src/components/design-library-list/util.js
index 82e5f301bd..98e16014af 100644
--- a/src/components/design-library-list/util.js
+++ b/src/components/design-library-list/util.js
@@ -7,8 +7,9 @@ import {
import { parse, serialize } from '@wordpress/blocks'
import { select } from '@wordpress/data'
+import { META_SEPARATORS } from '~stackable/block/posts/util'
-const DEFAULT_CONTENT = { ...DEFAULT }
+export const DEFAULT_CONTENT = { ...DEFAULT }
const PARSER = new DOMParser()
export const cleanParse = content => {
@@ -237,7 +238,7 @@ export const parseDisabledBlocks = parsedBlocks => {
const IMAGE_STORAGE = cdnUrl.replace( /\/$/, '' ) + '/library-v4/images/'
-export const addPlaceholderForPostsBlock = ( content, postsPlaceholder, defaultValues ) => {
+export const addPlaceholderForPostsBlock = ( content, postsPlaceholder, defaultValues, img = null ) => {
const remainingPosts = [ ...postsPlaceholder ]
// Normalize special characters
@@ -260,6 +261,7 @@ export const addPlaceholderForPostsBlock = ( content, postsPlaceholder, defaultV
const numItems = attrs.numberOfItems ?? 6
const width = attrs.imageWidth ? attrs.imageWidth + ( attrs.imageWidthUnit ?? 'px' ) : 'auto'
+ const separator = META_SEPARATORS[ attrs.metaSeparator ?? 'dot' ]
// Get the post template inside the block
const templateMatch = innerHtml.match( /([\s\S]*?)/ )
@@ -268,17 +270,35 @@ export const addPlaceholderForPostsBlock = ( content, postsPlaceholder, defaultV
}
const template = templateMatch[ 1 ].trim()
- const currentPosts = remainingPosts.splice( 0, numItems ) // Slice the posts for this block
- const renderedPosts = currentPosts.map( ( post, index ) =>
- template
+ let currentPosts
+ if ( numItems <= remainingPosts.length ) {
+ currentPosts = remainingPosts.slice( 0, numItems )
+ } else {
+ const needed = numItems
+ const postsToUse = [
+ ...remainingPosts,
+ ...Array.from(
+ { length: needed - remainingPosts.length },
+ ( _, i ) => postsPlaceholder[ i % postsPlaceholder.length ] // reuse placeholders if numberOfItems > 6
+ ),
+ ]
+ currentPosts = postsToUse.slice( 0, numItems )
+ }
+
+ const renderedPosts = currentPosts.map( ( post, index ) => {
+ const imgSrc = img ?? `${ IMAGE_STORAGE }stk-design-library-image-${ index + 1 }.jpeg`
+ return template
.replace( /!#title!#/g, post.title_placeholder )
.replace( /!#excerpt!#/g, post.text_placeholder )
+ .replace( /!#authorName!#/g, 'John Doe' )
+ .replaceAll( /!#metaSeparator!#/g, separator )
+ .replace( /!#commentsNum!#/g, '3 comments' )
.replace( /!#date!#/g, 'March 1, 2025' )
.replace( /!#readmoreText!#/g, defaultValues[ 'post-btn_placeholder' ] )
.replace( /!#category!#/g, defaultValues.tag_placeholder )
- .replace( /img class="stk-img"/g, `img class="stk-img" src="${ IMAGE_STORAGE }stk-design-library-image-${ index + 1 }.jpeg" width="${ width }" style="width: ${ width } !important;"` )
- ).join( '\n' )
+ .replace( /img class="stk-img"/g, `img class="stk-img" src="${ imgSrc }" width="${ width }" style="width: ${ width } !important;"` )
+ } ).join( '\n' )
// Replace just the template portion, keep rest of the block
const updatedInnerHtml = innerHtml.replace(
diff --git a/src/components/guided-modal-tour/editor.scss b/src/components/guided-modal-tour/editor.scss
new file mode 100644
index 0000000000..1bbde732e9
--- /dev/null
+++ b/src/components/guided-modal-tour/editor.scss
@@ -0,0 +1,236 @@
+.ugb-tour-modal--overlay {
+ z-index: 1000002;
+ background-color: transparent !important;
+ pointer-events: none;
+}
+
+.ugb-tour-modal {
+ pointer-events: all;
+ position: absolute;
+ --offset-x: 0px;
+ --offset-y: 0px;
+ --left: 50%;
+ --top: 50%;
+ left: var(--left);
+ top: var(--top);
+ margin-left: var(--offset-x);
+ margin-top: var(--offset-y);
+ overflow: visible;
+ border-radius: 16px;
+
+ --wp-admin-theme-color: #f00069;
+ --wp-admin-theme-color-darker-10: #e0003c;
+ --wp-admin-theme-color-darker-20: #cb0044;
+
+ // Smoothly transition moving top & left.
+ transition:
+ max-width 0.4s cubic-bezier(0.4, 0, 0.2, 1),
+ left 0.2s cubic-bezier(0.4, 0, 0.2, 1),
+ top 0.2s cubic-bezier(0.4, 0, 0.2, 1),
+ margin-left 0.2s cubic-bezier(0.4, 0, 0.2, 1),
+ margin-top 0.2s cubic-bezier(0.4, 0, 0.2, 1),
+ opacity 0.4s ease-in-out,
+ transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
+ box-shadow 0.2s ease-in-out;
+ will-change: left, top, max-width;
+
+ display: none;
+ &.ugb-tour-modal--visible {
+ display: block !important;
+ opacity: 0 !important;
+ transform: scale(0.4);
+ }
+ &.ugb-tour-modal--visible-delayed {
+ opacity: 1 !important;
+ transform: scale(1);
+ }
+
+ .components-modal__header-heading {
+ line-height: 1.2;
+ }
+
+ .components-button {
+ border-radius: 4px;
+ }
+
+ .components-modal__content {
+ padding: 2em;
+ margin: 0;
+ position: relative;
+ overflow: visible;
+ z-index: 1;
+ box-shadow: 0 22px 200px 4px #0005;
+ border-radius: 16px;
+ // border: 1px solid #f00069ad;
+ }
+ .components-modal__header {
+ position: relative;
+ padding: 0;
+ margin-bottom: 8px;
+ height: auto;
+ line-height: 1.2;
+ }
+ .ugb-tour-modal__footer {
+ margin-top: 16px;
+ justify-content: flex-end;
+ }
+
+ .ugb-tour-modal__help {
+ position: relative;
+ background: #f4fbff;
+ padding: 8px 12px;
+ padding-inline-start: 30px;
+ border-radius: 8px;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ margin-block: 16px;
+ svg {
+ vertical-align: text-top;
+ margin-inline-end: 8px;
+ margin-top: 1px;
+ position: absolute;
+ inset-inline-start: 9px;
+ }
+ }
+
+ .ugb-tour-modal__cta {
+ width: 100%;
+ justify-content: center;
+ margin: 16px 0 8px;
+ }
+
+ &.ugb-tour-modal--right,
+ &.ugb-tour-modal--left,
+ &.ugb-tour-modal--top,
+ &.ugb-tour-modal--bottom {
+ .components-modal__content {
+ box-shadow: rgba(0, 0, 0, 0.2) -20px 22px 60px -4px;
+ &::after {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: -10px;
+ width: 30px;
+ height: 30px;
+ transform: translateY(-50%) rotate(45deg);
+ border-radius: 4px;
+ background-color: #fff;
+ z-index: -1;
+ }
+ }
+ }
+ &.ugb-tour-modal--left {
+ .components-modal__content {
+ box-shadow: rgba(0, 0, 0, 0.2) 20px 22px 60px -4px;
+ &::after {
+ left: auto;
+ right: -10px;
+ }
+ }
+ }
+ &.ugb-tour-modal--left-top {
+ .components-modal__content {
+ &::after {
+ top: 30px;
+ }
+ }
+ }
+ &.ugb-tour-modal--top {
+ .components-modal__content {
+ box-shadow: rgba(0, 0, 0, 0.2) 0px 22px 60px -4px;
+ &::after {
+ top: auto;
+ left: 50%;
+ bottom: -10px;
+ transform: translateX(-50%) rotate(45deg);
+ }
+ }
+ }
+ &.ugb-tour-modal--top-right {
+ .components-modal__content {
+ &::after {
+ left: auto;
+ right: 16px;
+ }
+ }
+ }
+ &.ugb-tour-modal--bottom {
+ .components-modal__content {
+ box-shadow: rgba(0, 0, 0, 0.2) 0px -22px 60px -4px;
+ &::after {
+ left: 50%;
+ top: -10px;
+ transform: translateX(-50%) rotate(45deg);
+ }
+ }
+ }
+}
+
+.ugb-tour-modal__steps {
+ display: flex;
+ gap: 6px;
+ margin-inline-end: auto;
+}
+.ugb-tour-modal__step {
+ width: 8px;
+ height: 8px;
+ border-radius: 20px;
+ background-color: #e1e1e1;
+ // cursor: pointer;
+ padding: 0 !important;
+ margin: 0 !important;
+
+ &--active {
+ background: #f00069;
+ width: 24px;
+ border-radius: 20px;
+ }
+
+ // &:hover {
+ // background-color: #aaa;
+ // }
+}
+
+.ugb-tour-modal__glow {
+ position: absolute;
+ z-index: 1000001;
+ box-shadow: 0 0 20px #f00069;
+ border-radius: 8px;
+ pointer-events: none;
+ animation: tour-modal-glow 0.7s infinite alternate;
+ mix-blend-mode: multiply;
+ will-change: transform, box-shadow;
+ transition: opacity 0.2s ease-in-out;
+ opacity: 1;
+ &.ugb-tour-modal__glow--hidden {
+ opacity: 0;
+ }
+}
+
+.ugb-tour-modal__glow--medium,
+.ugb-tour-modal__glow--large {
+ animation: tour-modal-glow-small 0.7s infinite alternate;
+}
+
+// Animation keyframes to grow the box-shadow like it's glowing
+@keyframes tour-modal-glow {
+ 0% {
+ box-shadow: 0 0 20px #ff2283, 0 0 5px #f00069;
+ transform: scaleX(1) scaleY(1);
+ }
+ 100% {
+ box-shadow: 0 0 50px #ff2283, 0 0 5px #f00069;
+ transform: scaleX(1.05) scaleY(1.12);
+ }
+}
+
+// Animation keyframes for small glow
+@keyframes tour-modal-glow-small {
+ 0% {
+ box-shadow: 0 0 20px #ff2283, 0 0 5px #f00069;
+ transform: scaleX(1) scaleY(1);
+ }
+ 100% {
+ box-shadow: 0 0 50px #ff2283, 0 0 5px #f00069;
+ transform: scaleX(1.02) scaleY(1.02);
+ }
+}
diff --git a/src/components/guided-modal-tour/index.js b/src/components/guided-modal-tour/index.js
new file mode 100644
index 0000000000..cb1226c6e7
--- /dev/null
+++ b/src/components/guided-modal-tour/index.js
@@ -0,0 +1,605 @@
+/**
+ * Internal dependencies
+ */
+import { TOUR_STEPS } from './tour-steps'
+import {
+ setActiveTour,
+ clearActiveTour,
+ isTourActive,
+ getActiveTourId,
+ addTourStateListener,
+} from './util'
+
+/**
+ * External dependencies
+ */
+import {
+ i18n,
+ guidedTourStates,
+} from 'stackable'
+import classNames from 'classnames'
+import confetti from 'canvas-confetti'
+
+/**
+ * WordPress dependencies
+ */
+import {
+ Modal, Flex, Button,
+} from '@wordpress/components'
+import { models } from '@wordpress/api'
+import { __ } from '@wordpress/i18n'
+import {
+ Icon, arrowRight, arrowLeft, info,
+} from '@wordpress/icons'
+import {
+ useEffect, useState, useCallback, useRef, useMemo,
+} from '@wordpress/element'
+
+const NOOP = () => {}
+
+// The main tour component.
+const GuidedModalTour = props => {
+ const {
+ tourId = '', // This is the ID of the tour, this will be used to store the tour state in the database and to get the steps.
+ } = props
+
+ // On mount, check if the tour has been completed, if so, don't show it.
+ const [ isDone, setIsDone ] = useState( guidedTourStates.includes( tourId ) )
+
+ // We need this to prevent the tour from being shown again if it's just completed.
+ const [ justCompleted, setJustCompleted ] = useState( false )
+
+ // Check if another tour is already active
+ const [ isAnotherTourActive, setIsAnotherTourActive ] = useState( isTourActive() && getActiveTourId() !== tourId )
+
+ // Listen for tour state changes
+ useEffect( () => {
+ const removeListener = addTourStateListener( activeId => {
+ setIsAnotherTourActive( activeId !== null && activeId !== tourId )
+ } )
+ return removeListener
+ }, [ tourId ] )
+
+ const {
+ steps = [],
+ condition = null,
+ hasConfetti = true,
+ initialize = NOOP,
+ } = TOUR_STEPS[ tourId ]
+
+ if ( justCompleted ) {
+ return null
+ }
+
+ // If another tour is already active, don't show this tour
+ if ( isAnotherTourActive ) {
+ return null
+ }
+
+ // If there is a condition, check if it's met, if not, don't show the tour.
+ // condition can be true, false, or null. true will show the tour (even if
+ // it's already done), false will not show the tour, null will show the tour
+ // only once (normal behavior).
+ const conditionResult = condition ? condition() : null
+ if ( conditionResult === false ) {
+ return null
+ } else if ( conditionResult === null ) {
+ if ( isDone ) {
+ return null
+ }
+ }
+
+ if ( ! steps.length ) {
+ return null
+ }
+
+ return {
+ setIsDone( true )
+ setJustCompleted( true )
+
+ // Clear the active tour
+ clearActiveTour()
+
+ // Update the stackable_guided_tour_states setting
+ if ( ! guidedTourStates.includes( tourId ) ) {
+ // eslint-disable-next-line camelcase
+ const settings = new models.Settings( { stackable_guided_tour_states: [ ...guidedTourStates, tourId ] } )
+ settings.save()
+ }
+
+ // Soft update the global variable to prevent the tour from being shown again.
+ guidedTourStates.push( tourId )
+
+ // Remove the "tour" GET parameter from the URL so conditions won't get triggered again.
+ const url = new URL( window.location.href )
+ url.searchParams.delete( 'tour' )
+ window.history.replaceState( null, '', url.toString() )
+ } }
+ />
+}
+
+const ModalTour = props => {
+ const {
+ tourId,
+ steps,
+ onClose = NOOP,
+ hasConfetti = true,
+ initialize = NOOP,
+ } = props
+
+ const [ currentStep, setCurrentStep ] = useState( 0 )
+ const [ isVisible, setIsVisible ] = useState( false )
+ const [ isVisibleDelayed, setIsVisibleDelayed ] = useState( false )
+ const [ forceRefresh, setForceRefresh ] = useState( 0 )
+ const [ isTransitioning, setIsTransitioning ] = useState( false )
+ const [ direction, setDirection ] = useState( 'forward' )
+ const modalRef = useRef( null )
+ const glowElementRef = useRef( null )
+
+ const {
+ title,
+ description,
+ help = null, // If provided, a help text will be shown below the description.
+ ctaLabel = null, // If provided, a button will be shown with this label.
+ ctaOnClick = NOOP, // This will be called when the button is clicked, we will move to the next step after.
+ size = 'small', // Size of the modal. Can be 'small', 'medium', 'large'.
+ anchor = null, // This is a selector for the element to anchor the modal to. Defaults to middle of the screen.
+ position = 'center', // This is the position to place the modal relative to the anchor. Can be 'left', 'right', 'top', 'bottom', 'center'.
+ offsetX = '0px', // This is the X offset of the modal relative to the anchor.
+ offsetY = '0px', // This is the Y offset of the modal relative to the anchor.
+ showNext = true, // If true, a "Next" button will be shown.
+ nextEventTarget = null, // If provided, this is a selector for the element to trigger the next event if there is one.
+ nextEvent = 'click', // This is the event to listen for to trigger the next step.
+ glowTarget = null, // If provided, this is a selector for the element to glow when the step is active.
+ // eslint-disable-next-line no-unused-vars
+ preStep = NOOP, // If provided, this is a function to run before the step is shown.
+ // eslint-disable-next-line no-unused-vars
+ postStep = NOOP, // If provided, this is a function to run after the step is shown.
+ skipIf = NOOP, // If provided, this is a function to check if the step should be skipped.
+ } = steps[ currentStep ]
+
+ useEffect( () => {
+ setTimeout( () => {
+ initialize()
+ }, 50 )
+ }, [ initialize ] )
+
+ // Set active tour when modal becomes visible
+ useEffect( () => {
+ if ( isVisible ) {
+ setActiveTour( tourId )
+ }
+ }, [ isVisible, tourId ] )
+
+ // Clear active tour when component unmounts
+ useEffect( () => {
+ return () => {
+ if ( getActiveTourId() === tourId ) {
+ clearActiveTour()
+ }
+ }
+ }, [ tourId ] )
+
+ // While the modal is visible, just keep on force refreshing the modal in an interval to make sure the modal is always in the correct position.
+ useEffect( () => {
+ let interval
+ if ( isVisible && ! isTransitioning ) {
+ interval = setInterval( () => {
+ setForceRefresh( forceRefresh => forceRefresh + 1 )
+ }, 500 )
+ }
+ return () => clearInterval( interval )
+ }, [ isVisible, isVisibleDelayed, isTransitioning ] )
+
+ // Create a stable function reference for the event listener
+ const handleNextEvent = useCallback( () => {
+ // Hide modal during transition
+ setIsVisible( false )
+ setIsVisibleDelayed( false )
+ setIsTransitioning( true )
+ setDirection( 'forward' )
+
+ // If at the last step, just close
+ if ( currentStep === steps.length - 1 ) {
+ steps[ currentStep ]?.postStep?.( currentStep )
+ if ( hasConfetti ) {
+ throwConfetti()
+ }
+ onClose()
+ return
+ }
+
+ setTimeout( () => {
+ setCurrentStep( currentStep => {
+ setTimeout( () => {
+ steps[ currentStep ]?.postStep?.( currentStep )
+ }, 50 )
+ const nextStep = currentStep + 1
+ setTimeout( () => {
+ steps[ nextStep ]?.preStep?.( nextStep )
+ }, 50 )
+ return nextStep
+ } )
+
+ // Show modal after 200ms delay
+ setTimeout( () => {
+ setIsVisible( true )
+ setTimeout( () => {
+ setIsVisibleDelayed( true )
+ setIsTransitioning( false )
+ }, 150 )
+ }, 200 )
+ }, 100 )
+
+ setTimeout( () => {
+ setForceRefresh( forceRefresh => forceRefresh + 1 )
+ }, 350 )
+ setTimeout( () => {
+ setForceRefresh( forceRefresh => forceRefresh + 1 )
+ }, 650 )
+ }, [ currentStep, steps, hasConfetti ] )
+
+ const handleBackEvent = useCallback( () => {
+ // Hide modal during transition
+ setIsVisible( false )
+ setIsVisibleDelayed( false )
+ setIsTransitioning( true )
+ setDirection( 'backward' )
+
+ setTimeout( () => {
+ setCurrentStep( currentStep => {
+ // steps[ currentStep ]?.postStep?.( currentStep )
+ const nextStep = currentStep - 1
+ steps[ nextStep ]?.preStep?.( nextStep )
+ return nextStep
+ } )
+
+ // Show modal after 200ms delay
+ setTimeout( () => {
+ setIsVisible( true )
+ setTimeout( () => {
+ setIsVisibleDelayed( true )
+ setIsTransitioning( false )
+ }, 150 )
+ }, 200 )
+ }, 100 )
+
+ setTimeout( () => {
+ setForceRefresh( forceRefresh => forceRefresh + 1 )
+ }, 350 )
+ setTimeout( () => {
+ setForceRefresh( forceRefresh => forceRefresh + 1 )
+ }, 650 )
+ }, [ currentStep, steps ] )
+
+ // If we just moved to this step, even before showing it check if we should skip it, if so, move to the next/prev step.
+ useEffect( () => {
+ if ( skipIf() ) {
+ if ( direction === 'forward' ) {
+ handleNextEvent()
+ } else {
+ handleBackEvent()
+ }
+ }
+ }, [ currentStep, direction ] )
+
+ // Show modal after 1 second delay
+ useEffect( () => {
+ const timer = setTimeout( () => {
+ setIsVisible( true )
+ setTimeout( () => {
+ setIsVisibleDelayed( true )
+ }, 150 )
+ }, 1050 )
+
+ return () => clearTimeout( timer )
+ }, [] )
+
+ useEffect( () => {
+ let clickListener = null
+
+ if ( nextEventTarget ) {
+ if ( nextEvent === 'click' || nextEvent === 'mousedown' || nextEvent === 'mouseup' ) {
+ clickListener = event => {
+ // Check if the event target matches the selector or is inside an element that matches
+ if (
+ event.target.matches( nextEventTarget ) ||
+ event.target.closest( nextEventTarget )
+ ) {
+ handleNextEvent()
+ }
+ }
+ // Use ownerDocument instead of document directly
+ const doc = modalRef.current?.ownerDocument || document
+ doc.addEventListener( nextEvent, clickListener )
+ } else {
+ const elements = document.querySelectorAll( nextEventTarget )
+ for ( let i = 0; i < elements.length; i++ ) {
+ elements[ i ].addEventListener( nextEvent, handleNextEvent )
+ }
+ }
+ }
+
+ return () => {
+ if ( nextEventTarget ) {
+ if ( ( nextEvent === 'click' || nextEvent === 'mousedown' || nextEvent === 'mouseup' ) && clickListener ) {
+ // Use ownerDocument instead of document directly
+ const doc = modalRef.current?.ownerDocument || document
+ doc.removeEventListener( nextEvent, clickListener )
+ } else {
+ const elements = document.querySelectorAll( nextEventTarget )
+ for ( let i = 0; i < elements.length; i++ ) {
+ elements[ i ].removeEventListener( nextEvent, handleNextEvent )
+ }
+ }
+ }
+ }
+ }, [ currentStep, nextEventTarget, nextEvent, handleNextEvent ] )
+
+ // Create the glow element while this component is mounted.
+ useEffect( () => {
+ // Create the element.
+ const element = document.createElement( 'div' )
+ element.className = `ugb-tour-modal__glow ugb-tour-modal__glow--hidden`
+ document.body.appendChild( element )
+
+ // Keep track of the element.
+ glowElementRef.current = element
+
+ return () => {
+ glowElementRef.current = null
+ element.remove()
+ }
+ }, [] )
+
+ // These are the X and Y offsets of the modal relative to the anchor. This will be
+ const [ modalOffsetX, modalOffsetY ] = useMemo( () => {
+ if ( ! modalRef.current ) {
+ return [ '', '' ] // This is for the entire screen.
+ }
+
+ const modalRect = modalRef.current.querySelector( '.ugb-tour-modal' ).getBoundingClientRect()
+ const defaultOffset = [ `${ ( window.innerWidth / 2 ) - ( modalRect.width / 2 ) }px`, `${ ( window.innerHeight / 2 ) - ( modalRect.height / 2 ) }px` ]
+
+ if ( ! anchor ) {
+ return defaultOffset // This is for the entire screen.
+ }
+
+ // Based on the anchor and position, calculate the X and Y offsets of the modal relative to the anchor.
+ // We have the modalRef.current which we can use to get the modal's bounding client rect.
+ const anchorRect = document.querySelector( anchor )?.getBoundingClientRect()
+
+ if ( ! anchorRect ) {
+ return defaultOffset
+ }
+
+ switch ( position ) {
+ case 'left':
+ // Left, middle
+ return [ `${ anchorRect.left - modalRect.width - 16 }px`, `${ anchorRect.top + ( anchorRect.height / 2 ) - ( modalRect.height / 2 ) }px` ]
+ case 'left-top':
+ return [ `${ anchorRect.left - modalRect.width - 16 }px`, `${ anchorRect.top + 16 }px` ]
+ case 'left-bottom':
+ return [ `${ anchorRect.left - modalRect.width - 16 }px`, `${ anchorRect.bottom - modalRect.height - 16 }px` ]
+ case 'right':
+ // Right, middle
+ return [ `${ anchorRect.right + 16 }px`, `${ anchorRect.top + ( anchorRect.height / 2 ) - ( modalRect.height / 2 ) }px` ]
+ case 'right-top':
+ return [ `${ anchorRect.right + 16 }px`, `${ anchorRect.top + 16 }px` ]
+ case 'right-bottom':
+ return [ `${ anchorRect.right + 16 }px`, `${ anchorRect.bottom - modalRect.height - 16 }px` ]
+ case 'top':
+ // Center, top
+ return [ `${ anchorRect.left + ( anchorRect.width / 2 ) - ( modalRect.width / 2 ) }px`, `${ anchorRect.top - modalRect.height - 16 }px` ]
+ case 'top-left':
+ return [ `${ anchorRect.left + 16 }px`, `${ anchorRect.top - modalRect.height - 16 }px` ]
+ case 'top-right':
+ return [ `${ anchorRect.right - modalRect.width - 16 }px`, `${ anchorRect.top - modalRect.height - 16 }px` ]
+ case 'bottom':
+ // Center, bottom
+ return [ `${ anchorRect.left + ( anchorRect.width / 2 ) - ( modalRect.width / 2 ) }px`, `${ anchorRect.bottom + 16 }px` ]
+ case 'bottom-left':
+ return [ `${ anchorRect.left + 16 }px`, `${ anchorRect.bottom + 16 }px` ]
+ case 'bottom-right':
+ return [ `${ anchorRect.right - modalRect.width - 16 }px`, `${ anchorRect.bottom + 16 }px` ]
+ case 'center':
+ return [ `${ anchorRect.left + ( anchorRect.width / 2 ) - ( modalRect.width / 2 ) }px`, `${ anchorRect.top + ( anchorRect.height / 2 ) - ( modalRect.height / 2 ) }px` ]
+ case 'center-top':
+ return [ `${ anchorRect.left + ( anchorRect.width / 2 ) - ( modalRect.width / 2 ) }px`, `${ anchorRect.top + 16 }px` ]
+ case 'center-bottom':
+ return [ `${ anchorRect.left + ( anchorRect.width / 2 ) - ( modalRect.width / 2 ) }px`, `${ anchorRect.bottom - modalRect.height - 16 }px` ]
+ default:
+ return defaultOffset
+ }
+ }, [ anchor, position, modalRef.current, isVisible, isVisibleDelayed, isTransitioning, forceRefresh ] )
+
+ // If we have a glow target, create a new element in the body, placed on the top of the target, below the modal.
+ useEffect( () => {
+ if ( glowTarget && isVisibleDelayed ) {
+ // Get the top, left, width, and height of the target.
+ const target = document.querySelector( glowTarget )
+ if ( target ) {
+ const targetRect = target.getBoundingClientRect()
+
+ // Estimate the size of the glow target based on the size of the target.
+ const glowTargetSize = targetRect.width > 300 || targetRect.height > 200 ? 'large'
+ : targetRect.width > 300 || targetRect.height > 100 ? 'medium'
+ : 'small'
+
+ // Create the element.
+ if ( glowElementRef.current ) {
+ glowElementRef.current.className = `ugb-tour-modal__glow ugb-tour-modal__glow--${ glowTargetSize }`
+ glowElementRef.current.style.top = `${ targetRect.top - 8 }px`
+ glowElementRef.current.style.left = `${ targetRect.left - 8 }px`
+ glowElementRef.current.style.width = `${ targetRect.width + 16 }px`
+ glowElementRef.current.style.height = `${ targetRect.height + 16 }px`
+ }
+ }
+ } else if ( glowElementRef.current ) {
+ glowElementRef.current.className = `ugb-tour-modal__glow ugb-tour-modal__glow--hidden`
+ }
+ }, [ glowTarget, currentStep, isVisible, isVisibleDelayed, isTransitioning, forceRefresh ] )
+
+ // When unmounted, do not call onClose. So we need to do this handler on our own.
+ useEffect( () => {
+ const handleHeaderClick = () => {
+ onClose()
+ }
+ if ( modalRef.current ) {
+ modalRef.current.querySelector( '.components-modal__header' ).addEventListener( 'click', handleHeaderClick )
+ }
+ return () => {
+ if ( modalRef.current ) {
+ modalRef.current.querySelector( '.components-modal__header' ).removeEventListener( 'click', handleHeaderClick )
+ }
+ }
+ }, [ modalRef.current, onClose ] )
+
+ if ( ! isVisible ) {
+ return null
+ }
+
+ return (
+
+
+ { description }
+ { help && (
+
+ )
+}
+
+export default GuidedModalTour
diff --git a/src/components/guided-modal-tour/tour-steps.js b/src/components/guided-modal-tour/tour-steps.js
new file mode 100644
index 0000000000..bb5acc8516
--- /dev/null
+++ b/src/components/guided-modal-tour/tour-steps.js
@@ -0,0 +1,6 @@
+// Import all tour files from the tours directory
+import { tours } from './tours/index.js'
+
+export const TOUR_STEPS = {
+ ...tours,
+}
diff --git a/src/components/guided-modal-tour/tours/README.md b/src/components/guided-modal-tour/tours/README.md
new file mode 100644
index 0000000000..eda38d8ae2
--- /dev/null
+++ b/src/components/guided-modal-tour/tours/README.md
@@ -0,0 +1,159 @@
+# Guided Modal Tour Documentation
+
+This directory contains individual tour configurations for the Stackable guided modal tour system. Each tour is defined in its own JavaScript file and automatically imported into the main tour system.
+
+## How It Works
+
+The tour system uses `require.context()` to automatically discover and import all `.js` files in this directory. Each tour's ID (`tourId`) is derived from its filename in kebab-case (e.g., `design-library.js` becomes `design-library`). Each tour file should export a named export corresponding to the tour's purpose.
+
+## Tour Structure
+
+Each tour file should export an object with the following structure:
+
+```javascript
+import { __ } from '@wordpress/i18n'
+import { i18n } from 'stackable'
+import { createInterpolateElement } from '@wordpress/element'
+
+export const tourName = {
+ // Tour-level properties
+ hasConfetti: false,
+ condition: () => { /* condition logic */ },
+ initialize: () => { /* optional initialization */ },
+ steps: [
+ // Array of step objects
+ ]
+}
+```
+
+## Tour-Level Properties
+
+### `steps` (array)
+The array of step objects that define the tour flow.
+
+### `hasConfetti` (boolean)
+If `true`, confetti is shown on the last step. Default is `true`.
+
+### `condition` (function)
+A function that returns:
+- `true` - Show the tour (even if it's already been completed)
+- `false` - Do not show the tour
+- `null` - Show the tour only once (default behavior)
+
+### `initialize` (function, optional)
+A function called when the tour starts. Useful for setting up initial state or content.
+
+## Step Properties
+
+Each step in a tour is an object with the following possible properties:
+
+### `title` (string)
+The title text displayed at the top of the modal.
+
+### `description` (string|ReactNode)
+The main content or instructions for the step.
+
+### `help` (string|ReactNode, optional)
+If provided, a help text will be shown below the description.
+
+### `size` (string, optional)
+The size of the modal. Can be:
+- `'small'` (default)
+- `'medium'`
+- `'large'`
+
+### `anchor` (string, optional)
+A CSS selector for the element to which the modal should be anchored. If not provided, modal is centered.
+
+### `position` (string, optional)
+The position of the modal relative to the anchor. Can be:
+- `'left'`
+- `'right'`
+- `'top'`
+- `'bottom'`
+- `'center'` (default)
+
+### `offsetX` (number, optional)
+X-axis offset in pixels for fine-tuning the modal's position relative to the anchor.
+
+### `offsetY` (number, optional)
+Y-axis offset in pixels for fine-tuning the modal's position relative to the anchor.
+
+### `ctaLabel` (string, optional)
+If provided, a call-to-action button will be shown with this label.
+
+### `ctaOnClick` (function, optional)
+Function to call when the CTA button is clicked. The tour will move to the next step after this is called.
+
+### `showNext` (boolean, optional)
+If `true`, a "Next" button is shown. Default is `true`.
+
+### `nextEventTarget` (string, optional)
+A CSS selector for an element. If provided, the tour will wait for the specified event on this element before moving to the next step.
+
+### `nextEvent` (string, optional)
+The event name to listen for on `nextEventTarget` (e.g., 'click'). Default is 'click'.
+
+### `glowTarget` (string, optional)
+A CSS selector for an element to highlight/glow during this step.
+
+### `preStep` (function, optional)
+Function called before the step is displayed. Useful for setup or preparation.
+
+### `postStep` (function, optional)
+Function called after the step is completed. Useful for cleanup or triggering actions.
+
+### `skipIf` (function, optional)
+Function that returns `true` if this step should be skipped. Useful for conditional steps.
+
+## Example Tour
+
+```javascript
+import { __ } from '@wordpress/i18n'
+import { i18n } from 'stackable'
+import { createInterpolateElement } from '@wordpress/element'
+
+export const exampleTour = {
+ hasConfetti: false,
+ condition: () => {
+ // Only show if there's a specific URL parameter
+ return window?.location?.search?.includes('tour=example') ? true : null
+ },
+ steps: [
+ {
+ title: 'Welcome',
+ description: 'This is the first step.',
+ size: 'medium',
+ anchor: '.my-element',
+ position: 'bottom',
+ offsetX: 10,
+ offsetY: 0,
+ ctaLabel: 'Get Started',
+ ctaOnClick: () => { console.log('CTA clicked!') },
+ showNext: false,
+ nextEventTarget: '.my-button',
+ nextEvent: 'click',
+ glowTarget: '.my-element',
+ },
+ {
+ title: 'Second Step',
+ description: 'This is the second step.',
+ help: createInterpolateElement(
+ 'Click the Continue button to proceed.',
+ { strong: }
+ ),
+ anchor: '.another-element',
+ position: 'right',
+ nextEventTarget: '.continue-button',
+ glowTarget: '.another-element',
+ }
+ ]
+}
+```
+
+## Creating New Tours
+
+1. Create a new `.js` file in this directory
+2. Import the necessary dependencies (`__`, `i18n`, `createInterpolateElement`)
+3. Export a named export with your tour configuration
+4. The tour will be automatically discovered and included in the tour system
diff --git a/src/components/guided-modal-tour/tours/blocks.js b/src/components/guided-modal-tour/tours/blocks.js
new file mode 100644
index 0000000000..dd4d2b74b7
--- /dev/null
+++ b/src/components/guided-modal-tour/tours/blocks.js
@@ -0,0 +1,289 @@
+import { __ } from '@wordpress/i18n'
+import { i18n } from 'stackable'
+import { dispatch, select } from '@wordpress/data'
+import { createInterpolateElement } from '@wordpress/element'
+
+export const blocks = {
+ condition: () => { // If provided, true will show the tour (even if it's already done), false will not show the tour, null will show the tour only once.
+ // Force show the tour if there is a GET parameter tour=blocks
+ return window?.location?.search?.includes( 'tour=blocks' )
+ },
+ initialize: () => {
+ // Add some default content that we will select
+
+ const blockObject = wp.blocks.createBlock(
+ 'stackable/columns',
+ {
+ uniqueId: '1dbe04e',
+ blockMargin: { bottom: '' },
+ align: 'full',
+ },
+ [
+ wp.blocks.createBlock(
+ 'stackable/column',
+ {
+ uniqueId: 'f957abc',
+ hasContainer: true,
+ columnSpacing: {
+ top: '', right: '', bottom: '', left: '',
+ },
+ },
+ [
+ wp.blocks.createBlock(
+ 'stackable/heading',
+ {
+ uniqueId: 'a8ebea7',
+ // Retain our text
+ text: 'Explore the World with Us',
+ textTag: 'h2',
+ }
+ ),
+ wp.blocks.createBlock(
+ 'stackable/text',
+ {
+ uniqueId: '57e76a1',
+ // Retain our text
+ text: 'Discover breathtaking destinations, plan your next adventure, and make unforgettable memories with our travel guides and tips.',
+ }
+ ),
+ wp.blocks.createBlock(
+ 'stackable/button-group',
+ { uniqueId: 'e063798' },
+ [
+ wp.blocks.createBlock(
+ 'stackable/button',
+ {
+ uniqueId: '5d04ca8',
+ // Retain our text
+ text: 'Start your journey',
+ url: '',
+ }
+ ),
+ ]
+ ),
+ ]
+ ),
+ wp.blocks.createBlock(
+ 'stackable/column',
+ {
+ uniqueId: '3dcffca',
+ hasContainer: true,
+ containerBackgroundMediaExternalUrl: 'https://picsum.photos/id/177/500/700.jpg',
+ containerHeight: '500',
+ },
+ []
+ ),
+ ]
+ )
+
+ // Delete all blocks
+ // const allBlocks = select( 'core/block-editor' ).getBlocks()
+ // dispatch( 'core/block-editor' ).removeBlocks( allBlocks.map( block => block.clientId ) )
+
+ // Insert our block
+ dispatch( 'core/block-editor' ).insertBlocks( [ blockObject ], 0 )
+
+ // Select the inner columns block for the tour
+ dispatch( 'core/block-editor' ).selectBlock( blockObject.innerBlocks[ 0 ].clientId )
+ },
+ steps: [
+ {
+ title: '👋 ' + __( 'Welcome to Your Stackable Blocks', i18n ),
+ description: __( 'This inspector is contains all the settings for this block, let\'s explore it!', i18n ),
+ help: createInterpolateElement( __( 'If you\'re familiar with page builders, then you\'ll feel right at home.', i18n ), {
+ strong: ,
+ } ),
+ size: 'medium',
+ anchor: '.ugb--has-panel-tabs',
+ position: 'left',
+ glowTarget: '.ugb--has-panel-tabs',
+ preStep: () => {
+ // Open the inspector sidebar
+ dispatch( 'core/edit-post' ).openGeneralSidebar( 'edit-post/block' )
+ },
+ },
+ {
+ title: __( 'The Layout Tab', i18n ),
+ description: __( 'Stackable blocks normally have 3 tabs, each with different settings. The Layout Tab contains layout-related options like flex controls, spacing and margins.', i18n ),
+ help: createInterpolateElement( __( 'Open the Layout Tab to continue.', i18n ), {
+ strong: ,
+ } ),
+ anchor: '.edit-post-sidebar__panel-tab.ugb-tab--layout',
+ position: 'left',
+ glowTarget: '.edit-post-sidebar__panel-tab.ugb-tab--layout',
+ nextEventTarget: '.edit-post-sidebar__panel-tab.ugb-tab--layout',
+ preStep: () => {
+ // Open the inspector sidebar
+ dispatch( 'core/edit-post' ).openGeneralSidebar( 'edit-post/block' )
+
+ // Make sure the Inner Column is selected.
+ const block = select( 'core/block-editor' ).getSelectedBlock()
+ if ( block?.name !== 'stackable/column' ) {
+ // Look for the first "stackable/columns" block
+ const allBlocks = select( 'core/block-editor' ).getBlocks()
+ const columnsBlock = allBlocks.find( block => block.name === 'stackable/columns' )
+ if ( columnsBlock ) {
+ dispatch( 'core/block-editor' ).selectBlock( columnsBlock.innerBlocks[ 0 ].clientId )
+ }
+ }
+
+ setTimeout( () => {
+ // Click the tab
+ document.querySelector( '.edit-post-sidebar__panel-tab.ugb-tab--layout:not(.is-active)' )?.click()
+ }, 100 )
+ },
+ },
+ {
+ title: __( 'Try Changing Alignments', i18n ),
+ description: __( 'Let\'s try changing this option and see how it affects our block.', i18n ),
+ help: createInterpolateElement( __( 'Pick Center or End Column Alignment to continue.', i18n ), {
+ strong: ,
+ } ),
+ anchor: '.ugb-column-align-control',
+ position: 'left',
+ glowTarget: '.ugb-column-align-control',
+ nextEventTarget: '.ugb-column-align-control .stk-control-content button',
+ preStep: () => {
+ // Open the inspector sidebar
+ dispatch( 'core/edit-post' ).openGeneralSidebar( 'edit-post/block' )
+
+ // Make sure the Inner Column is selected.
+ const block = select( 'core/block-editor' ).getSelectedBlock()
+ if ( block?.name !== 'stackable/column' ) {
+ // Look for the first "stackable/columns" block
+ const allBlocks = select( 'core/block-editor' ).getBlocks()
+ const columnsBlock = allBlocks.find( block => block.name === 'stackable/columns' )
+ if ( columnsBlock ) {
+ dispatch( 'core/block-editor' ).selectBlock( columnsBlock.innerBlocks[ 0 ].clientId )
+ }
+ }
+
+ setTimeout( () => {
+ // Click the tab
+ document.querySelector( '.edit-post-sidebar__panel-tab.ugb-tab--layout:not(.is-active)' )?.click()
+ }, 100 )
+
+ setTimeout( () => {
+ document.querySelector( '.ugb-panel--layout:not(.is-opened)' )?.click()
+ }, 150 )
+ },
+ },
+ {
+ title: __( 'The Style Tab', i18n ),
+ description: __( 'Let\'s try to add a background to the main Columns block. The Style Tab contains style-related options like backgrounds, color, borders and typography.', i18n ),
+ help: createInterpolateElement( __( 'Click the Style Tab to continue.', i18n ), {
+ strong: ,
+ } ),
+ anchor: '.edit-post-sidebar__panel-tab.ugb-tab--style',
+ position: 'left',
+ glowTarget: '.edit-post-sidebar__panel-tab.ugb-tab--style',
+ nextEventTarget: '.edit-post-sidebar__panel-tab.ugb-tab--style',
+ preStep: () => {
+ // Open the inspector sidebar
+ dispatch( 'core/edit-post' ).openGeneralSidebar( 'edit-post/block' )
+
+ // Look for the first "stackable/columns" block
+ const allBlocks = select( 'core/block-editor' ).getBlocks()
+ const columnsBlock = allBlocks.find( block => block.name === 'stackable/columns' )
+ if ( columnsBlock ) {
+ dispatch( 'core/block-editor' ).selectBlock( columnsBlock.clientId )
+ }
+ },
+ postStep: () => {
+ // Click the tab
+ document.querySelector( '.edit-post-sidebar__panel-tab.ugb-tab--style:not(.is-active)' )?.click()
+ },
+ },
+ {
+ title: __( 'Try Enabling Backgrounds', i18n ),
+ description: __( 'Let\'s try turning on the background for our section and see how it affects our block.', i18n ),
+ help: createInterpolateElement( __( 'Toggle ON the Background to continue.', i18n ), {
+ strong: ,
+ } ),
+ anchor: '.ugb-block-background-panel .components-panel__body-title',
+ position: 'left',
+ glowTarget: '.ugb-block-background-panel .components-panel__body-title',
+ nextEventTarget: '.ugb-block-background-panel .components-panel__body-title input[type="checkbox"]',
+ nextEvent: 'mousedown',
+ preStep: () => {
+ // Open the inspector sidebar
+ dispatch( 'core/edit-post' ).openGeneralSidebar( 'edit-post/block' )
+
+ // Look for the first "stackable/columns" block
+ const allBlocks = select( 'core/block-editor' ).getBlocks()
+ const columnsBlock = allBlocks.find( block => block.name === 'stackable/columns' )
+ if ( columnsBlock ) {
+ dispatch( 'core/block-editor' ).selectBlock( columnsBlock.clientId )
+ }
+
+ setTimeout( () => {
+ // Click the tab
+ document.querySelector( '.edit-post-sidebar__panel-tab.ugb-tab--style:not(.is-active)' )?.click()
+ }, 100 )
+ },
+ // postStep: () => {
+ // setTimeout( () => {
+ // const checkbox = document.querySelector( '.ugb-block-background-panel .components-panel__body-title input[type="checkbox"]' )
+ // if ( checkbox && checkbox.value !== 'on' ) {
+ // checkbox.click()
+ // }
+ // }, 100 )
+ // },
+ },
+ {
+ title: __( 'The Advanced Tab', i18n ),
+ description: __( 'Lastly, the Advanced Tab contains all other options like z-index, transforms, conditional display and class names.', i18n ),
+ help: createInterpolateElement( __( 'Click the Advanced Tab to continue.', i18n ), {
+ strong: ,
+ } ),
+ anchor: '.edit-post-sidebar__panel-tab.ugb-tab--advanced',
+ position: 'left',
+ glowTarget: '.edit-post-sidebar__panel-tab.ugb-tab--advanced',
+ nextEventTarget: '.edit-post-sidebar__panel-tab.ugb-tab--advanced',
+ preStep: () => {
+ // Open the inspector sidebar
+ dispatch( 'core/edit-post' ).openGeneralSidebar( 'edit-post/block' )
+
+ // Look for the first "stackable/columns" block
+ const allBlocks = select( 'core/block-editor' ).getBlocks()
+ const columnsBlock = allBlocks.find( block => block.name === 'stackable/columns' )
+ if ( columnsBlock ) {
+ dispatch( 'core/block-editor' ).selectBlock( columnsBlock.clientId )
+ }
+ },
+ postStep: () => {
+ // Click the tab
+ document.querySelector( '.edit-post-sidebar__panel-tab.ugb-tab--advanced:not(.is-active)' )?.click()
+ },
+ },
+ {
+ title: __( 'Consistent Options Everywhere', i18n ),
+ description: __( 'Once you get the hang of these settings, you\'ll spot them in almost every Stackable block. This makes it easy and familiar to build any design you want.', i18n ),
+ anchor: '.ugb--has-panel-tabs',
+ size: 'medium',
+ position: 'left',
+ preStep: () => {
+ // Open the inspector sidebar
+ dispatch( 'core/edit-post' ).openGeneralSidebar( 'edit-post/block' )
+
+ // Look for the first "stackable/columns" block
+ const allBlocks = select( 'core/block-editor' ).getBlocks()
+ const columnsBlock = allBlocks.find( block => block.name === 'stackable/columns' )
+ if ( columnsBlock ) {
+ dispatch( 'core/block-editor' ).selectBlock( columnsBlock.clientId )
+ }
+ },
+ },
+ {
+ title: __( 'One Last Thing…', i18n ),
+ description: createInterpolateElement( __( 'You can also check the Stackable Design System to globally style all blocks. This saves a ton of time!', i18n ), {
+ strong: ,
+ } ),
+ anchor: '[aria-controls="stackable-global-settings:sidebar"]',
+ position: 'left-top',
+ offsetY: '-30px',
+ offsetX: '-8px',
+ glowTarget: '[aria-controls="stackable-global-settings:sidebar"]',
+ },
+ ],
+}
diff --git a/src/components/guided-modal-tour/tours/design-library.js b/src/components/guided-modal-tour/tours/design-library.js
new file mode 100644
index 0000000000..7387f83961
--- /dev/null
+++ b/src/components/guided-modal-tour/tours/design-library.js
@@ -0,0 +1,125 @@
+import { __ } from '@wordpress/i18n'
+import { i18n } from 'stackable'
+import { createInterpolateElement } from '@wordpress/element'
+
+export const designLibrary = {
+ condition: () => { // If provided, true will show the tour (even if it's already done), false will not show the tour, null will show the tour only once.
+ // Force show the tour if there is a GET parameter tour=design-library
+ return window?.location?.search?.includes( 'tour=design-library' ) ? true : null
+ },
+ initialize: () => {
+ // Make sure the patterns tab is selected
+ document.querySelector( '.ugb-modal-design-library button[value="patterns"]:not(.is-primary)' )?.click()
+ },
+ steps: [
+ {
+ title: '👋 ' + __( 'Welcome to Your Design Library', i18n ),
+ description: __( 'These are hundreds of pre-built designs that are style-matched to your block theme. You can insert one or more patterns to quickly build your page.', i18n ),
+ help: createInterpolateElement( __( 'Pick one of the designs to continue.', i18n ), {
+ strong: ,
+ } ),
+ size: 'medium',
+ nextEventTarget: '.ugb-design-library-item',
+ nextEvent: 'mouseup',
+ offsetX: '-400px',
+ postStep: () => {
+ // Make sure the first one (or at least there is one) that's toggled
+ if ( ! document.querySelector( '.ugb-design-library-item.ugb--is-toggled' ) ) {
+ document.querySelector( '.ugb-design-library-item' )?.click()
+ }
+ },
+ },
+ {
+ title: __( 'Pick Styling Options', i18n ),
+ description: __( 'Optionally, you can turn on backgrounds, change color schemes, to customize the library in real-time.', i18n ),
+ help: createInterpolateElement( __( 'Toggle the Section Background to continue.', i18n ), {
+ strong: ,
+ } ),
+ anchor: '.ugb-modal-design-library__enable-background',
+ position: 'right',
+ nextEventTarget: '.ugb-modal-design-library__enable-background',
+ glowTarget: '.ugb-modal-design-library__enable-background',
+ postStep: () => {
+ const el = document.querySelector( '.ugb-modal-design-library__enable-background input' )
+ // If the input is not checked, click the button.
+ if ( el && ! el.checked ) {
+ el.click()
+ }
+ },
+ },
+ {
+ title: __( 'Change Color Schemes', i18n ),
+ description: __( 'Awesome! Your designs now have a background. Try out the available color schemes below. You can also create your own later!', i18n ),
+ help: createInterpolateElement( __( 'Pick a Color Scheme to continue.', i18n ), {
+ strong: ,
+ } ),
+ anchor: '.ugb-design-library__color-scheme-popover',
+ position: 'top',
+ nextEventTarget: '.ugb-design-library__color-scheme-popover .ugb-modal-design-library__stk-color-scheme',
+ glowTarget: '.ugb-design-library__color-scheme-popover .ugb-modal-design-library__stk-color-scheme:last-of-type',
+ preStep: () => {
+ // Let's make sure the background scheme is open.
+ if ( ! document.querySelector( '.ugb-design-library__color-scheme-popover' ) ) {
+ document.querySelector( '.ugb-modal-design-library__background-scheme .ugb-modal-design-library__stk-color-scheme' )?.click()
+ }
+ },
+ postStep: () => {
+ document.querySelector( '.ugb-design-library__color-scheme-popover .ugb-modal-design-library__stk-color-scheme:last-of-type' )?.click()
+ document.querySelector( '.ugb-modal-design-library__color-scheme-close-button' )?.click()
+ },
+ },
+ {
+ title: __( 'Patterns and Full-Pages', i18n ),
+ description: __( 'Great! Your entire library is now styled. Aside from patterns, Stackable also provides you with full-page layouts.', i18n ),
+ help: createInterpolateElement( __( 'Click the Next to continue.', i18n ), {
+ strong: ,
+ } ),
+ anchor: '.stk-design-library-tabs .components-button-group',
+ position: 'bottom',
+ nextEventTarget: '.stk-design-library-tabs .components-button-group',
+ glowTarget: '.stk-design-library-tabs .components-button-group',
+ preStep: () => {
+ // Disable for now the pages tab
+ const pagesButton = document.querySelector( '.stk-design-library-tabs button[value="pages"]' )
+ if ( pagesButton ) {
+ pagesButton.disabled = true
+ }
+ },
+ postStep: () => {
+ // Enable the pages tab
+ const pagesButton = document.querySelector( '.stk-design-library-tabs button[value="pages"]' )
+ if ( pagesButton ) {
+ pagesButton.disabled = false
+ }
+ },
+ },
+ {
+ title: __( 'Let\'s Insert Our Pattern', i18n ),
+ description: __( 'Now let\'s insert our pattern into our page.', i18n ),
+ help: createInterpolateElement( __( 'Click on Add Designs to continue.', i18n ), {
+ strong: ,
+ } ),
+ anchor: '.ugb-modal-design-library__add-multi',
+ position: 'top-right',
+ nextEventTarget: '.ugb-modal-design-library__add-multi',
+ glowTarget: '.ugb-modal-design-library__add-multi',
+ preStep: () => {
+ // Make sure the patterns tab is selected
+ document.querySelector( '.ugb-modal-design-library button[value="patterns"]:not(.is-primary)' )?.click()
+
+ // Make sure the first one (or at least there is one) that's toggled
+ if ( ! document.querySelector( '.ugb-design-library-item.ugb--is-toggled' ) ) {
+ document.querySelector( '.ugb-design-library-item' )?.click()
+ }
+ },
+ postStep: () => {
+ setTimeout( () => {
+ // If the design library is still open, click the add button.
+ if ( document.querySelector( '.ugb-modal-design-library' ) ) {
+ document.querySelector( '.ugb-modal-design-library__add-multi' )?.click()
+ }
+ }, 100 )
+ },
+ },
+ ],
+}
diff --git a/src/components/guided-modal-tour/tours/design-system-picker.js b/src/components/guided-modal-tour/tours/design-system-picker.js
new file mode 100644
index 0000000000..43e1746f95
--- /dev/null
+++ b/src/components/guided-modal-tour/tours/design-system-picker.js
@@ -0,0 +1,15 @@
+import { __ } from '@wordpress/i18n'
+import { i18n } from 'stackable'
+
+export const designSystemPicker = {
+ condition: () => { // If provided, true will show the tour (even if it's already done), false will not show the tour, null will show the tour only once.
+ // Force show the tour if there is a GET parameter tour=design-system-picker
+ return window?.location?.search?.includes( 'tour=design-system-picker' ) ? true : null
+ },
+ steps: [
+ {
+ title: '👋 ' + __( 'Welcome to The Design System Picker', i18n ),
+ description: '', // Not yet available.
+ },
+ ],
+}
diff --git a/src/components/guided-modal-tour/tours/design-system.js b/src/components/guided-modal-tour/tours/design-system.js
new file mode 100644
index 0000000000..5ab781bfe0
--- /dev/null
+++ b/src/components/guided-modal-tour/tours/design-system.js
@@ -0,0 +1,137 @@
+import { __ } from '@wordpress/i18n'
+import { i18n } from 'stackable'
+import { createInterpolateElement } from '@wordpress/element'
+
+export const designSystem = {
+ condition: () => { // If provided, true will show the tour (even if it's already done), false will not show the tour, null will show the tour only once.
+ // Force show the tour if there is a GET parameter tour=design-system
+ return window?.location?.search?.includes( 'tour=design-system' ) ? true : null
+ },
+ steps: [
+ {
+ title: '👋 ' + __( 'Welcome to Your Design System', i18n ),
+ description: __( 'Design once, apply everywhere! Set global styles so every block across your site looks and feels unified.', i18n ),
+ size: 'medium',
+ anchor: '.interface-interface-skeleton__sidebar',
+ position: 'left-top',
+ // glowTarget: '.interface-interface-skeleton__sidebar',
+ },
+ {
+ title: __( 'Use The Style Guide', i18n ),
+ description: __( 'You can use the Style Guide to see how your complete design system looks.', i18n ),
+ help: createInterpolateElement( __( 'Click the Preview Design System button to continue.', i18n ), {
+ strong: ,
+ } ),
+ anchor: '.ugb-global-settings__preview-button',
+ position: 'left',
+ nextEventTarget: '.ugb-global-settings__preview-button',
+ glowTarget: '.ugb-global-settings__preview-button',
+ postStep: () => {
+ // Open the style guide if it's not open.
+ if ( ! document.querySelector( '.ugb-style-guide-popover' ) ) {
+ document.querySelector( '.ugb-global-settings__preview-button' )?.click()
+ }
+ },
+ },
+ {
+ title: __( 'This is Your Style Guide', i18n ),
+ description: __( 'This Style Guide shows a live preview of your entire design system showing how the different design elements look. This updates based on your current settings.', i18n ),
+ help: __( 'Scroll down to explore', i18n ),
+ size: 'medium',
+ anchor: '.ugb-style-guide-popover > .components-popover__content',
+ position: 'center',
+ // glowTarget: '.interface-interface-skeleton__sidebar',
+ },
+ {
+ title: __( 'Customize Your Design System', i18n ),
+ description: __( 'These settings are applied to all blocks across your entire site. They are used as defaults for your blocks, but you can override them on a per block basis.', i18n ),
+ help: createInterpolateElement( __( 'Open the Global Typography panel to continue.', i18n ), {
+ strong: ,
+ } ),
+ anchor: '.ugb-global-typography__panel .components-panel__body-title',
+ position: 'left',
+ glowTarget: '.ugb-global-typography__panel .components-panel__body-toggle',
+ nextEventTarget: '.ugb-global-typography__panel .components-panel__body-toggle',
+ skipIf: () => {
+ return document.querySelector( '.ugb-global-typography__panel' )?.classList.contains( 'is-opened' )
+ },
+ postStep: () => {
+ // Open the typography panel if it's not open.
+ if ( document.querySelector( '.ugb-global-typography__panel:not(.is-opened)' ) ) {
+ document.querySelector( '.ugb-global-typography__panel .components-panel__body-toggle' )?.click()
+ }
+ },
+ },
+ {
+ title: __( 'Try Changing Your Font Pair', i18n ),
+ description: __( 'Try changing the font pair to see how it looks in the Style Guide.', i18n ),
+ help: createInterpolateElement( __( 'Pick another Preset Font Pair to continue.', i18n ), {
+ strong: ,
+ } ),
+ anchor: '.ugb-global-settings-font-pair-control:not(.ugb-global-settings-font-pair__selected)',
+ position: 'left',
+ glowTarget: '.ugb-global-settings-font-pair-control:not(.ugb-global-settings-font-pair__selected)',
+ nextEventTarget: '.ugb-global-settings-font-pair__container [role="button"]',
+ preStep: () => {
+ // Make sure that the typography panel is open.
+ if ( document.querySelector( '.ugb-global-typography__panel:not(.is-opened)' ) ) {
+ document.querySelector( '.ugb-global-typography__panel .components-panel__body-title' )?.click()
+ }
+ const el = document.querySelector( '.ugb-global-settings-font-pair__container' )
+ if ( el ) {
+ // Scroll this to the top.
+ el.scrollTo( {
+ top: 0,
+ behavior: 'auto',
+ } )
+ }
+ },
+ },
+ {
+ title: __( 'Whoa! Your Site Updated', i18n ),
+ description: __( 'Did you see that? Your site has been updated with the new font pair. You can see the changes in the Style Guide as well!', i18n ),
+ help: __( 'Other parts below the style guide also updated!', i18n ),
+ anchor: '.ugb-style-guide__color-container',
+ position: 'top',
+ glowTarget: '.ugb-style-guide__color-container',
+ preStep: () => {
+ const el = document.querySelector( '.ugb-style-guide-popover > .components-popover__content' )
+ // Scroll this to the top.
+ if ( el ) {
+ el.scrollTo( {
+ top: 0,
+ behavior: 'smooth',
+ } )
+ }
+ },
+ },
+ {
+ title: __( 'Share Your Style Guide', i18n ),
+ description: __( 'You can easily share your design system with others by exporting your Style Guide as an image. This is perfect for sharing with clients, teammates, or for documentation.', i18n ),
+ anchor: '.ugb-style-guide__print-button',
+ position: 'bottom',
+ glowTarget: '.ugb-style-guide__print-button',
+ preStep: () => {
+ const el = document.querySelector( '.ugb-style-guide-popover > .components-popover__content' )
+ // Scroll this to the top.
+ if ( el ) {
+ el.scrollTo( {
+ top: 0,
+ behavior: 'smooth',
+ } )
+ }
+ },
+ },
+ {
+ title: __( 'You Did It!', i18n ),
+ description: __( 'That\'s it for the tour! Click the X to close the Style Guide. Your new styles are now live on your site. 🎉', i18n ),
+ anchor: '.ugb-style-guide-popover__close-button',
+ position: 'bottom',
+ glowTarget: '.ugb-style-guide-popover__close-button',
+ nextEventTarget: '.ugb-style-guide-popover__close-button',
+ postStep: () => {
+ document.querySelector( '.ugb-style-guide-popover__close-button' )?.click()
+ },
+ },
+ ],
+}
diff --git a/src/components/guided-modal-tour/tours/editor.js b/src/components/guided-modal-tour/tours/editor.js
new file mode 100644
index 0000000000..1d6e2a68e6
--- /dev/null
+++ b/src/components/guided-modal-tour/tours/editor.js
@@ -0,0 +1,27 @@
+import { __ } from '@wordpress/i18n'
+import { i18n, guidedTourStates } from 'stackable'
+import { createInterpolateElement } from '@wordpress/element'
+
+export const editor = {
+ hasConfetti: false,
+ condition: () => { // If provided, true will show the tour (even if it's already done), false will not show the tour, null will show the tour only once.
+ // Do not show the tour if there is a GET parameter that shows another tour.
+ return window?.location?.search?.includes( 'tour=' ) ? false
+ : guidedTourStates.includes( 'design-library' ) ? false : null
+ },
+ steps: [
+ {
+ title: '👋 ' + __( 'Welcome to Stackable', i18n ),
+ description: __( 'We\'re excited to have you here. Let\'s get you started by opening the Design Library.', i18n ),
+ help: createInterpolateElement( __( 'Click the Design Library button to continue.', i18n ), {
+ strong: ,
+ } ),
+ // size: 'medium',
+ anchor: '.ugb-insert-library-button',
+ position: 'bottom',
+ nextEventTarget: '.ugb-insert-library-button',
+ glowTarget: '.ugb-insert-library-button',
+ showNext: false,
+ },
+ ],
+}
diff --git a/src/components/guided-modal-tour/tours/index.js b/src/components/guided-modal-tour/tours/index.js
new file mode 100644
index 0000000000..e47c7ab697
--- /dev/null
+++ b/src/components/guided-modal-tour/tours/index.js
@@ -0,0 +1,34 @@
+// This file automatically imports all tour files from the tours directory
+// and exports them as a single object for use in TOUR_STEPS
+
+// Dynamically import all tour files from the tours directory
+const tourContext = require.context( './', false, /\.js$/ )
+const tours = {}
+
+// Import all tour files and populate the tours object using the filename as the key
+tourContext.keys().forEach( fileName => {
+ // Skip this index.js file itself
+ if ( fileName === './index.js' ) {
+ return
+ }
+
+ // Import the tour module
+ const tourModule = tourContext( fileName )
+
+ // Use the filename (without extension) as the key
+ const tourName = fileName.replace( './', '' ).replace( '.js', '' )
+
+ // Prefer default export, fallback to first named export if available
+ if ( tourModule.default ) {
+ tours[ tourName ] = tourModule.default
+ } else {
+ // If no default export, use the first named export (if any)
+ const namedExports = Object.keys( tourModule ).filter( name => name !== 'default' )
+ if ( namedExports.length > 0 ) {
+ tours[ tourName ] = tourModule[ namedExports[ 0 ] ]
+ }
+ }
+} )
+
+// Export all tours as a single object
+export { tours }
diff --git a/src/components/guided-modal-tour/tours/site-kit.js b/src/components/guided-modal-tour/tours/site-kit.js
new file mode 100644
index 0000000000..93b10fdfcc
--- /dev/null
+++ b/src/components/guided-modal-tour/tours/site-kit.js
@@ -0,0 +1,15 @@
+import { __ } from '@wordpress/i18n'
+import { i18n } from 'stackable'
+
+export const siteKit = {
+ condition: () => { // If provided, true will show the tour (even if it's already done), false will not show the tour, null will show the tour only once.
+ // Force show the tour if there is a GET parameter tour=site-kit
+ return window?.location?.search?.includes( 'tour=site-kit' ) ? true : null
+ },
+ steps: [
+ {
+ title: '👋 ' + __( 'Welcome to Site Kits', i18n ),
+ description: '', // Not yet available.
+ },
+ ],
+}
diff --git a/src/components/guided-modal-tour/util.js b/src/components/guided-modal-tour/util.js
new file mode 100644
index 0000000000..a8f2f34b7b
--- /dev/null
+++ b/src/components/guided-modal-tour/util.js
@@ -0,0 +1,53 @@
+/**
+ * Global tour state management utilities
+ *
+ * This module provides functions to manage the global state of guided modal tours,
+ * ensuring that only one tour can be active at a time.
+ */
+
+// Global tour state
+let activeTourId = null
+const tourStateListeners = new Set()
+
+/**
+ * Set the currently active tour
+ *
+ * @param {string} tourId - The ID of the tour to set as active
+ */
+export const setActiveTour = tourId => {
+ activeTourId = tourId
+ tourStateListeners.forEach( listener => listener( tourId ) )
+}
+
+/**
+ * Clear the currently active tour
+ */
+export const clearActiveTour = () => {
+ activeTourId = null
+ tourStateListeners.forEach( listener => listener( null ) )
+}
+
+/**
+ * Check if any tour is currently active
+ *
+ * @return {boolean} True if a tour is active, false otherwise
+ */
+export const isTourActive = () => activeTourId !== null
+
+/**
+ * Get the currently active tour ID
+ *
+ * @return {string|null} The active tour ID or null if no tour is active
+ */
+export const getActiveTourId = () => activeTourId
+
+/**
+ * Add a listener for tour state changes
+ *
+ * @param {Function} listener - Function to call when tour state changes
+ * @return {Function} Function to remove the listener
+ */
+export const addTourStateListener = listener => {
+ tourStateListeners.add( listener )
+ return () => tourStateListeners.delete( listener )
+}
diff --git a/src/components/index.js b/src/components/index.js
index 89ab886b80..7a4e47d490 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -56,6 +56,8 @@ export { default as AdminSelectSetting } from './admin-select-setting'
export { default as TaxonomyControl } from './taxonomy-control'
export { default as Tooltip } from './tooltip'
export { default as BlockStyles } from './block-styles'
+export { default as GuidedModalTour } from './guided-modal-tour'
+export { default as StyleGuide, StyleGuidePopover } from './style-guide'
// V2 only Components, for deprecation
export { default as BlockContainer } from './block-container'
@@ -123,5 +125,7 @@ export {
default as ColorSchemePreview,
ColorSchemePresetPicker,
DEFAULT_COLOR_SCHEME_COLORS,
+ ALTERNATE_COLOR_SCHEME_COLORS,
+ COLOR_SCHEME_PROPERTY_LABELS,
} from './color-scheme-preview'
export { ColorSchemesHelp } from './color-schemes-help'
diff --git a/src/components/modal-design-library/modal.js b/src/components/modal-design-library/modal.js
index f6cd9938e2..bc9e4e364f 100644
--- a/src/components/modal-design-library/modal.js
+++ b/src/components/modal-design-library/modal.js
@@ -6,6 +6,7 @@ import BlockList from './block-list'
import Button from '../button'
import AdvancedToolbarControl from '../advanced-toolbar-control'
import DesignLibraryList from '~stackable/components/design-library-list'
+import { GuidedModalTour } from '~stackable/components'
import { getDesigns, filterDesigns } from '~stackable/design-library'
/**
@@ -213,6 +214,9 @@ export const ModalDesignLibrary = props => {
onRequestClose={ props.onClose }
>