OwlCyberSecurity - MANAGER
Edit File: select-template.js
import { useState, useCallback, useEffect, useMemo, useRef, useReducer, } from '@wordpress/element'; import { twMerge } from 'tailwind-merge'; import { useSelect, useDispatch } from '@wordpress/data'; import apiFetch from '@wordpress/api-fetch'; import NavigationButtons from '../components/navigation-buttons'; import { classNames, toastBody } from '../helpers'; import { STORE_KEY } from '../store'; import { ColumnItem } from '../components/column-item'; import Input from '../components/input'; import { ChevronUpIcon, MagnifyingGlassIcon, XMarkIcon, } from '@heroicons/react/24/outline'; import { useForm } from 'react-hook-form'; import { useDebounce } from '../hooks/use-debounce'; import ColumnSkeleton from '../components/column-skeleton'; import { clearSessionStorage, getFromSessionStorage, setToSessionStorage, } from '../utils/helpers'; import { useNavigateSteps } from '../router'; import Button from '../components/button'; import LoadingSpinner from '../components/loading-spinner'; import { __ } from '@wordpress/i18n'; import toast from 'react-hot-toast'; import Heading from '../components/heading'; import { SelectTemplatePageBuilderDropdown } from '../components/page-builder-dropdown'; import { debounce } from 'lodash'; export const USER_KEYWORD = 'st-template-search'; export const getRandomUniqueId = () => Math.random().toString( 16 ).substring( 3 ); const DESIGN_LOAD_BATCH_COUNT = 4; // how many templates to load at once const SelectTemplate = () => { const { previousStep } = useNavigateSteps(); const { setWebsiteTemplatesAIStep, setWebsiteSelectedTemplateAIStep, setWebsiteTemplateSearchResultsAIStep, setSelectedTemplateIsPremium, setSelectedPageBuilder, } = useDispatch( STORE_KEY ); const { stepsData: { businessName, businessType, templateSearchResults, templateList: allTemplates, templateKeywords: keywords = [], }, } = useSelect( ( select ) => { const { getAIStepData, getAllPatternsCategories, getOnboardingAI } = select( STORE_KEY ); const onboardingAI = getOnboardingAI(); return { stepsData: getAIStepData(), allPatternsCategories: getAllPatternsCategories(), isNewUser: onboardingAI?.isNewUser, }; }, [] ); const { register, handleSubmit, formState: { errors }, reset, setFocus, watch, getValues, } = useForm( { defaultValues: { keyword: getFromSessionStorage( USER_KEYWORD ) ?? keywords?.join( ', ' ) ?? '', }, } ); const watchedKeyword = watch( 'keyword' ); const debouncedKeyword = useDebounce( watchedKeyword, 300 ); const [ isFetching, setIsFetching ] = useState( false ); const [ backToTop, setBackToTop ] = useState( false ); const [ selectedBuilder, setSelectedBuilder ] = useState( 'spectra' ); useEffect( () => { setSelectedPageBuilder( selectedBuilder === 'spectra' ? 'gutenberg' : selectedBuilder ); }, [ selectedBuilder ] ); const parentContainer = useRef( null ); const templatesContainer = useRef( null ); const abortRequest = useRef( [] ); // Batch template loading state const [ designLoadState, setDesignLoadState ] = useState( { toLoad: [], loaded: [], } ); const designToLoadList = designLoadState.toLoad; const [ loadMoreTemplates, setLoadMoreTemplates ] = useReducer( ( state, updatedState ) => { return { ...state, ...updatedState, }; }, { page: 1, loading: false, showLoadMore: false, } ); const TEMPLATE_TYPE = { RECOMMENDED: 'recommended', PARTIAL: 'partial', GENERIC: 'generic', }; const refinedSearchResults = useMemo( () => { if ( ! templateSearchResults?.length ) { return []; } return templateSearchResults.reduceRight( ( acc, item, index ) => { if ( ! item.designs?.length ) { return acc; } const otherDesigns = acc .filter( ( designItem ) => item.match !== designItem.match ) .flatMap( ( otherItem ) => otherItem.designs ); const updatedDesigns = item.designs.filter( ( designItem ) => ! otherDesigns.find( ( otherDesign ) => otherDesign.uuid === designItem.uuid ) ); acc[ index ] = { ...item, designs: updatedDesigns }; return acc; }, templateSearchResults ); }, [ templateSearchResults ] ); const getTemplates = useCallback( ( type ) => { const { RECOMMENDED, GENERIC, PARTIAL } = TEMPLATE_TYPE; switch ( type ) { case RECOMMENDED: return refinedSearchResults?.[ 0 ]?.designs || []; case PARTIAL: return refinedSearchResults?.[ 1 ]?.designs || []; case GENERIC: return refinedSearchResults?.[ 2 ]?.designs || []; } }, [ refinedSearchResults ] ); const getInitialUserKeyword = () => { const type = businessType.toLowerCase(); if ( type !== 'others' ) { return type; } else if ( keywords?.length > 0 ) { return keywords[ 0 ]; } return businessName; }; const handleHiddenTemplates = ( result ) => { // Hide ecommerce templates if ecommerce is disabled in the AI Builder settings. const hideEcommerceTemplates = aiBuilderVars?.hide_site_features?.includes( 'ecommerce' ); // Hide premium templates if `show_premium_template` is false. const hidePremiumTemplates = ! aiBuilderVars?.show_premium_templates; if ( hidePremiumTemplates ) { result = result.map( ( item ) => { return { ...item, designs: item?.designs?.filter( ( template ) => ! template.is_premium ), }; } ); } if ( hideEcommerceTemplates ) { result = result.map( ( item ) => { return { ...item, designs: item?.designs?.filter( ( template ) => template.features.ecommerce !== 'yes' ), }; } ); } return result; }; // Called when an iframe (template preview) finishes loading const onIframeLoaded = ( uniqueId ) => { setDesignLoadState( ( state ) => { const updatedToLoad = state.toLoad.filter( ( id ) => id !== uniqueId ); const updatedLoaded = [ ...state.loaded, uniqueId ]; // Load next batch if current batch is fully loaded if ( updatedToLoad.length === 0 ) { const remainingToLoad = allTemplates .filter( ( template ) => ! updatedLoaded.includes( template.uniqueId ) && template.uniqueId !== uniqueId ) .map( ( template ) => template.uniqueId ) .slice( 0, DESIGN_LOAD_BATCH_COUNT ); return { toLoad: remainingToLoad, loaded: updatedLoaded, }; } return { toLoad: updatedToLoad, loaded: updatedLoaded, }; } ); }; const fetchTemplates = async ( keyword = getInitialUserKeyword(), isPageBuilderChanged = false, _selectedBuilder = selectedBuilder ) => { if ( ! keyword ) { return; } try { setIsFetching( true ); if ( abortRequest.current.length ) { abortRequest.current.forEach( ( controller ) => { controller.abort(); } ); abortRequest.current = []; } setWebsiteTemplatesAIStep( [] ); const finalKeywords = [ ...new Set( keyword .split( ',' ) .map( ( item ) => item.trim()?.toLowerCase() ) ), ]; let results = []; const allTemplatesList = []; // Create a single abort controller for the combined request const abortController = new AbortController(); abortRequest.current.push( abortController ); // Send all keywords in a single request const response = await apiFetch( { path: 'zipwp/v1/templates', method: 'POST', data: { keywords: finalKeywords, // Send array of keywords business_name: businessName, page_builder: _selectedBuilder, }, signal: abortController.signal, } ); let result = response?.data?.data || []; // Filter out Hidden templates based on the settings. result = handleHiddenTemplates( result ); results = result.map( ( item ) => { if ( Array.isArray( item.designs ) ) { item.designs = item.designs.map( ( design ) => ( { ...design, uniqueId: getRandomUniqueId(), } ) ); return item; } return { ...item, designs: [], }; } ); // Get the designs in sequence result.forEach( ( item ) => { if ( Array.isArray( item.designs ) ) { allTemplatesList.push( ...item.designs.filter( ( template ) => ! allTemplatesList.find( ( existingTemplate ) => existingTemplate.uuid === template.uuid ) ) ); } } ); setWebsiteTemplatesAIStep( [ ...allTemplatesList ] ); setWebsiteTemplateSearchResultsAIStep( [ ...results ] ); setIsFetching( false ); const isEmptyResults = allTemplatesList.length === 0; let showLoadMoreTemplates = true; if ( isEmptyResults ) { showLoadMoreTemplates = false; } setLoadMoreTemplates( { showLoadMore: showLoadMoreTemplates, page: 1, } ); // Add templates to load list const templateIdList = allTemplatesList.map( ( { uniqueId } ) => uniqueId ); setDesignLoadState( ( state ) => { return { ...state, loaded: isPageBuilderChanged ? [] : state.loaded, toLoad: templateIdList.slice( 0, DESIGN_LOAD_BATCH_COUNT ), }; } ); if ( allTemplatesList.length < 4 ) { fetchAllTemplatesByPage( 1, { searchResults: results, templateList: allTemplatesList, showLoadMoreTemplates: loadMoreTemplates.showLoadMore, } ); } } catch ( error ) { if ( error?.name === 'AbortError' ) { return; } setIsFetching( false ); } }; const fetchAllTemplatesByPage = async ( page = 1, { searchResults = templateSearchResults, templateList = allTemplates, _showLoadMoreTemplates = loadMoreTemplates, } = {} ) => { try { if ( loadMoreTemplates.loading || ! _showLoadMoreTemplates ) { return; } setLoadMoreTemplates( { loading: true } ); const response = await apiFetch( { path: 'zipwp/v1/all-templates', method: 'POST', data: { business_name: businessName, page_builder: selectedBuilder, per_page: 9, page, }, } ); if ( ! response.success ) { throw new Error( response?.data?.data ?? __( 'Error while fetching templates', 'ai-builder' ) ); } let result = response?.data?.data?.result || []; const lastPage = response?.data?.data?.lastPage || 1; // Filter out Hidden templates based on the settings. result = handleHiddenTemplates( result ); // Get the the designs in sequence const allTemplatesList = templateList ? [ ...templateList ] : []; const newSearchResults = searchResults ? [ ...searchResults ] : []; const allTemplatesListUUID = allTemplatesList.map( ( template ) => template.uuid ); // There might be duplicates with same uuid, so add a uniqueId for each result = result.map( ( item ) => { if ( Array.isArray( item.designs ) ) { item.designs = item.designs.map( ( design ) => { if ( allTemplatesListUUID.includes( design.uuid ) ) { return false; } // else return the design with a uniqueId return { ...design, uniqueId: getRandomUniqueId(), }; } ); item.designs = item.designs.filter( ( design ) => design ); return item; } return { ...item, designs: [], }; } ); result.forEach( ( item ) => { if ( Array.isArray( item.designs ) ) { allTemplatesList.push( ...item.designs ); } newSearchResults.map( ( searchResult, ind ) => { if ( searchResult.match === item.match ) { searchResult.designs = [ ...searchResult.designs, ...item.designs.filter( ( itm ) => ! searchResult.designs.some( ( existingItem ) => existingItem.uuid === itm.uuid ) ), ]; } newSearchResults[ ind ] = searchResult; return searchResult; } ); } ); setWebsiteTemplatesAIStep( allTemplatesList ); setWebsiteTemplateSearchResultsAIStep( newSearchResults ); const templateIdList = allTemplatesList.map( ( { uniqueId } ) => uniqueId ); // If toLoad list is empty, add new templates to load setDesignLoadState( ( state ) => { const newLoadList = templateIdList .filter( ( uniqueId ) => ! state.loaded.includes( uniqueId ) ) .slice( 0, DESIGN_LOAD_BATCH_COUNT ); const isToLoadListEmpty = state.toLoad.length === 0; const toLoad = isToLoadListEmpty ? newLoadList : state.toLoad; return { ...state, toLoad, }; } ); const isEmptyResults = result.every( ( item ) => item.designs.length === 0 ); if ( page === lastPage || isEmptyResults ) { setLoadMoreTemplates( { showLoadMore: false } ); } setIsFetching( false ); setLoadMoreTemplates( { page: loadMoreTemplates.page + 1, } ); } catch ( error ) { setIsFetching( false ); toast.error( toastBody( { message: error?.message?.toString(), } ) ); } finally { setLoadMoreTemplates( { loading: false } ); } }; useEffect( () => { setFocus( 'keyword' ); // Save the manually entered keyword to session storage. return () => { const keyword = getValues( 'keyword' ); if ( ! keyword || keywords.some( ( item ) => item?.toLowerCase() === keyword?.toLowerCase() ) ) { return clearSessionStorage( USER_KEYWORD ); } setToSessionStorage( USER_KEYWORD, keyword ); }; }, [] ); const resetState = () => { // clear the state before calling the API setLoadMoreTemplates( { page: 1, loading: false, showLoadMore: false, } ); setDesignLoadState( { toLoad: [], loaded: [], } ); setWebsiteTemplatesAIStep( [] ); setWebsiteTemplateSearchResultsAIStep( [] ); setSelectedTemplateIsPremium( '' ); setWebsiteSelectedTemplateAIStep( '' ); setBackToTop( false ); }; const fetchNewTemplates = ( isPageBuilderChanged = false ) => { resetState(); fetchTemplates( debouncedKeyword ? debouncedKeyword : getInitialUserKeyword(), isPageBuilderChanged ); }; useEffect( () => { fetchNewTemplates( false ); }, [ debouncedKeyword ] ); const handleSubmitKeyword = ( { keyword } ) => { onChangeKeyword( keyword ); }; const handleClearSearch = () => { if ( ! watchedKeyword ) { return; } reset( { keyword: '' } ); onChangeKeyword( getInitialUserKeyword() ); }; const onChangeKeyword = ( value = '' ) => { resetState(); fetchTemplates( value ); }; const renderTemplates = useMemo( () => { const recommendedTemplates = getTemplates( TEMPLATE_TYPE.RECOMMENDED ); const partialTemplates = getTemplates( TEMPLATE_TYPE.PARTIAL ); const genericTemplates = getTemplates( TEMPLATE_TYPE.GENERIC ); if ( ! recommendedTemplates?.length && ! partialTemplates?.length && ! genericTemplates?.length ) { return null; } const recommendedTemplateIdSet = new Set( recommendedTemplates.map( ( { uuid } ) => uuid ) ); const partialTemplateIdSet = new Set( partialTemplates.map( ( { uuid } ) => uuid ) ); const filteredGenericTemplates = genericTemplates.filter( ( { uuid } ) => ! recommendedTemplateIdSet.has( uuid ) && ! partialTemplateIdSet.has( uuid ) ); return ( <> { recommendedTemplates?.map( ( template, index ) => ( <ColumnItem key={ template.uniqueId } template={ template } position={ index + 1 } onIframeLoaded={ onIframeLoaded } // callback to track load shouldLoad={ designToLoadList.includes( template.uniqueId ) } /> ) ) } { partialTemplates?.map( ( template, index ) => ( <ColumnItem key={ template.uniqueId } template={ template } position={ index + 1 + ( recommendedTemplates?.length || 0 ) } onIframeLoaded={ onIframeLoaded } // callback to track load shouldLoad={ designToLoadList.includes( template.uniqueId ) } /> ) ) } { filteredGenericTemplates?.map( ( template, index ) => ( <ColumnItem key={ template.uniqueId } template={ template } position={ index + 1 + ( ( recommendedTemplates?.length || 0 ) + ( partialTemplates?.length || 0 ) ) } onIframeLoaded={ onIframeLoaded } // callback to track load shouldLoad={ designToLoadList.includes( template.uniqueId ) } /> ) ) } </> ); }, [ getTemplates, designToLoadList ] ); const handleClickBackToTop = () => { parentContainer.current.scrollTo( { top: 0, behavior: 'smooth', } ); }; const lastVisibleTemplateCheckTop = useRef( 0 ); const checkForVisibleTemplates = ( target ) => { const { scrollTop, clientHeight } = target; if ( lastVisibleTemplateCheckTop.current === scrollTop ) { return; } lastVisibleTemplateCheckTop.current = scrollTop; const designs = target.querySelectorAll( '.design-template' ); const TOP_SEARCHBAR_HEIGHT = 80; const BOTTOM_NAVBAR_HEIGHT = 80; const actualClientHeight = clientHeight - TOP_SEARCHBAR_HEIGHT; const actualScrollTop = scrollTop - TOP_SEARCHBAR_HEIGHT; const actualScrollBottom = actualScrollTop + actualClientHeight - BOTTOM_NAVBAR_HEIGHT; const designList = Array.from( designs ).map( ( design ) => ( { middlePoint: design.offsetTop + design.clientHeight / 2, uniqueId: design.getAttribute( 'data-template-unique-id' ), } ) ); const visibleTemplateIdList = designList .filter( ( design ) => { return ( design.middlePoint >= actualScrollTop && design.middlePoint <= actualScrollBottom // Middle point is within the range ); } ) .map( ( { uniqueId } ) => uniqueId ); setDesignLoadState( ( state ) => { const filterVisibleList = visibleTemplateIdList.filter( ( uniqueId ) => ! ( state.loaded.includes( uniqueId ) && ! state.toLoad.includes( uniqueId ) ) ); return { ...state, toLoad: [ ...state.toLoad, ...filterVisibleList ], }; } ); }; // Wait X amount of time after user scrolls and then checkForVisibleTemplates const debouncedCheckForVisibleTemplates = debounce( ( target ) => { checkForVisibleTemplates( target ); }, 2000 ); const handleScroll = ( event ) => { const SCROLL_THRESHOLD = 100; const { scrollTop } = event.target; if ( scrollTop > SCROLL_THRESHOLD && ! backToTop ) { setBackToTop( true ); } if ( scrollTop <= SCROLL_THRESHOLD && backToTop ) { setBackToTop( false ); } debouncedCheckForVisibleTemplates( event.target ); }; return ( <div ref={ parentContainer } className={ twMerge( `mx-auto flex flex-col overflow-x-hidden`, 'w-full' ) } onScroll={ handleScroll } > <Heading heading={ __( 'Choose the Design', 'ai-builder' ) } className="px-5 md:px-10 lg:px-14 xl:px-15 pt-5 md:pt-10 lg:pt-8 xl:pt-8 max-w-fit mx-auto" /> <form className="w-full pt-4 pb-4 max-w-[37.5rem] mx-auto" onSubmit={ handleSubmit( handleSubmitKeyword ) } > <div className={ classNames( 'flex w-full bg-white gap-2 items-center rounded-md shadow-sm border border-border-tertiary flex-col-reverse md:flex-row' ) } > <SelectTemplatePageBuilderDropdown selectedBuilder={ selectedBuilder } onChange={ ( builder ) => { setSelectedBuilder( builder.id ); fetchTemplates( watchedKeyword ? watchedKeyword : getInitialUserKeyword(), true, builder.id ); } } /> <Input name="keyword" inputClassName={ 'pr-11 pl-2' } register={ register } placeholder={ __( 'Add a keyword', 'ai-builder' ) } height="12" className="w-full h-12" noBorder={ true } error={ errors?.keyword } suffixIcon={ <div className="absolute right-4 flex items-center"> <button type="button" className="w-auto h-auto p-0 flex items-center justify-center cursor-pointer bg-transparent border-0 focus:outline-none" onClick={ handleClearSearch } > { watchedKeyword ? ( <XMarkIcon className="w-5 h-5 text-zip-app-inactive-icon" /> ) : ( <MagnifyingGlassIcon className="w-5 h-5 text-zip-app-inactive-icon" /> ) } </button> </div> } /> </div> </form> <div ref={ templatesContainer } className={ classNames( 'custom-confirmation-modal-scrollbar', // class for thin scrollbar 'relative', 'px-5 md:px-10 lg:px-14 xl:px-15', 'xl:max-w-full' ) } > <div ref={ templatesContainer } className={ classNames( 'grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 auto-rows-auto items-start justify-center gap-4 sm:gap-6 mb-10' ) } > { ! isFetching ? renderTemplates : Array.from( { length: 6 } ).map( ( _, index ) => ( <ColumnSkeleton key={ `skeleton-${ index }` } /> ) ) } </div> </div> { loadMoreTemplates.showLoadMore && ( <div className="align-center flex justify-center sm:pb-0 pb-40"> <Button className="min-w-[188px] min-h-[50px]" variant="primary" onClick={ () => { if ( loadMoreTemplates.loading ) { return; } fetchAllTemplatesByPage( loadMoreTemplates.page ); } } disabled={ loadMoreTemplates.loading } > { loadMoreTemplates.loading ? ( <LoadingSpinner /> ) : ( __( 'Load More Designs', 'ai-builder' ) ) } </Button> </div> ) } { backToTop && ( <div className="absolute right-20 bottom-28 ml-auto"> <button type="button" className="absolute bottom-0 right-0 z-10 w-8 h-8 rounded-full bg-accent-st border-0 border-solid text-white flex items-center justify-center shadow-sm cursor-pointer" onClick={ handleClickBackToTop } > <ChevronUpIcon className="w-5 h-5" /> </button> </div> ) } <div className="fixed sm:sticky bottom-0 w-full bg-container-background py-4.75 px-5 md:px-10 lg:px-14 xl:px-15"> <NavigationButtons onClickPrevious={ previousStep } hideContinue /> </div> </div> ); }; export default SelectTemplate;