Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions interactions.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,4 @@ function interact_require_types() {
require_once( plugin_dir_path( __FILE__ ) . 'src/rest-api/class-rest-location-rules.php' );

require_once( plugin_dir_path( __FILE__ ) . 'src/editor/interaction-library/index.php' );
require_once( plugin_dir_path( __FILE__ ) . 'src/editor/getting-started.php' );
37 changes: 37 additions & 0 deletions src/editor/components/guided-modal-tour/README.MD
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# How to create new tours

## 1. Create a unique tourID for the tour

Let's say the tourId is `interaction-library`

## 2. Add the GuidedModalTour component

Add the GuidedModalTour component to where we want to show the tour. This tour
will be visible only once when the component is rendered.

For example in `interaction-library` tour, we can add it in the actual interaction library
modal render:

```js
<GuidedModalTour tourId="interaction-library" />
```

## 3. Add initialization code to trigger the tour if needed

Since the `GuidedModalTour` component is only rendered when the tour is actually
shown, if we are coming from the Getting Started screen, and we want to force
the tour to open and need to perform any code for this, then we can add them in:

`src/editor/components/guided-modal-tour/index.js`

## 4. Add the show condition for the tour

By default, `GuidedModalTour` only shows once. When it's finished, it will not
show again. We can change this behavior e.g. stop the tour from showing, by
adding a condition in `./tour-conditions.js`.

## 5. Create the tour steps

Create the tour steps in `src/editor/components/modal-tour/tours/`, refer to
`src/editor/components/modal-tour/tours/README.md` for more details

236 changes: 236 additions & 0 deletions src/editor/components/guided-modal-tour/editor.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
.interact-tour-modal--overlay {
z-index: 1000002;
background-color: transparent !important;
pointer-events: none;
}

.interact-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: #05f;
--wp-admin-theme-color-darker-10: #0044cc;
--wp-admin-theme-color-darker-20: #0036a1;

// 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;
&.interact-tour-modal--visible {
display: block !important;
opacity: 0 !important;
transform: scale(0.4);
}
&.interact-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;
}
.interact-tour-modal__footer {
margin-top: 16px;
justify-content: flex-end;
}

.interact-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;
}
}

.interact-tour-modal__cta {
width: 100%;
justify-content: center;
margin: 16px 0 8px;
}

&.interact-tour-modal--right,
&.interact-tour-modal--left,
&.interact-tour-modal--top,
&.interact-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;
}
}
}
&.interact-tour-modal--left {
.components-modal__content {
box-shadow: rgba(0, 0, 0, 0.2) 20px 22px 60px -4px;
&::after {
left: auto;
right: -10px;
}
}
}
&.interact-tour-modal--left-top {
.components-modal__content {
&::after {
top: 30px;
}
}
}
&.interact-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);
}
}
}
&.interact-tour-modal--top-right {
.components-modal__content {
&::after {
left: auto;
right: 16px;
}
}
}
&.interact-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);
}
}
}
}

.interact-tour-modal__steps {
display: flex;
gap: 6px;
margin-inline-end: auto;
}
.interact-tour-modal__step {
width: 8px;
height: 8px;
border-radius: 20px;
background-color: #e1e1e1;
// cursor: pointer;
padding: 0 !important;
margin: 0 !important;

&--active {
background: #05f;
width: 24px;
border-radius: 20px;
}

// &:hover {
// background-color: #aaa;
// }
}

.interact-tour-modal__glow {
position: absolute;
z-index: 1000001;
box-shadow: 0 0 20px #05f;
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;
&.interact-tour-modal__glow--hidden {
opacity: 0;
}
}

.interact-tour-modal__glow--medium,
.interact-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 #05f, 0 0 5px #0036a1;
transform: scaleX(1) scaleY(1);
}
100% {
box-shadow: 0 0 50px #05f, 0 0 5px #0036a1;
transform: scaleX(1.05) scaleY(1.12);
}
}

// Animation keyframes for small glow
@keyframes tour-modal-glow-small {
0% {
box-shadow: 0 0 20px #05f, 0 0 5px #0036a1;
transform: scaleX(1) scaleY(1);
}
100% {
box-shadow: 0 0 50px #05f, 0 0 5px #0036a1;
transform: scaleX(1.02) scaleY(1.02);
}
}
108 changes: 108 additions & 0 deletions src/editor/components/guided-modal-tour/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Internal dependencies
*/
// import { TOUR_STEPS } from './tour-steps'
import { TOUR_CONDITIONS } from './tour-conditions'
import {
clearActiveTour,
isTourActive,
getActiveTourId,
addTourStateListener,
} from './util'

/**
* External dependencies
*/
import { guidedTourStates } from 'interactions'

/**
* WordPress dependencies
*/
import { models } from '@wordpress/api'
import {
useEffect, useState, lazy, Suspense, memo,
} from '@wordpress/element'

// The main tour component.
const GuidedModalTour = memo( 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 ] )

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 condition = TOUR_CONDITIONS[ tourId ]
const conditionResult = condition ? condition() : null
if ( conditionResult === false ) {
return null
} else if ( conditionResult === null ) {
if ( isDone ) {
return null
}
}

// Only lazy-load ModalTour when we're actually going to render it
const ModalTour = lazy( () => import( /* webpackChunkName: "modal-tour" */ '../modal-tour' ) )
Comment on lines +72 to +73
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Move lazy() call to module scope.

The lazy() call is currently inside the component's render path, which means it's re-invoked on every render. React's lazy() must be called once at the module level to properly enable code-splitting and avoid remounting issues.

Apply this diff:

+// Only lazy-load ModalTour when we're actually going to render it
+const ModalTour = lazy( () => import( /* webpackChunkName: "modal-tour" */ '../modal-tour' ) )
+
 // The main tour component.
 const GuidedModalTour = memo( props => {
 	const {
@@ -69,9 +72,6 @@
 		}
 	}
 
-	// Only lazy-load ModalTour when we're actually going to render it
-	const ModalTour = lazy( () => import( /* webpackChunkName: "modal-tour" */ '../modal-tour' ) )
-
 	return (
 		<Suspense fallback={ null }>
 			<ModalTour
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Only lazy-load ModalTour when we're actually going to render it
const ModalTour = lazy( () => import( /* webpackChunkName: "modal-tour" */ '../modal-tour' ) )
// Only lazy-load ModalTour when we're actually going to render it
const ModalTour = lazy( () => import( /* webpackChunkName: "modal-tour" */ '../modal-tour' ) )
// The main tour component.
const GuidedModalTour = memo( props => {
const {
tourId,
isDone,
setIsDone,
} = props
const markTourAsComplete = useCallback( async () => {
setIsDone( true )
const guidedTourStates = window.interactEvents?.guidedTourStates || []
if ( guidedTourStates.includes( tourId ) ) {
return
}
// Update the interact_guided_tour_states setting
if ( ! guidedTourStates.includes( tourId ) ) {
// eslint-disable-next-line camelcase
const settings = new models.Settings( { interact_guided_tour_states: [ ...guidedTourStates, tourId ] } )
settings.save().catch( error => {
console.error( 'Failed to save tour state:', error )
// Optionally: notify the user or retry
} )
}
// Soft update the global variable to prevent the tour from being shown again.
// Note: This mutates the global cache to prevent re-renders in other components
guidedTourStates.push( tourId )
}, [ tourId, setIsDone ] )
return (
<Suspense fallback={ null }>
<ModalTour
onClose={ markTourAsComplete }
/>
</Suspense>
)
} )
🤖 Prompt for AI Agents
In src/editor/components/guided-modal-tour/index.js around lines 72-73, the
lazy() call for ModalTour is inside the component render path causing it to be
re-invoked on every render; move the const ModalTour = lazy(() => import(/*
webpackChunkName: "modal-tour" */ '../modal-tour')) to module scope (top of the
file) so it runs once at load time, remove the in-render declaration and
reference the top-level ModalTour variable instead; also ensure React.lazy (or
lazy) is imported at the top of the file if not already.


return (
<Suspense fallback={ null }>
<ModalTour
tourId={ tourId }
onClose={ () => {
setIsDone( true )
setJustCompleted( true )

// Clear the active tour
clearActiveTour()

// Update the interact_guided_tour_states setting
if ( ! guidedTourStates.includes( tourId ) ) {
// eslint-disable-next-line camelcase
const settings = new models.Settings( { interact_guided_tour_states: [ ...guidedTourStates, tourId ] } )
settings.save().catch( error => {
console.error( 'Error saving guided tour state:', error ) // eslint-disable-line no-console
} )
}

// Soft update the global variable to prevent the tour from being shown again.
guidedTourStates.push( tourId )
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Avoid direct mutation of the imported array.

Directly mutating guidedTourStates with .push() is a side effect that could lead to unexpected behavior in React. While this appears to be a cache optimization after the API call, consider using immutable updates or documenting this as an intentional cache sync.

If guidedTourStates is meant to be mutated as a global cache, add a comment explaining this. Otherwise, consider if this mutation is necessary since the component already handles state with setIsDone(true):

 					// Soft update the global variable to prevent the tour from being shown again.
+					// Note: This mutates the global cache to prevent re-renders in other components
 					guidedTourStates.push( tourId )

Based on learnings: In React, direct mutations of imported data structures should be avoided or clearly documented as intentional cache synchronization.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
guidedTourStates.push( tourId )
// Soft update the global variable to prevent the tour from being shown again.
// Note: This mutates the global cache to prevent re-renders in other components
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() )
} }
/>
</Suspense>
)
} )

export default GuidedModalTour
2 changes: 2 additions & 0 deletions src/editor/components/guided-modal-tour/tour-conditions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// For each condition, true will show the tour (even if it's already done), false will not show the tour, null will show the tour only once.
export const TOUR_CONDITIONS = {}
Loading