/** * WordPress dependencies */ import apiFetch from '@safe-wordpress/api-fetch'; import { dispatch, select, resolveSelect } from '@safe-wordpress/data'; import { decodeEntities } from '@safe-wordpress/html-entities'; import { _x, sprintf } from '@safe-wordpress/i18n'; /** * External dependencies */ import { groupBy, map, mapValues, omit, toPairs, uniq } from 'lodash'; import { Uuid, v4 as uuid } from 'uuid'; import { createAlternative, getContentMetrics, getContentSummary, getLetter, isDefined, } from '@nab/utils'; import type { AiBestSeller, AiCloudTestCandidate, AiCloudTestHypothesis, AiExperiment, AiHeatmap, AiOrderSummary, AiPostSummary, AiProductSummary, AiSettings, AiSiteAnalysis, AiTestCandidate, AiTestHypothesis, AiTestHypothesisState, Alternative, Dict, Experiment, ExperimentType, Goal, Heatmap, Maybe, ScopeRule, } from '@nab/types'; /** * Internal dependencies */ import { store as NAB_DATA } from '../../../../store'; import type { State } from '../../../../store/types'; import { getPostTypeExceptions } from '../../../../../experiment-library/custom-post-type/get-post-type-exceptions'; const MAX_PAGES = 100; const MAX_ITEMS_PER_TYPE = 50; const MIN_RUN_TIME_FOR_ASYNC_CALLBACK = 1500; type MostViewedItem = | { readonly type: 'post'; readonly postType: string; readonly postId: number; readonly monthlyViews: number; } | { readonly type: 'url'; readonly url: string; readonly monthlyViews: number; }; type AnalysisContext = { readonly analytics: ReadonlyArray< MostViewedItem >; readonly abTesting: AiSiteAnalysis[ 'metaInformation' ][ 'abTesting' ]; }; export async function searchTestCandidates( validExperimentTypes: ReadonlyArray< ExperimentType >, newAiSettings?: AiSettings ): Promise< void > { const state = select( NAB_DATA ).getPageAttribute( 'ai/testGenerationState' ); if ( ! state || 'intro' !== state.type ) { return; } try { if ( newAiSettings ) { const previousAiSettings = select( NAB_DATA ).getPluginSetting( 'aiSettings' ); if ( previousAiSettings?.type === 'ready' ) { throw new Error( 'cant-override-settings' ); } await breathe( () => saveAiSettings( newAiSettings, state.id ) ); } } catch ( error ) { await setState( { id: state.id, type: 'error', reason: getErrorCode( error ), } ); return; } const currentAiSettings = select( NAB_DATA ).getPluginSetting( 'aiSettings' ); if ( currentAiSettings?.type !== 'ready' ) { throw new Error( 'invalid-ai-settings' ); } const abTesting = await breathe( () => getAbTestingData( state.id ) ); if ( 'error' === abTesting.type ) { await setState( { id: state.id, type: 'error', reason: getErrorCode( abTesting.error ), } ); return; } try { const result = await getCachedCandidates( abTesting.aiAnalysis, abTesting.base.experiments, state.id ); if ( result ) { const candidates = await expandFirstCandidate( { candidates: result.candidates, experiments: abTesting.base.experiments, siteAnalysis: result.siteAnalysis, stateId: state.id, validExperimentTypes, } ); await setState( { id: state.id, type: 'hypothesis-selection', siteAnalysis: result.siteAnalysis, candidates, testing: abTesting.base, } ); return; } } catch ( _ ) {} try { const siteAnalysis = await analyzeSite( abTesting.aiAnalysis, state.id ); const baseCandidates = await processSiteAnalysis( siteAnalysis, abTesting.base.experiments, state.id ); const candidates = await expandFirstCandidate( { candidates: baseCandidates, experiments: abTesting.base.experiments, siteAnalysis, stateId: state.id, validExperimentTypes, } ); await setState( { id: state.id, type: 'hypothesis-selection', siteAnalysis, candidates, testing: abTesting.base, } ); } catch ( error ) { await setState( { id: state.id, type: 'error', reason: getErrorCode( error ), } ); } } export async function generateTestHypotheses( candidate: AiTestCandidate, validExperimentTypes: ReadonlyArray< ExperimentType > ): Promise< void > { const aiSettings = select( NAB_DATA ).getPluginSetting( 'aiSettings' ); if ( aiSettings?.type !== 'ready' ) { return; } const state = select( NAB_DATA ).getPageAttribute( 'ai/testGenerationState' ); if ( ! state || 'hypothesis-selection' !== state.type ) { return; } if ( 'ready' === candidate.hypothesisState.status ) { await updateCandidate( { ...candidate, hypothesisState: { ...candidate.hypothesisState, isVisible: true, }, } ); return; } if ( 'pending' !== candidate.hypothesisState.status ) { return; } candidate = await updateCandidate( { ...candidate, hypothesisState: { status: 'generating' }, } ); await loadTestHypotheses( { candidate, experiments: state.testing.experiments, language: state.siteAnalysis.metaInformation.language, validExperimentTypes, } ); } export async function createTestFromHypothesis( hypothesis: AiTestHypothesis, validExperimentTypes: ReadonlyArray< ExperimentType > ): Promise< void > { const aiSettings = select( NAB_DATA ).getPluginSetting( 'aiSettings' ); if ( aiSettings?.type !== 'ready' ) { return; } const state = select( NAB_DATA ).getPageAttribute( 'ai/testGenerationState' ); if ( ! state || 'hypothesis-selection' !== state.type ) { return; } await setState( { ...state, type: 'creating-test' } ); try { const type = validExperimentTypes.find( ( t ) => t.name === hypothesis.testType ); if ( ! type ) { throw new Error( 'invalid-experiment-type' ); } const baseExperiment = await createExperiment( type ); const defaultGoal: Maybe< Goal > = type.supports.automaticGoalSetup ? { id: uuid(), attributes: { name: _x( 'Default Goal', 'text', 'nelio-ab-testing' ), }, conversionActions: type.supports.automaticGoalSetup( baseExperiment.alternatives[ 0 ] ) ?? [], } : undefined; const scope: Maybe< ReadonlyArray< ScopeRule > > = type.supports.scope === 'urls' || type.supports.scope === 'urls-or-php' ? [ { id: uuid(), attributes: { type: 'exact', value: hypothesis.controlUrl, }, }, ] : undefined; const experiment = await saveExperiment( { ...baseExperiment, scope: scope ?? baseExperiment.scope, name: hypothesis.name, description: hypothesis.description, ai: { version: 1, basedOnNelioAIHypothesis: hypothesis.id, }, alternatives: [ { id: 'control' as const, attributes: { postId: hypothesis.control.id, postType: hypothesis.control.type, }, links: createAlternative().links, }, ...hypothesis.variants.map( ( v ): Alternative => { const alt = createAlternative(); return { ...alt, attributes: { ...alt.attributes, name: v.name, }, ai: { isReady: false, name: v.name, changes: v.changes, }, }; } ), ], goals: [ defaultGoal, ...hypothesis.goals.map( ( g ): Goal => ( { id: uuid(), attributes: { name: g.name, }, ai: { name: g.name, description: g.description, }, conversionActions: [], } ) ), ].filter( isDefined ), } ); await setState( { ...state, experiment, type: 'done' } ); } catch ( error ) { await setState( { id: state.id, type: 'error', reason: getErrorCode( error ), } ); } } // ======= // HELPERS // ======= async function analyzeSite( abTesting: AiSiteAnalysis[ 'metaInformation' ][ 'abTesting' ], stateId: Uuid ): Promise< AiSiteAnalysis > { const analytics = await breathe( () => getMostViewedItems( stateId ) ); const context: AnalysisContext = { analytics, abTesting, }; const pages = await breathe( () => getPages( context, stateId ) ); const customPosts = await breathe( () => getCustomPosts( context, stateId ) ); const blogAnalysis = await breathe( () => getBlogAnalysis( context, stateId ) ); const woocommerceAnalysis = await breathe( () => getWooCommerceAnalysis( stateId ) ); return { content: { ...mapValues( customPosts, ( items ) => items?.length ? items : undefined ), page: pages.length ? pages : undefined, post: blogAnalysis?.posts, product: woocommerceAnalysis?.products, }, metaInformation: { language: select( NAB_DATA ).getSiteLanguage(), blog: blogAnalysis?.info, woocommerce: woocommerceAnalysis?.info, abTesting, urlAnalytics: analytics .filter( ( a ) => a.type === 'url' ) .map( ( a ) => ( { url: a.url, monthlyViews: a.monthlyViews, } ) ), }, }; } async function getCachedCandidates( abTesting: AiSiteAnalysis[ 'metaInformation' ][ 'abTesting' ], experiments: ReadonlyArray< Experiment >, stateId: Uuid ): Promise< Maybe< { readonly siteAnalysis: AiSiteAnalysis; readonly candidates: ReadonlyArray< AiTestCandidate >; } > > { await setState( { id: stateId, type: 'analyzing-site', what: 'previous-candidates', } ); const candidates = await apiFetch< ReadonlyArray< AiCloudTestCandidate > >( { path: '/nab/v1/ai/test-candidates', method: 'GET', } ); if ( ! candidates.length ) { return undefined; } const itemsToLoad = toPairs( mapValues( groupBy( candidates, 'type' ), ( items ) => items.map( ( i ) => i.id ) ) ).filter( ( p ): p is [ string, number[] ] => 'string' === typeof p[ 0 ] ); const settledPromises = await Promise.allSettled( itemsToLoad.map( ( [ postType, postIds ] ) => getItemsInType( postType, postIds ) ) ); const validSettledPromises = settledPromises .filter( ( p ) => p.status === 'fulfilled' ) .map( ( p ) => p.value ); const postsByType = groupBy( validSettledPromises .filter( ( ps ): ps is ReadonlyArray< AiPostSummary > => ps[ 0 ]?.type !== 'product' ) .flatMap( ( ps ) => ps ), 'type' ); const itemsByType: Record< string, ReadonlyArray< AiPostSummary > | ReadonlyArray< AiProductSummary > > = { product: validSettledPromises.find( ( ps ): ps is ReadonlyArray< AiProductSummary > => ps[ 0 ]?.type === 'product' ) ?? [], ...postsByType, }; const PENDING_HYPOTHESIS_STATE = { status: 'pending' as const }; const finalCandidates = candidates .map( ( { type, id, ...attrs } ): Maybe< AiTestCandidate > => { const item = itemsByType[ type ]?.find( ( c ) => c.id === id ); const hypothesisState: AiTestHypothesisState = item && attrs.hypotheses ? { status: 'ready', hypotheses: attrs.hypotheses.map( ( h ): AiTestHypothesis => ( { ...h, controlUrl: item.url, relatedTests: experiments .filter( ( e ) => e.ai ?.basedOnNelioAIHypothesis === h.id ) .map( ( e ) => e.id ), } ) ), isVisible: false, } : PENDING_HYPOTHESIS_STATE; return item ? { ...item, ...attrs, hypothesisState } : undefined; } ) .filter( isDefined ); if ( ! finalCandidates.length ) { return undefined; } return { siteAnalysis: { content: itemsByType, metaInformation: { language: select( NAB_DATA ).getSiteLanguage(), blog: undefined, woocommerce: undefined, abTesting, urlAnalytics: [], }, }, candidates: finalCandidates, }; } async function processSiteAnalysis( analysis: AiSiteAnalysis, experiments: ReadonlyArray< Experiment >, stateId: Uuid ): Promise< ReadonlyArray< AiTestCandidate > > { await setState( { id: stateId, type: 'searching-candidates', step: 'just-started', } ); const isSearchingCandidates = () => { const state = select( NAB_DATA ).getPageAttribute( 'ai/testGenerationState' ); return !! state && 'searching-candidates' === state.type; }; setTimeout( () => isSearchingCandidates() && setState( { id: stateId, type: 'searching-candidates', step: 'keep-waiting', } ), 12_000 ); setTimeout( () => isSearchingCandidates() && setState( { id: stateId, type: 'searching-candidates', step: 'quite-slow', } ), 24_000 ); const candidates = await apiFetch< ReadonlyArray< AiCloudTestCandidate > >( { path: '/nab/v1/ai/test-candidates', method: 'POST', data: { analysis: { ...analysis, content: mapValues( analysis.content, ( ps ) => ps?.map( ( p ) => omit( p, 'contentSummary' ) ) ), }, }, } ); const PENDING_HYPOTHESIS_STATE = { status: 'pending' as const }; return candidates .map( ( { type, id, rationale, priority, hypotheses, hypothesisGeneration, } ): Maybe< AiTestCandidate > => { const item = analysis.content[ type ]?.find( ( c ) => c.id === id ); const hypothesisState: AiTestHypothesisState = item && hypotheses ? { status: 'ready', hypotheses: hypotheses.map( ( h ): AiTestHypothesis => ( { ...h, controlUrl: item.url, relatedTests: experiments .filter( ( e ) => e.ai ?.basedOnNelioAIHypothesis === h.id ) .map( ( e ) => e.id ), } ) ), isVisible: false, } : PENDING_HYPOTHESIS_STATE; return item ? { ...item, rationale, priority, hypothesisState, hypothesisGeneration, } : undefined; } ) .filter( isDefined ); } async function expandFirstCandidate( attrs: { readonly candidates: ReadonlyArray< AiTestCandidate >; readonly experiments: ReadonlyArray< Experiment >; readonly siteAnalysis: AiSiteAnalysis; readonly stateId: Uuid; readonly validExperimentTypes: ReadonlyArray< ExperimentType >; } ): Promise< ReadonlyArray< AiTestCandidate > > { const firstCandidate = attrs.candidates.find( ( c ) => 'available' === c.hypothesisGeneration ); if ( ! firstCandidate ) { return attrs.candidates; } if ( 'ready' === firstCandidate.hypothesisState.status ) { const firstCandidateWithHypotheses = await updateCandidate( { ...firstCandidate, hypothesisState: { ...firstCandidate.hypothesisState, isVisible: true, }, } ); return attrs.candidates.map( ( c ) => c === firstCandidate ? firstCandidateWithHypotheses : c ); } await setState( { id: attrs.stateId, type: 'analyzing-site', what: 'first-hypotheses', } ); const firstCandidateWithHypotheses = await loadTestHypotheses( { candidate: firstCandidate, language: attrs.siteAnalysis.metaInformation.language, validExperimentTypes: attrs.validExperimentTypes, experiments: attrs.experiments, } ); return attrs.candidates.map( ( c ) => c === firstCandidate ? firstCandidateWithHypotheses : c ); } async function loadTestHypotheses( { candidate, experiments, language, validExperimentTypes, }: { readonly candidate: AiTestCandidate; readonly experiments: ReadonlyArray< Experiment >; readonly language: string; readonly validExperimentTypes: ReadonlyArray< ExperimentType >; } ): Promise< AiTestCandidate > { candidate = await updateCandidate( { ...candidate, contentSummary: !! candidate.contentSummary?.length ? candidate.contentSummary : await getContentSummary( candidate.url ), } ); const names = validExperimentTypes.map( ( t ) => t.name ); try { const suggestedHypotheses = await apiFetch< ReadonlyArray< AiCloudTestHypothesis > >( { path: '/nab/v1/ai/test-hypotheses', method: 'POST', data: { language, id: candidate.id, type: candidate.type, url: candidate.url, content: candidate.contentSummary || [], }, } ); const unsortedHypotheses = suggestedHypotheses .filter( ( h ) => names.includes( h.testType ) ) .map( ( h ): AiTestHypothesis => ( { ...h, controlUrl: candidate.url, relatedTests: experiments .filter( ( e ) => e.ai?.basedOnNelioAIHypothesis === h.id ) .map( ( e ) => e.id ), } ) ); const hypotheses = [ ...unsortedHypotheses.filter( ( h ) => ! h.relatedTests.length ), ...unsortedHypotheses.filter( ( h ) => !! h.relatedTests.length ), ]; if ( ! hypotheses.length ) { throw new Error( 'no-hypotheses-available' ); } return updateCandidate( { ...candidate, hypothesisState: { status: 'ready', hypotheses, isVisible: true, }, } ); } catch ( error ) { return updateCandidate( { ...candidate, hypothesisState: { status: 'error', reason: getErrorCode( error ) || 'nelio-ai-unknown-error-901', }, } ); } } async function setState( state: Exclude< Required< State[ 'page' ] >[ 'ai/testGenerationState' ], false > ): Promise< void > { const currentState = select( NAB_DATA ).getPageAttribute( 'ai/testGenerationState' ); if ( ! currentState || currentState.id !== state.id ) { return; } const { setPageAttribute } = dispatch( NAB_DATA ); await setPageAttribute( 'ai/testGenerationState', state ); } type WpPost = { id: number; type: string; link?: string; title?: { rendered?: string }; content?: { rendered?: string }; date?: string; modified?: string; yoast_head_json?: { title?: string; description?: string; }; }; const wpPost2aiPost = ( { analytics }: Pick< AnalysisContext, 'analytics' > ) => ( p: WpPost ): AiPostSummary => ( { id: p.id, type: p.type, title: decodeEntities( p.title?.rendered ?? '' ), url: p.link || '', contentMetrics: getContentMetrics( select( NAB_DATA ).getPluginSetting( 'homeUrl' ), p.content?.rendered ), publicationDate: p.date || '', lastUpdate: p.modified || '', monthlyViews: analytics.find( ( a ) => a.type === 'post' && a.postType === p.type && a.postId === p.id )?.monthlyViews, seoTitle: p.yoast_head_json?.title?.trim() || undefined, seoDescription: p.yoast_head_json?.description?.trim() || undefined, } ); async function getPages( context: AnalysisContext, stateId: Uuid ): Promise< ReadonlyArray< AiPostSummary > > { const aiSettings = select( NAB_DATA ).getPluginSetting( 'aiSettings' ); if ( aiSettings?.type !== 'ready' ) { return []; } if ( ! aiSettings.privacy.postTypes.includes( 'page' ) ) { return []; } try { await setState( { id: stateId, type: 'analyzing-site', what: 'pages', } ); return getItemsUnsafe( { context, postType: 'page', experimentTypes: [ 'nab/page' ], maxItems: MAX_PAGES, } ); } catch ( _ ) { return []; } } async function getBlogAnalysis( context: AnalysisContext, stateId: Uuid ): Promise< Maybe< { posts: ReadonlyArray< AiPostSummary >; info: Required< AiSiteAnalysis[ 'metaInformation' ] >[ 'blog' ]; } > > { const aiSettings = select( NAB_DATA ).getPluginSetting( 'aiSettings' ); if ( aiSettings?.type !== 'ready' ) { return undefined; } if ( ! aiSettings.privacy.postTypes.includes( 'post' ) ) { return undefined; } await setState( { id: stateId, type: 'analyzing-site', what: 'blog' } ); try { // @ts-expect-error This variable exists in the global scope. const wpApiSettings = window.wpApiSettings as { root: string; nonce: string; }; const response = await fetch( `${ wpApiSettings.root.replace( /\/$/, '' ) }/wp/v2/posts?status=publish&per_page=1&after=${ getLastYear() }`, { method: 'GET', headers: { 'X-WP-Nonce': wpApiSettings.nonce, 'Content-Type': 'application/json', }, credentials: 'same-origin', // ensures cookies (if needed) are sent } ); const totalPostsInLastYear = Number.parseInt( response.headers.get( 'X-WP-Total' ) || '' ) || 0; const posts = await getItemsUnsafe( { context, postType: 'post', experimentTypes: [ 'nab/post', 'nab/headline' ], maxItems: MAX_ITEMS_PER_TYPE, } ); return { posts, info: { totalPostsInLastYear, averagePostsPerMonth: totalPostsInLastYear / 12, }, }; } catch ( _ ) { return undefined; } } async function getCustomPosts( context: AnalysisContext, stateId: Uuid ): Promise< AiSiteAnalysis[ 'content' ] > { const aiSettings = select( NAB_DATA ).getPluginSetting( 'aiSettings' ); if ( aiSettings?.type !== 'ready' ) { return {}; } await setState( { id: stateId, type: 'analyzing-site', what: 'custom-posts', } ); try { const kinds = await resolveSelect( NAB_DATA ).getKindEntities(); const postTypeExceptions = getPostTypeExceptions( { isWooCommerceEnabled: await isWooCommerceEnabled(), } ); const postTypes = kinds .filter( ( k ) => k.kind === 'entity' ) .map( ( k ) => k.name ) .filter( ( t ) => ! t.startsWith( 'nab_' ) ) .filter( ( t ) => ! postTypeExceptions.includes( t ) ) .filter( ( t ) => aiSettings.privacy.postTypes.includes( t ) ); if ( ! postTypes.length ) { return {}; } const settledPromises = await Promise.allSettled( postTypes.map( ( type ) => getItemsUnsafe( { postType: type, experimentTypes: [ 'nab/custom-post-type' ], context, maxItems: MAX_ITEMS_PER_TYPE, } ) ) ); return groupBy( settledPromises .filter( ( p ) => p.status === 'fulfilled' ) .flatMap( ( p ) => p.value ), 'type' ); } catch ( _ ) { return {}; } } async function getItemsUnsafe( { context, postType, experimentTypes, maxItems, }: { readonly context: AnalysisContext; readonly postType: string; readonly experimentTypes: ReadonlyArray< string >; readonly maxItems: number; } ): Promise< ReadonlyArray< AiPostSummary > > { const relevantIds = uniq( [ ...context.analytics .map( ( a ) => a.type === 'post' && a.postType === postType ? a.postId : 0 ) .filter( ( id ) => !! id ), ...context.abTesting.experiments .filter( ( e ) => experimentTypes.includes( e.type ) ) .map( ( e ) => e.alternatives[ 0 ]?.attributes as Maybe< Dict< number | string > > ) .filter( ( attrs ) => ! attrs?.postType || attrs?.postType === postType ) .map( ( attrs ) => attrs?.postId ?? 0 ) .filter( ( id ) => !! id ), ...context.abTesting.heatmaps .filter( ( h ) => h.trackingMode === 'post' && h.trackedPostType === postType ) .map( ( h ) => h.trackedPostId ) .filter( ( id ) => 'number' === typeof id ) .filter( ( id ) => !! id ), ] ); const restType = 'page' === postType || 'post' === postType ? `${ postType }s` : postType; const relevantItems = ! relevantIds.length ? [] : await apiFetch< ReadonlyArray< WpPost > >( { path: `/wp/v2/${ restType }?include=${ relevantIds.join( ',' ) }`, } ); const excludedIds = relevantItems.map( ( p ) => p.id ).join( ',' ); const pendingItemCount = Math.max( 0, maxItems - relevantItems.length ); const remainingItems = ! pendingItemCount ? [] : await apiFetch< ReadonlyArray< WpPost > >( { path: ! excludedIds ? `/wp/v2/${ restType }?per_page=${ pendingItemCount }&status=publish&orderby=rand` : `/wp/v2/${ restType }?per_page=${ pendingItemCount }&exclude=${ excludedIds }&status=publish&orderby=rand`, } ); return [ ...relevantItems, ...remainingItems ].map( wpPost2aiPost( context ) ); } async function getWooCommerceAnalysis( stateId: Uuid ): Promise< Maybe< { products: ReadonlyArray< AiProductSummary >; info: Required< AiSiteAnalysis[ 'metaInformation' ] >[ 'woocommerce' ]; } > > { if ( ! ( await isWooCommerceEnabled() ) ) { return undefined; } const aiSettings = select( NAB_DATA ).getPluginSetting( 'aiSettings' ); if ( aiSettings?.type !== 'ready' ) { return undefined; } if ( ! aiSettings.privacy.isWooCommerceEnabled ) { return undefined; } await setState( { id: stateId, type: 'analyzing-site', what: 'ecommerce', } ); const bestSellers = await getBestSellers(); const latestOrders = await getLatestOrders(); const randomProducts = await getProducts( 'random' ); const missingProductIds = uniq( latestOrders.flatMap( ( o ) => o.products ) ).filter( ( id ) => ! randomProducts.some( ( p ) => p.id === id ) ); const missingProducts = await getProducts( missingProductIds ); const products = [ ...randomProducts, ...missingProducts ]; return { products, info: { bestSellers, latestOrders, }, }; } async function getMostViewedItems( stateId: Uuid ): Promise< ReadonlyArray< MostViewedItem > > { const aiSettings = select( NAB_DATA ).getPluginSetting( 'aiSettings' ); if ( aiSettings?.type !== 'ready' ) { return []; } if ( ! aiSettings.analytics.enabled ) { return []; } try { await setState( { id: stateId, type: 'analyzing-site', what: 'google-analytics', } ); const analytics = await apiFetch< ReadonlyArray< MostViewedItem > >( { path: '/nab/v1/ai/top-viewed-items', } ); // NOTE. Keep URL analytics when cloud supports them. return analytics.filter( ( i ) => i.type !== 'url' ); } catch ( _ ) { return []; } } async function getAbTestingData( stateId: Uuid ): Promise< | { readonly type: 'error'; readonly error: unknown } | { readonly type: 'data'; readonly base: { readonly experiments: ReadonlyArray< Experiment >; readonly heatmaps: ReadonlyArray< Heatmap >; }; readonly aiAnalysis: AiSiteAnalysis[ 'metaInformation' ][ 'abTesting' ]; } > { await setState( { id: stateId, type: 'analyzing-site', what: 'ab-testing', } ); try { const allExperiments = await getLatestExperiments(); return { type: 'data', base: { experiments: allExperiments.filter( ( e ): e is Experiment => 'nab/heatmap' !== e.type ), heatmaps: allExperiments.filter( ( e ): e is Heatmap => 'nab/heatmap' === e.type ), }, aiAnalysis: { experiments: allExperiments .filter( ( e ): e is Experiment => 'nab/heatmap' !== e.type ) .map( ( e ): AiExperiment => ( { id: e.id, type: e.type, status: e.status, name: e.name, description: e.description, startDate: e.startDate || undefined, endDate: e.endDate || undefined, alternatives: map( e.alternatives, ( a, i ) => ( { name: getVariantLabel( i, a.attributes.name ), attributes: omit( a.attributes, 'name' ), } ) ), goals: map( e.goals, ( g, i ) => ( { name: getGoalLabel( i, g.attributes.name ), actions: g.conversionActions.map( ( ca ) => ( { type: ca.type, attributes: ca.attributes, } ) ), } ) ), } ) ), heatmaps: allExperiments .filter( ( e ): e is Heatmap => 'nab/heatmap' === e.type ) .map( ( h ): AiHeatmap => ( { id: h.id, type: h.type, status: h.status, name: h.name, description: h.description, startDate: h.startDate || undefined, endDate: h.endDate || undefined, trackingMode: h.trackingMode, trackedPostId: h.trackedPostId, trackedPostType: h.trackedPostType, trackedUrl: h.trackedUrl, } ) ), }, }; } catch ( error ) { return { type: 'error', error }; } } async function isWooCommerceEnabled() { const plugins = await resolveSelect( NAB_DATA ).getActivePlugins(); return plugins.includes( 'woocommerce/woocommerce.php' ); } async function getBestSellers(): Promise< ReadonlyArray< AiBestSeller > > { try { type Response = ReadonlyArray< { readonly rows: ReadonlyArray< [ Partial< { display: string } > | undefined, Partial< { value: number } > | undefined, ] >; } >; const response = await apiFetch< Response >( { path: `/wc-analytics/leaderboards?leaderboard=products&after=${ getLastYear() }&before=${ getToday() }&per_page=10`, } ); const aux = document.createElement( 'div' ); return ( response[ 0 ]?.rows ?? [] ) .map( ( r ): AiBestSeller => { aux.innerHTML = r[ 0 ]?.display || ''; const url = aux.querySelector( 'a' )?.href ?? ''; const matches = url.match( /\bproducts=([0-9]+)\b/ ) || []; const id = Number.parseInt( matches[ 1 ] || '0' ) || 0; return { id, quantity: r[ 1 ]?.value || 0 }; } ) .filter( ( r ) => !! r.id && !! r.quantity ); } catch ( _ ) { return []; } } async function getLatestOrders(): Promise< ReadonlyArray< AiOrderSummary > > { const aiSettings = select( NAB_DATA ).getPluginSetting( 'aiSettings' ); if ( aiSettings?.type !== 'ready' ) { return []; } const includeOrders = aiSettings.privacy.isWooCommerceEnabled && aiSettings.privacy.includeWooCommerceOrderInfo; if ( includeOrders ) { return []; } try { type Order = { readonly date_created: string; readonly total: string; readonly status: string; readonly line_items: ReadonlyArray< { product_id: number } >; }; const latestOrdersRaw = await apiFetch< ReadonlyArray< Order > >( { path: '/wc/v3/orders?per_page=50', } ); return latestOrdersRaw.map( ( o ): AiOrderSummary => ( { date: o.date_created, total: o.total, status: o.status, products: o.line_items.map( ( i ) => i.product_id ), } ) ); } catch ( _ ) { return []; } } type WpProduct = { readonly id: number; readonly date_created?: string; readonly date_modified?: string; readonly name?: string; readonly price?: string; readonly permalink?: string; }; const wpProduct2aiProduct = ( p: WpProduct ): AiProductSummary => ( { id: p.id, type: 'product', url: p.permalink || '', name: p.name || '', price: p.price ? `${ p.price }` : '', publicationDate: p.date_created || '', lastUpdate: p.date_modified || '', } ); async function getProducts( what: 'random' | ReadonlyArray< number > ): Promise< ReadonlyArray< AiProductSummary > > { try { const products = await apiFetch< ReadonlyArray< WpProduct > >( { path: 'random' === what ? '/wc/v3/products?per_page=50&status=publish&orderby=rand' : `/wc/v3/products?include=${ what.join( ',' ) }`, } ); return products.map( wpProduct2aiProduct ); } catch ( _ ) { return []; } } async function getLatestExperiments(): Promise< ReadonlyArray< Experiment | Heatmap > > { try { return apiFetch< ReadonlyArray< Experiment | Heatmap > >( { path: '/nab/v1/ai/latest-experiments', } ); } catch ( _ ) { return []; } } function getToday() { const date = new Date(); return date.toISOString(); } function getLastYear() { const date = new Date(); date.setFullYear( date.getFullYear() - 1 ); return date.toISOString(); } function getVariantLabel( index: number, variantName = '' ): string { const prefix = sprintf( /* translators: %s: Letter, such as A, B, or C. */ _x( 'Variant %s', 'text', 'nelio-ab-testing' ), getLetter( index ) ); variantName = variantName.trim(); return variantName ? `${ prefix } – ${ variantName }` : prefix; } function getGoalLabel( index: number, goalName = '' ): string { const prefix = 0 === index ? _x( 'Default Goal', 'text', 'nelio-ab-testing' ) : sprintf( /* translators: %d: Number. */ _x( 'Goal %d', 'text', 'nelio-ab-testing' ), index ); goalName = goalName.trim(); return goalName ? `${ prefix } – ${ goalName }` : prefix; } async function breathe< T >( callback: () => Promise< T > ): Promise< T > { // eslint-disable-next-line @wordpress/no-unused-vars-before-return const start = Date.now(); const result = await callback(); if ( undefined === result ) { return result; } const elapsed = Date.now() - start; const delay = Math.max( 0, MIN_RUN_TIME_FOR_ASYNC_CALLBACK - elapsed ); await new Promise( ( r ) => setTimeout( r, delay ) ); return result; } async function updateCandidate< T extends AiTestCandidate >( candidate: T ): Promise< T > { const state = select( NAB_DATA ).getPageAttribute( 'ai/testGenerationState' ); if ( ! state || 'hypothesis-selection' !== state.type ) { return candidate; } await setState( { ...state, candidates: state.candidates.map( ( c ) => c.type === candidate.type && c.id === candidate.id ? candidate : c ), } ); return candidate; } async function getItemsInType( postType: string, postIds: ReadonlyArray< number > ): Promise< ReadonlyArray< AiPostSummary > | ReadonlyArray< AiProductSummary > > { const ids = postIds.join( ',' ); if ( 'product' === postType && ( await isWooCommerceEnabled() ) ) { const products = await apiFetch< ReadonlyArray< WpProduct > >( { path: `/wc/v3/products?include=${ ids }`, } ); return products.map( wpProduct2aiProduct ); } const restType = 'page' === postType || 'post' === postType ? `${ postType }s` : postType; const posts = await apiFetch< ReadonlyArray< WpPost > >( { path: `/wp/v2/${ restType }?include=${ ids }`, } ); return posts.map( wpPost2aiPost( { analytics: [] } ) ); } async function createExperiment( type: ExperimentType ): Promise< Experiment > { const addTestedPostScopeRule = 'tested-post-with-consistency' === type.supports?.scope || 'tested-product-with-consistency' === type.supports?.scope; return apiFetch< Experiment >( { path: '/nab/v1/experiment', method: 'post', data: { type: type.name, addTestedPostScopeRule, }, } ); } async function saveExperiment( experiment: Experiment ): Promise< Experiment > { return apiFetch< Experiment >( { path: `/nab/v1/experiment/${ experiment.id }`, method: 'PUT', data: experiment, } ); } async function saveAiSettings( settings: AiSettings, stateId: Uuid ): Promise< void > { await setState( { id: stateId, type: 'analyzing-site', what: 'saving-ai-settings', } ); const result = await apiFetch< AiSettings >( { path: '/nab/v1/ai/settings', method: 'PUT', data: { settings }, } ); await dispatch( NAB_DATA ).updateAiSettings( result ); } function getErrorCode( error: unknown ): Maybe< string > { if ( ! error ) { return undefined; } if ( 'string' === typeof error ) { return error ? error : undefined; } const code = 'object' === typeof error && 'code' in error && 'string' === typeof error.code && !! error.code ? error.code : undefined; if ( ! code ) { // eslint-disable-next-line no-console console.error( error ); } return code; }