woocommerce/plugins/woocommerce-admin/client/guided-tours/shipping-tour.tsx

395 lines
10 KiB
TypeScript
Raw Normal View History

/**
* External dependencies
*/
import { TourKit, TourKitTypes } from '@woocommerce/components';
import { __ } from '@wordpress/i18n';
import { useDispatch, useSelect } from '@wordpress/data';
import {
useLayoutEffect,
useEffect,
useState,
useRef,
createPortal,
} from '@wordpress/element';
import { OPTIONS_STORE_NAME } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { getCountryCode } from '~/dashboard/utils';
const REVIEWED_DEFAULTS_OPTION =
'woocommerce_admin_reviewed_default_shipping_zones';
const CREATED_DEFAULTS_OPTION =
'woocommerce_admin_created_default_shipping_zones';
const FLOATER_WRAPPER_CLASS =
'woocommerce-settings-shipping-tour-floater-wrapper';
const FLOATER_CLASS =
'woocommerce-settings-smart-defaults-shipping-tour-floater';
const SHIPPING_ZONES_SETTINGS_TABLE_CLASS = 'table.wc-shipping-zones';
const WCS_LINK_SELECTOR = 'a[href*="woocommerce-services-settings"]';
const SHIPPING_RECOMMENDATIONS_SELECTOR =
'div.woocommerce-recommended-shipping-extensions';
const useShowShippingTour = () => {
const {
hasCreatedDefaultShippingZones,
hasReviewedDefaultShippingOptions,
businessCountry,
isLoading,
} = useSelect( ( select ) => {
const { hasFinishedResolution, getOption } =
select( OPTIONS_STORE_NAME );
return {
isLoading:
! hasFinishedResolution( 'getOption', [
CREATED_DEFAULTS_OPTION,
] ) &&
! hasFinishedResolution( 'getOption', [
REVIEWED_DEFAULTS_OPTION,
] ) &&
! hasFinishedResolution( 'getOption', [
'woocommerce_default_country',
] ),
hasCreatedDefaultShippingZones:
getOption( CREATED_DEFAULTS_OPTION ) === 'yes',
hasReviewedDefaultShippingOptions:
getOption( REVIEWED_DEFAULTS_OPTION ) === 'yes',
businessCountry: getCountryCode(
getOption( 'woocommerce_default_country' ) as string
),
};
} );
return {
isLoading,
show:
window.wcAdminFeatures[ 'shipping-setting-tour' ] &&
! isLoading &&
hasCreatedDefaultShippingZones &&
! hasReviewedDefaultShippingOptions,
isUspsDhlEligible: businessCountry === 'US',
};
};
type NonEmptySelectorArray = readonly [ string, ...string[] ];
const computeDims = ( elementsSelectors: NonEmptySelectorArray ) => {
const rects = elementsSelectors.map( ( elementSelector ) => {
const rect = document
?.querySelector( elementSelector )
?.getBoundingClientRect();
if ( ! rect ) {
throw new Error(
"Shipping tour: Couldn't find element with selector: " +
elementSelector
);
}
return rect;
} );
const originCoords = document
.querySelector( `.${ FLOATER_WRAPPER_CLASS }` )
?.getBoundingClientRect() || { top: 0, left: 0 };
const top = Math.min( ...rects.map( ( rect ) => rect.top ) );
const left = Math.min( ...rects.map( ( rect ) => rect.left ) );
const right = Math.max( ...rects.map( ( rect ) => rect.right ) );
const bottom = Math.max( ...rects.map( ( rect ) => rect.bottom ) );
const width = right - left;
const height = bottom - top;
// offset top and left from origin
const topOffset = top - originCoords.top;
const leftOffset = left - originCoords.left;
return { left: leftOffset, top: topOffset, width, height };
};
const TourFloater = ( { dims }: { dims: Partial< DOMRect > } ) => {
return (
<div
style={ {
position: 'relative',
pointerEvents: 'none',
...dims,
} }
className={ FLOATER_CLASS }
></div>
);
};
// this is defines the elements to be spotlit for each step
const spotlitElementsSelectors: Array< NonEmptySelectorArray > = [
[
// just use bottom right element and top left element instead of all rects
// top left = table header cell for sort handles
'th.wc-shipping-zone-sort',
// bottom right = worldwide region cell
'tfoot.wc-shipping-zone-worldwide tr > td.wc-shipping-zone-region',
],
[
// selectors for rightmost column (shipping methods)
'th.wc-shipping-zone-methods',
'tfoot.wc-shipping-zone-worldwide tr > td.wc-shipping-zone-methods',
],
];
const TourFloaterWrapper = ( { step }: { step: number } ) => {
const thisRef = useRef< HTMLDivElement >( null );
useLayoutEffect( () => {
// this moves the element to the correct place which is right before the table element
if ( thisRef.current?.parentElement ) {
thisRef.current.parentElement.insertBefore(
thisRef.current,
document.querySelector( 'table.wc-shipping-zones' )
);
}
}, [] );
const currentStepSelectors =
spotlitElementsSelectors[ step ] ??
spotlitElementsSelectors[ spotlitElementsSelectors.length - 1 ];
const [ dims, setDims ] = useState( computeDims( currentStepSelectors ) );
useEffect( () => {
setDims( computeDims( currentStepSelectors ) );
const observer = new ResizeObserver( () => {
setDims( computeDims( currentStepSelectors ) );
} );
const shippingSettingsTableElement = document.querySelector(
SHIPPING_ZONES_SETTINGS_TABLE_CLASS
);
if ( ! shippingSettingsTableElement ) {
throw new Error(
"Shipping tour: Couldn't find shipping settings table element with selector: " +
SHIPPING_ZONES_SETTINGS_TABLE_CLASS
);
}
observer.observe( shippingSettingsTableElement );
return () => {
observer.disconnect();
};
}, [ currentStepSelectors ] );
const shippingSettingsTableParentElement = document.querySelector(
SHIPPING_ZONES_SETTINGS_TABLE_CLASS
)?.parentElement;
if ( ! shippingSettingsTableParentElement ) {
throw new Error(
"Shipping tour: Couldn't find shipping settings table parent element with selector: " +
SHIPPING_ZONES_SETTINGS_TABLE_CLASS
);
}
/**
* use ReactDOM.createPortal to inject our element into non-React controlled DOM
* unfortunately createPortal uses .appendChild which puts it in the wrong place,
* we want it to be before the table
*/
return createPortal(
<div
ref={ thisRef }
className={ FLOATER_WRAPPER_CLASS }
style={ { position: 'absolute' } }
>
{ <TourFloater dims={ dims } /> }
</div>,
shippingSettingsTableParentElement
);
};
export const ShippingTour: React.FC< {
showShippingRecommendationsStep: boolean;
} > = ( { showShippingRecommendationsStep } ) => {
const { updateOptions } = useDispatch( OPTIONS_STORE_NAME );
const { show: showTour, isUspsDhlEligible } = useShowShippingTour();
const [ step, setStepNumber ] = useState( 0 );
const { createNotice } = useDispatch( 'core/notices' );
const tourConfig: TourKitTypes.WooConfig = {
placement: 'auto',
options: {
effects: {
spotlight: {
interactivity: {
enabled: false,
},
},
liveResize: {
mutation: true,
resize: true,
},
autoScroll: true,
},
callbacks: {
onNextStep: ( newStepIndex ) => {
setStepNumber( newStepIndex );
recordEvent( 'walkthrough_settings_shipping_next_click', {
step_name:
tourConfig.steps[ newStepIndex - 1 ].meta.name,
} );
},
onPreviousStep: ( newStepIndex ) => {
setStepNumber( newStepIndex );
recordEvent( 'walkthrough_settings_shipping_back_click', {
step_name:
tourConfig.steps[ newStepIndex + 1 ].meta.name,
} );
},
},
},
steps: [
{
referenceElements: {
desktop: `.${ FLOATER_CLASS }`,
},
meta: {
name: 'shipping-zones',
heading: __( 'Shipping zones', 'woocommerce' ),
descriptions: {
desktop: (
<>
<span>
{ __(
"Specify the areas you'd like to ship to! Give each zone a name, then list the regions you'd like to include. Your regions can be as specific as a zip code or as broad as a country. Shoppers will only see the methods available in their region.",
'woocommerce'
) }
</span>
<br />
<span>
{ __(
"We've added some shipping zones to get you started — you can manage them by selecting Edit or Delete.",
'woocommerce'
) }
</span>
</>
),
},
},
},
{
referenceElements: {
desktop: `.${ FLOATER_CLASS }`,
},
meta: {
name: 'shipping-methods',
heading: __( 'Shipping methods', 'woocommerce' ),
descriptions: {
desktop: (
<>
<span>
{ __(
"Add one or more shipping methods you'd like to offer to shoppers in your zones.",
'woocommerce'
) }
</span>
<br />
<span>
{ __(
"For example, we've added the “Free shipping” method for shoppers in your country. You can edit, add to, or remove shipping methods by selecting Edit or Delete.",
'woocommerce'
) }
</span>
</>
),
},
},
},
],
closeHandler: async ( steps, stepIndex, source ) => {
const update = await updateOptions( {
[ REVIEWED_DEFAULTS_OPTION ]: 'yes',
} );
if ( ! update.success ) {
createNotice(
'error',
__(
'There was a problem marking the shipping tour as completed.',
'woocommerce'
)
);
recordEvent(
'walkthrough_settings_shipping_updated_option_error'
);
}
if ( source === 'close-btn' ) {
recordEvent( 'walkthrough_settings_shipping_dismissed', {
step_name: steps[ stepIndex ].meta.name,
} );
} else if ( source === 'done-btn' ) {
recordEvent( 'walkthrough_settings_shipping_completed' );
}
},
};
const isWcsSectionPresent = document.querySelector( WCS_LINK_SELECTOR );
if ( isWcsSectionPresent && isUspsDhlEligible ) {
tourConfig.steps.push( {
referenceElements: {
desktop: WCS_LINK_SELECTOR,
},
meta: {
name: 'woocommerce-shipping',
heading: __( 'WooCommerce Shipping', 'woocommerce' ),
descriptions: {
desktop: __(
'Print USPS and DHL labels straight from your Woo dashboard and save on shipping thanks to discounted rates. You can manage WooCommerce Shipping in this section.',
'woocommerce'
),
},
},
} );
}
if ( showShippingRecommendationsStep ) {
tourConfig.steps.push( {
referenceElements: {
desktop: SHIPPING_RECOMMENDATIONS_SELECTOR,
},
meta: {
name: 'shipping-recommendations',
heading: __( 'WooCommerce Shipping', 'woocommerce' ),
descriptions: {
desktop: __(
'If youd like to speed up your process and print your shipping label straight from your Woo dashboard, WooCommerce Shipping may be for you! ',
'woocommerce'
),
},
},
} );
}
useEffect( () => {
if ( showTour ) {
recordEvent( 'walkthrough_settings_shipping_view' );
}
}, [ showTour ] );
if ( showTour ) {
return (
<div>
<TourFloaterWrapper step={ step } />
<TourKit config={ tourConfig }></TourKit>
</div>
);
}
return null;
};