/** * WordPress dependencies */ import apiFetch from '@safe-wordpress/api-fetch'; import { dispatch, select } from '@safe-wordpress/data'; import { _x } from '@safe-wordpress/i18n'; import { store as NOTICES } from '@safe-wordpress/notices'; /** * External dependencies */ import { values } from 'lodash'; import { store as NAB_DATA } from '@nab/data'; import { store as NAB_EXPERIMENTS } from '@nab/experiments'; import type { Alternative, AlternativeId, ConversionAction, ECommercePlugin, Experiment, Goal, Heatmap, Maybe, OrderStatus, OrderStatusName, Segment, Url, } from '@nab/types'; /** * Internal dependencies */ import { store as NAB_EDITOR } from '../../../store'; export async function saveExperiment(): Promise< void > { const isSaving = select( NAB_EDITOR ).isExperimentBeingSaved(); if ( isSaving ) { return; } await dispatch( NAB_EDITOR ).setExperimentAsBeingSaved( true ); const goals: Goal[] = []; const goalsWithoutActions = select( NAB_EDITOR ).getGoals(); for ( const goal of goalsWithoutActions ) { const actions = select( NAB_EDITOR ).getConversionActions( goal.id ); const ecommerce = getECommercePlugin( actions ); goals.push( { ...goal, attributes: { ...goal.attributes, useOrderRevenue: !! ecommerce ? goal.attributes.useOrderRevenue ?? true : undefined, useFullOrderRevenue: !! ecommerce ? goal.attributes.useFullOrderRevenue || undefined : undefined, orderStatusForConversion: !! ecommerce ? getOrderStatusForConversion( ecommerce, goal ) : undefined, }, conversionActions: actions, } ); } const segments: Segment[] = []; const segmentsWithoutRules = select( NAB_EDITOR ).getSegments(); for ( const segment of segmentsWithoutRules ) { segments.push( { ...segment, segmentationRules: select( NAB_EDITOR ).getSegmentationRules( segment.id ), } ); } const nonControlAlternatives: Alternative[] = []; const alternativeIds = select( NAB_EDITOR ).getAlternativeIds(); const isTestedElementInvalid = select( NAB_EDITOR ).isTestedElementInvalid(); const control = select( NAB_EDITOR ).getAlternative( 'control' ); for ( const alternativeId of alternativeIds ) { if ( 'control' === alternativeId ) { continue; } const alt = select( NAB_EDITOR ).getAlternative( alternativeId ); if ( alt ) { nonControlAlternatives.push( alt ); } } let experiment: Omit< Experiment, 'links' > | Omit< Heatmap, 'links' >; const type = select( NAB_EDITOR ).getExperimentType(); if ( 'nab/heatmap' === type ) { experiment = { id: select( NAB_EDITOR ).getExperimentId(), type, name: select( NAB_EDITOR ).getExperimentAttribute( 'name' ), description: select( NAB_EDITOR ).getExperimentAttribute( 'description' ), status: select( NAB_EDITOR ).getExperimentAttribute( 'status' ), startDate: select( NAB_EDITOR ).getExperimentAttribute( 'startDate' ), endDate: false, endMode: select( NAB_EDITOR ).getExperimentAttribute( 'endMode' ), endValue: select( NAB_EDITOR ).getExperimentAttribute( 'endValue' ), trackingMode: select( NAB_EDITOR ).getHeatmapAttribute( 'trackingMode' ) ?? 'post', trackedPostId: select( NAB_EDITOR ).getHeatmapAttribute( 'trackedPostId' ) ?? 0, trackedPostType: select( NAB_EDITOR ).getHeatmapAttribute( 'trackedPostType' ) ?? 'page', trackedUrl: select( NAB_EDITOR ).getHeatmapAttribute( 'trackedUrl' ) ?? ( '' as Url ), participationConditions: select( NAB_EDITOR ).getHeatmapAttribute( 'participationConditions' ) ?? [], }; } else { experiment = { id: select( NAB_EDITOR ).getExperimentId(), type, name: select( NAB_EDITOR ).getExperimentAttribute( 'name' ), description: select( NAB_EDITOR ).getExperimentAttribute( 'description' ), status: select( NAB_EDITOR ).getExperimentAttribute( 'status' ), startDate: select( NAB_EDITOR ).getExperimentAttribute( 'startDate' ), endDate: false, endMode: select( NAB_EDITOR ).getExperimentAttribute( 'endMode' ), endValue: select( NAB_EDITOR ).getExperimentAttribute( 'endValue' ), autoAlternativeApplication: select( NAB_EDITOR ).getExperimentAttribute( 'autoAlternativeApplication' ), scope: select( NAB_EDITOR ).getScope(), alternatives: isTestedElementInvalid ? [ control ] : [ control, ...nonControlAlternatives ], goals, segmentEvaluation: select( NAB_EDITOR ).getSegmentEvaluation(), segments, }; } try { let savedExperiment = await apiFetch< Experiment >( { path: `/nab/v1/experiment/${ experiment.id }`, method: 'PUT', data: experiment, } ); if ( 'nab/heatmap' !== experiment.type && isTestedElementInvalid ) { savedExperiment = { ...savedExperiment, alternatives: [ savedExperiment.alternatives[ 0 ], ...nonControlAlternatives, ], }; } await dispatch( NAB_EDITOR ).setupEditor( savedExperiment ); await dispatch( NAB_EDITOR ).setExperimentAsBeingSaved( false ); await dispatch( NAB_EDITOR ).setExperimentAsRecentlySaved(); } catch ( e ) { const message = getErrorMessage( e ); await dispatch( NOTICES ).createErrorNotice( message ); await dispatch( NAB_EDITOR ).setExperimentAsBeingSaved( false ); } } export async function saveExperimentAndEditAlternative( alternativeId: AlternativeId ): Promise< void > { await saveExperiment(); if ( ! select( NAB_EDITOR ).hasExperimentBeenRecentlySaved() ) { return; } const alternativeIds = select( NAB_EDITOR ).getAlternativeIds(); if ( ! alternativeIds.includes( alternativeId ) ) { return; } const alternative = select( NAB_EDITOR ).getAlternative( alternativeId ); if ( ! alternative ) { return; } if ( ! alternative.links.edit ) { return; } window.location.href = alternative.links.edit; } export async function saveExperimentAndPreviewAlternative( alternativeId: AlternativeId ): Promise< void > { await saveExperiment(); if ( ! select( NAB_EDITOR ).hasExperimentBeenRecentlySaved() ) { return; } const alternativeIds = select( NAB_EDITOR ).getAlternativeIds(); if ( ! alternativeIds.includes( alternativeId ) ) { return; } const alternative = select( NAB_EDITOR ).getAlternative( alternativeId ); if ( ! alternative?.links.preview ) { let message = _x( 'Preview not available.', 'text', 'nelio-ab-testing' ); const type = select( NAB_EDITOR ).getExperimentType(); const scopeType = select( NAB_EXPERIMENTS ).getExperimentSupport( type, 'scope' ); if ( 'urls' === scopeType || 'urls-with-tested-post' === scopeType ) { message += ' '; message += _x( 'Please review your test scope and consider adding an exact URL where the test should be active so that Nelio can use it in the preview.', 'user', 'nelio-ab-testing' ); } await dispatch( NOTICES ).createWarningNotice( message ); return; } await dispatch( NAB_EDITOR ).openAlternativePreviewer( alternative.links.preview ); } export async function moveToTrash(): Promise< void > { await dispatch( NAB_EDITOR ).setExperimentData( { status: 'trash' } ); await saveExperiment(); window.location.href = select( NAB_DATA ).getAdminUrl( 'edit.php', { post_type: 'nab_experiment', } ); } export async function startExperiment(): Promise< void > { await startOrResumeExperiment( 'start' ); } export async function resumeExperiment(): Promise< void > { await startOrResumeExperiment( 'resume' ); } // ======= // HELPERS // ======= async function startOrResumeExperiment( action: 'start' | 'resume', ignoreScopeOverlap = false ): Promise< void > { await saveExperiment(); if ( ! select( NAB_EDITOR ).hasExperimentBeenRecentlySaved() ) { return; } await dispatch( NAB_EDITOR ).setExperimentAsBeingSaved( true ); const experimentId = select( NAB_EDITOR ).getExperimentId(); try { await apiFetch( { path: `/nab/v1/experiment/${ experimentId }/${ action }`, method: 'PUT', data: { ignoreScopeOverlap }, } ); } catch ( e ) { const message = getErrorMessage( e, 'start' === action ? _x( 'Test can’t be started', 'text', 'nelio-ab-testing' ) : _x( 'Test can’t be resumed', 'text', 'nelio-ab-testing' ) ); if ( 'equivalent-experiment-running' === getErrorCode( e ) ) { const { notice } = ( await dispatch( NOTICES ).createWarningNotice( message, { actions: [ { label: 'start' === action ? _x( 'Start anyway', 'command', 'nelio-ab-testing' ) : _x( 'Resume anyway', 'command', 'nelio-ab-testing' ), // @ts-expect-error `variant` is actually properly processed. variant: 'primary', onClick: async () => { await dispatch( NOTICES ).removeNotice( notice.id ); await startOrResumeExperiment( action, true ); }, }, ], } ) ) as { notice: { id: string } }; } else { await dispatch( NOTICES ).createErrorNotice( message ); } await dispatch( NAB_EDITOR ).setExperimentAsBeingSaved( false ); return; } window.location.href = select( NAB_DATA ).getAdminUrl( 'admin.php', { page: 'nelio-ab-testing-experiment-view', experiment: experimentId, } ); } function getECommercePlugin( actions: ReadonlyArray< ConversionAction > ): Maybe< ECommercePlugin > { if ( ! actions.length ) { return undefined; } const isWcOrder = ( { type }: ConversionAction ) => 'nab/wc-order' === type; if ( actions.every( isWcOrder ) ) { return 'woocommerce'; } const isEddOrder = ( { type }: ConversionAction ) => 'nab/edd-order' === type; if ( actions.every( isEddOrder ) ) { return 'edd'; } return undefined; } function getOrderStatusForConversion( plugin: ECommercePlugin, goal: Omit< Goal, 'conversionActions' > ): OrderStatusName { const validStatuses = select( NAB_DATA ).getECommerceSetting( plugin, 'orderStatuses' ); const status = goal.attributes.orderStatusForConversion; return status && validStatuses.map( ( s: OrderStatus ) => s.value ).includes( status ) ? status : getDefaultOrderStatus( plugin ); } function getDefaultOrderStatus( plugin: ECommercePlugin ): string { switch ( plugin ) { case 'woocommerce': return 'wc-completed'; case 'edd': return 'complete'; } } function getErrorCode( e: unknown ): false | 'equivalent-experiment-running' { if ( ! e || 'object' !== typeof e ) { return false; } if ( 'code' in e && 'equivalent-experiment-running' === e.code ) { return 'equivalent-experiment-running'; } if ( ! ( 'errors' in e ) || 'object' !== typeof e.errors ) { return false; } const errors = e.errors || {}; return ( Object.keys( errors ).includes( 'equivalent-experiment-running' ) && 'equivalent-experiment-running' ); } function getErrorMessage( e: unknown, defaultMessage?: string ): string { const fallback = defaultMessage || _x( 'Unknown error', 'text', 'nelio-ab-testing' ); if ( ! e || 'object' !== typeof e ) { return fallback; } if ( 'errors' in e && 'object' === typeof e.errors ) { const errors = Array.isArray( e.errors ) ? e.errors : values( e.errors ); if ( 'string' === typeof errors[ 0 ] ) { return errors[ 0 ] || fallback; } const firstError = Array.isArray( errors[ 0 ] ) && 'string' === typeof errors[ 0 ][ 0 ] ? errors[ 0 ][ 0 ] : undefined; if ( firstError ) { return firstError || fallback; } } if ( 'message' in e && 'string' === typeof e.message ) { return e.message || fallback; } return fallback; }