/**
* 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 =