Skip to content

Commit

Permalink
Add onboarding tours
Browse files Browse the repository at this point in the history
  • Loading branch information
nateweller committed Apr 1, 2024
1 parent cfa1e98 commit 2dbf9b8
Show file tree
Hide file tree
Showing 15 changed files with 1,291 additions and 14 deletions.
81 changes: 81 additions & 0 deletions client/a8c-for-agencies/components/guided-tour-step/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Button, Popover } from '@automattic/components';
import classNames from 'classnames';
import { useTranslate } from 'i18n-calypso';
import { LegacyRef, useContext, useEffect } from 'react';
import { GuidedTourContext } from 'calypso/a8c-for-agencies/data/guided-tours/guided-tour-context';

type Props = {
id: string;
context: LegacyRef< HTMLSpanElement | null >;
className?: string;
hideSteps?: boolean;
};

/**
* Renders a single step in a guided tour.
*/
export function GuidedTourStep( { id, context, hideSteps, className }: Props ) {
const translate = useTranslate();

const { currentStep, currentStepCount, stepsCount, nextStep, endTour, tourDismissed } =
useContext( GuidedTourContext );

const lastTourLabel = stepsCount === 1 ? translate( 'Got it' ) : translate( 'Done' );

// Keep track of which tour steps are currently rendered/mounted.
useEffect( () => {
// Register on mount.
registerRenderedStep( id );
// Unregister on unmount.
return () => unregisterRenderedStep( id );
}, [ id, registerRenderedStep, unregisterRenderedStep ] );

// Do not render unless this is the current step in the active tour.
if ( ! currentStep || id !== currentStep.id || tourDismissed ) {
return null;
}

return (
<Popover
isVisible={ true }
className={ classNames( className, 'guided-tour__popover' ) }
context={ context }
position={ currentStep.popoverPosition }
>
<h2 className="guided-tour__popover-heading">{ currentStep.title }</h2>
<p className="guided-tour__popover-description">{ currentStep.description }</p>
<div className="guided-tour__popover-footer">
<div>
{
// Show the step count if there are multiple steps and we're not on the last step, unless we explicitly choose to hide them
stepsCount > 1 && ! hideSteps && (
<span className="guided-tour__popover-step-count">
{ translate( 'Step %(currentStep)d of %(totalSteps)d', {
args: { currentStep: currentStepCount, totalSteps: stepsCount },
} ) }
</span>
)
}
</div>
<div className="guided-tour__popover-footer-right-content">
<>
{ ( ( ! currentStep.nextStepOnTargetClick &&
stepsCount > 1 &&
currentStepCount < stepsCount ) ||
currentStep.forceShowSkipButton ) && (
// Show the skip button if there are multiple steps and we're not on the last step, unless we explicitly choose to add them
<Button borderless onClick={ endTour }>
{ translate( 'Skip' ) }
</Button>
) }
{ ! currentStep.nextStepOnTargetClick && (
<Button onClick={ nextStep }>
{ currentStepCount === stepsCount ? lastTourLabel : translate( 'Next' ) }
</Button>
) }
</>
</div>
</div>
</Popover>
);
}
166 changes: 166 additions & 0 deletions client/a8c-for-agencies/components/guided-tour/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import page from '@automattic/calypso-router';
import { Popover, Button } from '@automattic/components';
import classNames from 'classnames';
import { useTranslate } from 'i18n-calypso';
import { useState, useEffect, useCallback, useContext } from 'react';
import { GuidedTourContext } from 'calypso/a8c-for-agencies/data/guided-tours/guided-tour-context';
import { useDispatch, useSelector } from 'calypso/state';
import { recordTracksEvent } from 'calypso/state/analytics/actions';
import { getJetpackDashboardPreference as getPreference } from 'calypso/state/jetpack-agency-dashboard/selectors';
import { savePreference } from 'calypso/state/preferences/actions';
import { preferencesLastFetchedTimestamp } from 'calypso/state/preferences/selectors';

import './style.scss';

export interface Tour {
id: string;
target: string;
title: string;
description: string | JSX.Element;
popoverPosition?:
| 'top'
| 'top right'
| 'right'
| 'bottom right'
| 'bottom'
| 'bottom left'
| 'left'
| 'top left';
nextStepOnTargetClick?: string;
forceShowSkipButton?: boolean;
}

type Props = {
className?: string;
};

const GuidedTour = ( { className }: Props ) => {
const translate = useTranslate();
const dispatch = useDispatch();

const { currentTourId, currentStep, currentStepCount, nextStep, stepsCount, targetElement } =
useContext( GuidedTourContext );

if ( ! currentTourId || ! currentStep ) {
throw new Error( 'GuidedTour requires a current tour ID to be available in context.' );
}

const preference = useSelector( ( state ) => getPreference( state, currentTourId ) );
const hasFetched = !! useSelector( preferencesLastFetchedTimestamp );

const [ isVisible, setIsVisible ] = useState( false );

const isDismissed = preference?.dismiss;

const {
title,
description,
popoverPosition,
nextStepOnTargetClick,
forceShowSkipButton = false,
} = currentStep;

const redirectAfterTourEnds = false;
const hideSteps = false;

useEffect( () => {
if ( targetElement && ! isDismissed && hasFetched ) {
setIsVisible( true );
dispatch(
recordTracksEvent( 'calypso_a4a_start_tour', {
tour: currentTourId,
} )
);
}
}, [ dispatch, isDismissed, currentTourId, targetElement, hasFetched ] );

const endTour = useCallback( () => {
dispatch( savePreference( currentTourId, { ...preference, dismiss: true } ) );
dispatch(
recordTracksEvent( 'calypso_a4a_end_tour', {
tour: currentTourId,
} )
);
if ( redirectAfterTourEnds ) {
page.redirect( redirectAfterTourEnds );
}
}, [ dispatch, currentTourId, preference, redirectAfterTourEnds ] );

useEffect( () => {
if ( nextStepOnTargetClick && targetElement && ! isDismissed && hasFetched ) {
// Find the target element using the nextStepOnTargetClick selector
targetElement.addEventListener( 'click', nextStep );
}

// Cleanup function to remove the event listener
return () => {
if ( targetElement ) {
targetElement.removeEventListener( 'click', nextStep );
}
};
}, [ nextStepOnTargetClick, nextStep, targetElement, isDismissed, hasFetched ] );

const lastTourLabel = stepsCount === 1 ? translate( 'Got it' ) : translate( 'Done' );

return (
<Popover
isVisible={ isVisible }
className={ classNames( className, 'guided-tour__popover' ) }
context={ targetElement }
position={ popoverPosition }
>
<h2 className="guided-tour__popover-heading">{ title }</h2>
<p className="guided-tour__popover-description">{ description }</p>
<div className="guided-tour__popover-footer">
<div>
{
// Show the step count if there are multiple steps and we're not on the last step, unless we explicitly choose to hide them
stepsCount > 1 && ! hideSteps && (
<span className="guided-tour__popover-step-count">
{ translate( 'Step %(currentStep)d of %(totalSteps)d', {
args: { currentStep: currentStepCount + 1, totalSteps: stepsCount },
} ) }
</span>
)
}
</div>
<div className="guided-tour__popover-footer-right-content">
<>
{ ( ( ! nextStepOnTargetClick &&
stepsCount > 1 &&
currentStepCount < stepsCount - 1 ) ||
forceShowSkipButton ) && (
// Show the skip button if there are multiple steps and we're not on the last step, unless we explicitly choose to add them
<Button borderless onClick={ endTour }>
{ translate( 'Skip' ) }
</Button>
) }
{ ! nextStepOnTargetClick && (
<Button onClick={ nextStep }>
{ currentStepCount === stepsCount - 1 ? lastTourLabel : translate( 'Next' ) }
</Button>
) }
</>
</div>
</div>
</Popover>
);
};

const GuidedTourSlot = ( { defaultTourId } ) => {
const { currentTourId, currentStep, startTour } = useContext( GuidedTourContext );

useEffect( () => {
if ( ! currentTourId && defaultTourId ) {
startTour( defaultTourId );
}
}, [ currentTourId, defaultTourId, startTour ] );

if ( ! currentTourId || ! currentStep ) {
return null;
}

return <GuidedTour />;
};

export default GuidedTourSlot;
49 changes: 49 additions & 0 deletions client/a8c-for-agencies/components/guided-tour/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
.guided-tour__popover {
.popover__inner {
display: flex;
gap: 16px;
padding: 16px;
flex-direction: column;
align-items: flex-start;
/* stylelint-disable-next-line scales/radii */
border-radius: 8px;
}
}

.guided-tour__popover-heading {
color: var(--studio-gray-100);
font-size: rem(14px);
line-height: 1.5;
font-weight: 500;
}

.guided-tour__popover-description {
color: var(--studio-gray-80);
font-size: rem(12px);
line-height: 1.5;
max-width: 220px;
text-align: left;
margin-block-end: 0;
}

.guided-tour__popover-footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;

.guided-tour__popover-step-count {
color: var(--studio-gray-60);
font-size: rem(12px);
}

.guided-tour__popover-footer-right-content {
display: flex;
gap: 8px;

button {
padding: 4px 8px;
font-size: rem(12px);
}
}
}
28 changes: 16 additions & 12 deletions client/a8c-for-agencies/components/layout/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import classNames from 'classnames';
import React, { ReactNode } from 'react';
import GuidedTour from 'calypso/a8c-for-agencies/components/guided-tour';
import { GuidedTourContextProvider } from 'calypso/a8c-for-agencies/data/guided-tours/guided-tour-context';
import DocumentHead from 'calypso/components/data/document-head';
import Main from 'calypso/components/main';
import LayoutColumn from './column';
Expand Down Expand Up @@ -33,18 +35,20 @@ export default function Layout( {
: 'a4a-layout__container';

return (
<Main
className={ classNames( 'a4a-layout', className, {
'is-with-border': withBorder,
'is-compact': compact,
} ) }
fullWidthLayout={ wide }
wideLayout={ ! wide } // When we set to full width, we want to set this to false.
>
<DocumentHead title={ title } />
{ sidebarNavigation }
<GuidedTourContextProvider>
<Main
className={ classNames( 'a4a-layout', className, {
'is-with-border': withBorder,
'is-compact': compact,
} ) }
fullWidthLayout={ wide }
wideLayout={ ! wide } // When we set to full width, we want to set this to false.
>
<DocumentHead title={ title } />
{ sidebarNavigation }

<div className={ layoutContainerClassname }>{ children }</div>
</Main>
<div className={ layoutContainerClassname }>{ children }</div>
</Main>
</GuidedTourContextProvider>
);
}
Loading

0 comments on commit 2dbf9b8

Please sign in to comment.