/** * External dependencies */ import { useAnalytics } from '@automattic/jetpack-shared-extension-utils'; import { Modal, Button } from '@wordpress/components'; import { useDispatch, select } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import clsx from 'clsx'; import debugFactory from 'debug'; import { useState, useEffect, useCallback, useRef } from 'react'; /** * Internal dependencies */ import AiModalFooter from '../../components/ai-modal-footer/index.tsx'; import { DEFAULT_LOGO_COST, EVENT_MODAL_OPEN, EVENT_FEEDBACK, EVENT_MODAL_CLOSE, EVENT_GENERATE, } from '../constants.ts'; import { useCheckout } from '../hooks/use-checkout.ts'; import useLogoGenerator from '../hooks/use-logo-generator.ts'; import useRequestErrors from '../hooks/use-request-errors.ts'; import { isLogoHistoryEmpty, clearDeletedMedia } from '../lib/logo-storage.ts'; import { STORE_NAME } from '../store/index.ts'; import { FeatureFetchFailureScreen } from './feature-fetch-failure-screen.tsx'; import { FirstLoadScreen } from './first-load-screen.tsx'; import { HistoryCarousel } from './history-carousel.tsx'; import { LogoPresenter } from './logo-presenter.tsx'; import { Prompt } from './prompt.tsx'; import { UpgradeScreen } from './upgrade-screen.tsx'; import { VisitSiteBanner } from './visit-site-banner.tsx'; import './generator-modal.scss'; /** * Types */ import type { GeneratorModalProps } from '../types.ts'; import type { FC, ReactNode } from 'react'; const debug = debugFactory( 'jetpack-ai-calypso:generator-modal' ); export const GeneratorModal: FC< GeneratorModalProps > = ( { isOpen, onClose, onApplyLogo, onReload = null, siteDetails, context, placement, } ) => { const { tracks } = useAnalytics(); const { recordEvent: recordTracksEvent } = tracks; const { setSiteDetails, fetchAiAssistantFeature, loadLogoHistory, setIsLoadingHistory } = useDispatch( STORE_NAME ); const { getIsRequestingAiAssistantFeature } = select( STORE_NAME ); const [ loadingState, setLoadingState ] = useState< 'loadingFeature' | 'analyzing' | 'generating' | null >( null ); const [ initialPrompt, setInitialPrompt ] = useState< string | undefined >(); const needsToHandleModalOpen = useRef< boolean >( true ); const requestedFeatureData = useRef< boolean >( false ); const [ needsFeature, setNeedsFeature ] = useState( false ); const [ needsMoreRequests, setNeedsMoreRequests ] = useState( false ); const { selectedLogo, getAiAssistantFeature, generateFirstPrompt, generateLogo, setContext, tierPlansEnabled, site, requireUpgrade, } = useLogoGenerator(); const { featureFetchError, setFeatureFetchError, firstLogoPromptFetchError, clearErrors } = useRequestErrors(); const siteId = siteDetails?.ID; const [ logoAccepted, setLogoAccepted ] = useState( false ); const { nextTierCheckoutURL: upgradeURL } = useCheckout(); // First fetch the feature data so we have the most up-to-date info from the backend. const feature = getAiAssistantFeature(); const generateFirstLogo = useCallback( async () => { try { // First generate the prompt based on the site's data. setLoadingState( 'analyzing' ); recordTracksEvent( EVENT_GENERATE, { context, tool: 'first-prompt' } ); const prompt = await generateFirstPrompt(); setInitialPrompt( prompt ); // Then generate the logo based on the prompt. setLoadingState( 'generating' ); await generateLogo( { prompt, style: 'none' } ); setLoadingState( null ); } catch ( error ) { debug( 'Error generating first logo', error ); setLoadingState( null ); } }, [ context, generateFirstPrompt, generateLogo ] ); /* * Called ONCE to check the feature data to make sure the site is allowed to do the generation. * Also, checks site history and trigger a new generation in case there are no logos to present. */ const initializeModal = useCallback( async () => { try { if ( ! siteId ) { throw new Error( 'Site ID is missing' ); } if ( ! feature?.featuresControl?.[ 'logo-generator' ]?.enabled ) { setFeatureFetchError( 'Failed to fetch feature data' ); throw new Error( 'Failed to fetch feature data' ); } const hasHistory = ! isLogoHistoryEmpty( String( siteId ) ); const logoCost = feature?.costs?.[ 'jetpack-ai-logo-generator' ]?.logo ?? DEFAULT_LOGO_COST; const promptCreationCost = 1; const currentLimit = feature?.currentTier?.limit || 0; const currentValue = feature?.currentTier?.value || 0; const currentUsage = feature?.usagePeriod?.requestsCount || 0; const isUnlimited = ! tierPlansEnabled ? currentValue > 0 : currentValue === 1; const hasNoNextTier = ! feature?.nextTier; // If there is no next tier, the user cannot upgrade. // The user needs an upgrade immediately if they have no logos and not enough requests remaining for one prompt and one logo generation. const siteNeedsMoreRequests = ! isUnlimited && ! hasNoNextTier && ! hasHistory && ( tierPlansEnabled ? currentLimit - currentUsage < logoCost + promptCreationCost : currentLimit < currentUsage ); // If the site requires an upgrade, show the upgrade screen immediately. setNeedsFeature( currentValue === 0 ); setNeedsMoreRequests( siteNeedsMoreRequests ); if ( currentValue === 0 || siteNeedsMoreRequests ) { setLoadingState( null ); return; } setIsLoadingHistory( true ); // Load the logo history and clear any deleted media. await clearDeletedMedia( String( siteId ) ); loadLogoHistory( siteId ); // If there is any logo, we do not need to generate a first logo again. if ( hasHistory ) { setLoadingState( null ); setIsLoadingHistory( false ); return; } // if site requires an upgrade, just return and set loaders to null, // prompt component will take over the situation if ( requireUpgrade ) { setLoadingState( null ); setIsLoadingHistory( false ); return; } // If the site does not require an upgrade and has no logos stored // and has title and description, generate the first prompt based on the site's data. if ( site && site.name && site.description && site.name !== __( 'Site Title', 'jetpack-ai-client' ) ) { generateFirstLogo(); } else { setLoadingState( null ); setIsLoadingHistory( false ); } } catch ( error ) { debug( 'Error fetching feature', error ); setLoadingState( null ); setIsLoadingHistory( false ); } }, [ feature, generateFirstLogo, loadLogoHistory, clearDeletedMedia, isLogoHistoryEmpty, siteId, requireUpgrade, setFeatureFetchError, ] ); const handleModalOpen = useCallback( async () => { setContext( context ); recordTracksEvent( EVENT_MODAL_OPEN, { context, placement } ); initializeModal(); }, [ setContext, context, placement, initializeModal ] ); const closeModal = () => { // Reset the state when the modal is closed, so we trigger the modal initialization again when it's opened. needsToHandleModalOpen.current = true; onClose(); setLoadingState( null ); setNeedsFeature( false ); setNeedsMoreRequests( false ); clearErrors(); setLogoAccepted( false ); setIsLoadingHistory( false ); recordTracksEvent( EVENT_MODAL_CLOSE, { context, placement } ); }; const handleReload = useCallback( () => { if ( ! onReload ) { return; } closeModal(); requestedFeatureData.current = false; onReload(); }, [ onReload, closeModal ] ); const handleApplyLogo = ( mediaId: number ) => { setLogoAccepted( true ); onApplyLogo?.( mediaId ); }; const handleFeedbackClick = () => { recordTracksEvent( EVENT_FEEDBACK, { context } ); }; // Set site details when siteId changes useEffect( () => { if ( siteId ) { setSiteDetails( siteDetails ); } // When the site details are set, we need to fetch the feature data. if ( ! requestedFeatureData.current ) { const isRequestingFeature = getIsRequestingAiAssistantFeature(); if ( ! isRequestingFeature ) { requestedFeatureData.current = true; fetchAiAssistantFeature(); } } }, [ siteId, siteDetails, setSiteDetails, getIsRequestingAiAssistantFeature ] ); // Handles modal opening logic useEffect( () => { // While the modal is not open, the siteId is not set, or the feature data is not available, do nothing. if ( ! isOpen ) { return; } // Prevent multiple calls of the handleModalOpen function if ( needsToHandleModalOpen.current ) { needsToHandleModalOpen.current = false; handleModalOpen(); } }, [ isOpen, handleModalOpen ] ); let body: ReactNode; if ( loadingState ) { body = ; } else if ( featureFetchError || firstLogoPromptFetchError ) { body = ( ); } else if ( needsFeature || needsMoreRequests ) { body = ( ); } else { body = ( <> { ! logoAccepted && } { logoAccepted ? (
) : ( <>
) } ); } return ( <> { isOpen && (
{ body }
) } ); };