diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52649aa1737e6..9fd6f9d5a3471 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,9 +115,15 @@ importers: '@types/react': specifier: 18.3.1 version: 18.3.1 + '@types/wordpress__block-editor': + specifier: 11.5.14 + version: 11.5.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/api-fetch': specifier: 7.2.0 version: 7.2.0 + '@wordpress/blob': + specifier: 4.2.0 + version: 4.2.0 '@wordpress/block-editor': specifier: 13.2.0 version: 13.2.0(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/projects/js-packages/ai-client/changelog/update-jetpack-ai-copy-logo-generator-code b/projects/js-packages/ai-client/changelog/update-jetpack-ai-copy-logo-generator-code new file mode 100644 index 0000000000000..1b8b3d5494200 --- /dev/null +++ b/projects/js-packages/ai-client/changelog/update-jetpack-ai-copy-logo-generator-code @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Jetpack AI: add logo generator codebase to the ai-client package. diff --git a/projects/js-packages/ai-client/package.json b/projects/js-packages/ai-client/package.json index fb709a3455184..c68b9b5f3f498 100644 --- a/projects/js-packages/ai-client/package.json +++ b/projects/js-packages/ai-client/package.json @@ -48,7 +48,9 @@ "@automattic/jetpack-shared-extension-utils": "workspace:*", "@microsoft/fetch-event-source": "2.0.1", "@types/react": "18.3.1", + "@types/wordpress__block-editor": "11.5.14", "@wordpress/api-fetch": "7.2.0", + "@wordpress/blob": "4.2.0", "@wordpress/block-editor": "13.2.0", "@wordpress/components": "28.2.0", "@wordpress/compose": "7.2.0", diff --git a/projects/js-packages/ai-client/src/hooks/use-save-to-media-library/index.ts b/projects/js-packages/ai-client/src/hooks/use-save-to-media-library/index.ts new file mode 100644 index 0000000000000..88abbceeb94f2 --- /dev/null +++ b/projects/js-packages/ai-client/src/hooks/use-save-to-media-library/index.ts @@ -0,0 +1,95 @@ +/** + * External dependencies + */ +import { isBlobURL } from '@wordpress/blob'; +import { useSelect } from '@wordpress/data'; +import { useState } from '@wordpress/element'; +import debugFactory from 'debug'; +/** + * Types + */ +import type { BlockEditorStore } from '../../types.js'; + +const debug = debugFactory( 'ai-client:save-to-media-library' ); + +/** + * Hook to save an image to the media library. + * + * @returns {object} Object with the loading state and the function to save the image to the media library. + */ +export default function useSaveToMediaLibrary() { + const [ isLoading, setIsLoading ] = useState( false ); + const { getSettings } = useSelect( + select => select( 'core/block-editor' ), + [] + ) as BlockEditorStore[ 'selectors' ]; + + const saveToMediaLibrary = ( + url: string, + name?: string + ): Promise< { id: string; url: string } > => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const settings = getSettings() as any; + + return new Promise( ( resolve, reject ) => { + setIsLoading( true ); + + debug( 'Fetching image from URL' ); + + fetch( url ) + .then( response => { + debug( 'Transforming response to blob' ); + + response + .blob() + .then( ( blob: Blob ) => { + debug( 'Uploading blob to media library' ); + const filesList = Array< File | Blob >(); + + if ( name ) { + filesList.push( new File( [ blob ], name ) ); + } else { + filesList.push( blob ); + } + + settings.mediaUpload( { + allowedTypes: [ 'image' ], + filesList, + onFileChange( [ image ] ) { + if ( isBlobURL( image?.url ) ) { + return; + } + + if ( image ) { + debug( 'Image uploaded to media library', image ); + resolve( image ); + } + + setIsLoading( false ); + }, + onError( message ) { + debug( 'Error uploading image to media library:', message ); + reject( message ); + setIsLoading( false ); + }, + } ); + } ) + .catch( e => { + debug( 'Error transforming response to blob:', e?.message ); + reject( e?.message ); + setIsLoading( false ); + } ); + } ) + .catch( e => { + debug( 'Error fetching image from URL:', e?.message ); + reject( e?.message ); + setIsLoading( false ); + } ); + } ); + }; + + return { + isLoading, + saveToMediaLibrary, + }; +} diff --git a/projects/js-packages/ai-client/src/index.ts b/projects/js-packages/ai-client/src/index.ts index c3325f1919c93..fb185b2309ea2 100644 --- a/projects/js-packages/ai-client/src/index.ts +++ b/projects/js-packages/ai-client/src/index.ts @@ -41,3 +41,8 @@ export * from './types.js'; * Libs */ export * from './libs/index.js'; + +/* + * Logo Generator + */ +export * from './logo-generator/index.js'; diff --git a/projects/js-packages/ai-client/src/logo-generator/assets/icons/ai.tsx b/projects/js-packages/ai-client/src/logo-generator/assets/icons/ai.tsx new file mode 100644 index 0000000000000..82e6f8c64816d --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/assets/icons/ai.tsx @@ -0,0 +1,21 @@ +/** + * Internal dependencies + */ +import './icons.scss'; + +export default () => { + return ( + + + + + + ); +}; diff --git a/projects/js-packages/ai-client/src/logo-generator/assets/icons/check.tsx b/projects/js-packages/ai-client/src/logo-generator/assets/icons/check.tsx new file mode 100644 index 0000000000000..9c3b17395396c --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/assets/icons/check.tsx @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import './icons.scss'; + +export default () => { + return ( + + + + ); +}; diff --git a/projects/js-packages/ai-client/src/logo-generator/assets/icons/icons.scss b/projects/js-packages/ai-client/src/logo-generator/assets/icons/icons.scss new file mode 100644 index 0000000000000..1ad0242848589 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/assets/icons/icons.scss @@ -0,0 +1,5 @@ +.jetpack-ai-logo-generator-icon { + path { + fill: var(--color-link, #3858e9); + } +} diff --git a/projects/js-packages/ai-client/src/logo-generator/assets/icons/logo.tsx b/projects/js-packages/ai-client/src/logo-generator/assets/icons/logo.tsx new file mode 100644 index 0000000000000..c6e8be7cb8a86 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/assets/icons/logo.tsx @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import './icons.scss'; + +export default () => { + return ( + + + + ); +}; diff --git a/projects/js-packages/ai-client/src/logo-generator/assets/icons/media.tsx b/projects/js-packages/ai-client/src/logo-generator/assets/icons/media.tsx new file mode 100644 index 0000000000000..4b54e9560dbfb --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/assets/icons/media.tsx @@ -0,0 +1,24 @@ +/** + * Internal dependencies + */ +import './icons.scss'; + +export default () => { + return ( + + + + + ); +}; diff --git a/projects/js-packages/ai-client/src/logo-generator/assets/images/jetpack-logo.svg b/projects/js-packages/ai-client/src/logo-generator/assets/images/jetpack-logo.svg new file mode 100644 index 0000000000000..aa0b8f87aecf4 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/assets/images/jetpack-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/projects/js-packages/ai-client/src/logo-generator/assets/images/loader.gif b/projects/js-packages/ai-client/src/logo-generator/assets/images/loader.gif new file mode 100644 index 0000000000000..4af5a96b0ed83 Binary files /dev/null and b/projects/js-packages/ai-client/src/logo-generator/assets/images/loader.gif differ diff --git a/projects/js-packages/ai-client/src/logo-generator/assets/index.d.ts b/projects/js-packages/ai-client/src/logo-generator/assets/index.d.ts new file mode 100644 index 0000000000000..3eebb0d372164 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/assets/index.d.ts @@ -0,0 +1,3 @@ +declare module '*.gif'; +declare module '*.png'; +declare module '*.svg'; diff --git a/projects/js-packages/ai-client/src/logo-generator/components/feature-fetch-failure-screen.tsx b/projects/js-packages/ai-client/src/logo-generator/components/feature-fetch-failure-screen.tsx new file mode 100644 index 0000000000000..dc26c0570e1ca --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/components/feature-fetch-failure-screen.tsx @@ -0,0 +1,35 @@ +/** + * External dependencies + */ +import { Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +/** + * Types + */ +import type React from 'react'; + +export const FeatureFetchFailureScreen: React.FC< { + onCancel: () => void; + onRetry: () => void; +} > = ( { onCancel, onRetry } ) => { + const errorMessage = __( + 'We are sorry. There was an error loading your Jetpack AI account settings. Please, try again.', + 'jetpack-ai-client' + ); + + return ( +
+
+ { errorMessage } +
+
+ + +
+
+ ); +}; diff --git a/projects/js-packages/ai-client/src/logo-generator/components/first-load-screen.scss b/projects/js-packages/ai-client/src/logo-generator/components/first-load-screen.scss new file mode 100644 index 0000000000000..d70d0d922f458 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/components/first-load-screen.scss @@ -0,0 +1,12 @@ +.jetpack-ai-logo-generator-modal__loading-wrapper { + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + justify-content: center; + flex-grow: 1; +} + +.jetpack-ai-logo-generator-modal__loader { + margin-bottom: 29px; +} diff --git a/projects/js-packages/ai-client/src/logo-generator/components/first-load-screen.tsx b/projects/js-packages/ai-client/src/logo-generator/components/first-load-screen.tsx new file mode 100644 index 0000000000000..92456812fd2c2 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/components/first-load-screen.tsx @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import React from 'react'; +/** + * Internal dependencies + */ +import { ImageLoader } from './image-loader.js'; +import './first-load-screen.scss'; + +export const FirstLoadScreen: React.FC< { + state?: 'loadingFeature' | 'analyzing' | 'generating'; +} > = ( { state = 'loadingFeature' } ) => { + const loadingLabel = __( 'Loading…', 'jetpack-ai-client' ); + const analyzingLabel = __( + 'Analyzing your site to create the perfect logo…', + 'jetpack-ai-client' + ); + const generatingLabel = __( 'Generating logo…', 'jetpack-ai-client' ); + + return ( +
+ + + { state === 'loadingFeature' && loadingLabel } + { state === 'analyzing' && analyzingLabel } + { state === 'generating' && generatingLabel } + +
+ ); +}; diff --git a/projects/js-packages/ai-client/src/logo-generator/components/generator-modal.scss b/projects/js-packages/ai-client/src/logo-generator/components/generator-modal.scss new file mode 100644 index 0000000000000..2221ff9ae6969 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/components/generator-modal.scss @@ -0,0 +1,92 @@ +@import '@automattic/jetpack-base-styles/root-variables'; + +.jetpack-ai-logo-generator-modal { + @media (min-width: 960px) { + max-height: 90%; + } + + .components-modal__header { + border-bottom: 1px solid var(--studio-gray-5, #dcdcde); + padding: 20px 24px; + } + + .components-modal__content { + padding: 32px 32px 2px 32px; + margin-bottom: 30px; + + > div:not(.components-modal__header) { + height: 100%; + } + } + + .components-button { + &:focus:not(:disabled):not(.is-primary) { + box-shadow: 0 0 0 2px var(--color-link, #3858e9); + } + + &.is-link { + text-decoration: none; + color: var(--color-link, #3858e9); + } + } +} + +.jetpack-ai-logo-generator-modal__body { + display: flex; + flex-direction: column; + gap: 16px; + height: 100%; + + @media (min-width: 700px) { + width: 700px; + min-height: 409px; + + &.notice-modal { + width: 470px; + min-height: unset; + } + } +} + +.jetpack-ai-logo-generator__footer { + display: flex; + + .jetpack-ai-logo-generator__feedback-button { + display: flex; + gap: 4px; + align-items: center; + margin-top: 8px; + + .icon { + color: var(--studio-gray-20); + } + } +} + +.jetpack-ai-logo-generator-modal__notice-message-wrapper { + display: flex; + flex-direction: column; + gap: 24px; +} + +.jetpack-ai-logo-generator-modal__notice-message { + font-size: var( --font-body-small ); +} + +.jetpack-ai-logo-generator-modal__notice-actions { + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.jetpack-ai-logo-generator__accept { + display: flex; + flex-direction: column; + gap: 64px; +} + +.jetpack-ai-logo-generator__accept-actions { + display: flex; + justify-content: flex-end; + gap: 22px; +} diff --git a/projects/js-packages/ai-client/src/logo-generator/components/generator-modal.tsx b/projects/js-packages/ai-client/src/logo-generator/components/generator-modal.tsx new file mode 100644 index 0000000000000..d7729ab2a6735 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/components/generator-modal.tsx @@ -0,0 +1,291 @@ +/** + * External dependencies + */ +import { useAnalytics } from '@automattic/jetpack-shared-extension-utils'; +import { Modal, Button } from '@wordpress/components'; +import { useDispatch } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { external, Icon } from '@wordpress/icons'; +import clsx from 'clsx'; +import debugFactory from 'debug'; +import { useState, useEffect, useCallback, useRef } from 'react'; +/** + * Internal dependencies + */ +import { + DEFAULT_LOGO_COST, + EVENT_MODAL_OPEN, + EVENT_FEEDBACK, + EVENT_MODAL_CLOSE, + EVENT_PLACEMENT_QUICK_LINKS, + EVENT_GENERATE, +} from '../constants.js'; +import useLogoGenerator from '../hooks/use-logo-generator.js'; +import useRequestErrors from '../hooks/use-request-errors.js'; +import { isLogoHistoryEmpty, clearDeletedMedia } from '../lib/logo-storage.js'; +import { STORE_NAME } from '../store/index.js'; +import { FeatureFetchFailureScreen } from './feature-fetch-failure-screen.js'; +import { FirstLoadScreen } from './first-load-screen.js'; +import { HistoryCarousel } from './history-carousel.js'; +import { LogoPresenter } from './logo-presenter.js'; +import { Prompt } from './prompt.js'; +import { UpgradeScreen } from './upgrade-screen.js'; +import { VisitSiteBanner } from './visit-site-banner.js'; +import './generator-modal.scss'; +/** + * Types + */ +import type { GeneratorModalProps } from '../types.js'; +import type React from 'react'; + +const debug = debugFactory( 'jetpack-ai-calypso:generator-modal' ); + +export const GeneratorModal: React.FC< GeneratorModalProps > = ( { + isOpen, + onClose, + siteDetails, + context, +} ) => { + const { tracks } = useAnalytics(); + const { recordEvent: recordTracksEvent } = tracks; + const { setSiteDetails, fetchAiAssistantFeature, loadLogoHistory } = useDispatch( 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 [ upgradeURL, setUpgradeURL ] = useState( '' ); + const { selectedLogo, getAiAssistantFeature, generateFirstPrompt, generateLogo, setContext } = + useLogoGenerator(); + const { featureFetchError, firstLogoPromptFetchError, clearErrors } = useRequestErrors(); + const siteId = siteDetails?.ID; + const siteURL = siteDetails?.URL; + const [ logoAccepted, setLogoAccepted ] = useState( false ); + + // 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 } ); + 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 { + const hasHistory = ! isLogoHistoryEmpty( String( siteId ) ); + const logoCost = feature?.costs?.[ 'jetpack-ai-logo-generator' ]?.logo ?? DEFAULT_LOGO_COST; + const promptCreationCost = 1; + const currentLimit = feature?.currentTier?.value || 0; + const currentUsage = feature?.usagePeriod?.requestsCount || 0; + const isUnlimited = currentLimit === 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 && + currentLimit - currentUsage < logoCost + promptCreationCost; + + // If the site requires an upgrade, set the upgrade URL and show the upgrade screen immediately. + setNeedsFeature( ! feature?.hasFeature ?? true ); + setNeedsMoreRequests( siteNeedsMoreRequests ); + + if ( ! feature?.hasFeature || siteNeedsMoreRequests ) { + const siteUpgradeURL = new URL( + `${ location.origin }/checkout/${ siteDetails?.domain }/${ feature?.nextTier?.slug }` + ); + siteUpgradeURL.searchParams.set( 'redirect_to', location.href ); + setUpgradeURL( siteUpgradeURL.toString() ); + setLoadingState( null ); + return; + } + + // 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 ( ! isLogoHistoryEmpty( String( siteId ) ) ) { + setLoadingState( null ); + return; + } + + // If the site does not require an upgrade and has no logos stored, generate the first prompt based on the site's data. + generateFirstLogo(); + } catch ( error ) { + debug( 'Error fetching feature', error ); + setLoadingState( null ); + } + }, [ + feature, + generateFirstLogo, + loadLogoHistory, + clearDeletedMedia, + isLogoHistoryEmpty, + siteId, + ] ); + + const handleModalOpen = useCallback( async () => { + setContext( context ); + recordTracksEvent( EVENT_MODAL_OPEN, { context, placement: EVENT_PLACEMENT_QUICK_LINKS } ); + + initializeModal(); + }, [ setContext, context, 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 ); + recordTracksEvent( EVENT_MODAL_CLOSE, { context, placement: EVENT_PLACEMENT_QUICK_LINKS } ); + }; + + const handleApplyLogo = () => { + setLogoAccepted( true ); + }; + + const handleCloseAndReload = () => { + closeModal(); + + setTimeout( () => { + // Reload the page to update the logo. + window.location.reload(); + }, 1000 ); + }; + + 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 ) { + requestedFeatureData.current = true; + fetchAiAssistantFeature(); + } + }, [ siteId, siteDetails, setSiteDetails ] ); + + // 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 || ! siteId || ! feature?.costs ) { + return; + } + + // Prevent multiple calls of the handleModalOpen function + if ( needsToHandleModalOpen.current ) { + needsToHandleModalOpen.current = false; + handleModalOpen(); + } + }, [ isOpen, siteId, handleModalOpen, feature ] ); + + let body: React.ReactNode; + + if ( loadingState ) { + body = ; + } else if ( featureFetchError || firstLogoPromptFetchError ) { + body = ; + } else if ( needsFeature || needsMoreRequests ) { + body = ( + + ); + } else { + body = ( + <> + { ! logoAccepted && } + + { logoAccepted ? ( +
+ +
+ + +
+
+ ) : ( + <> + +
+ +
+ + ) } + + ); + } + + return ( + <> + { isOpen && ( + +
+ { body } +
+
+ ) } + + ); +}; diff --git a/projects/js-packages/ai-client/src/logo-generator/components/history-carousel.scss b/projects/js-packages/ai-client/src/logo-generator/components/history-carousel.scss new file mode 100644 index 0000000000000..d7f2107cfe02e --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/components/history-carousel.scss @@ -0,0 +1,36 @@ +@import '@automattic/jetpack-base-styles/root-variables'; + +.jetpack-ai-logo-generator__carousel { + display: flex; + gap: 8px; + overflow-x: auto; + flex-shrink: 0; + + .components-button { + height: unset; + padding: unset; + } + + @media (min-width: 700px) { + padding: 2px; + } +} + +.jetpack-ai-logo-generator__carousel-logo { + display: flex; + justify-content: center; + align-items: center; + border: 1px solid var(--studio-gray-5, #dcdcde); + border-radius: 2px; + flex-shrink: 0; + + &.is-selected { + border-color: var(--color-link, #3858e9); + border-width: 1.5px; + } + + img { + width: 48px; + height: 48px; + } +} diff --git a/projects/js-packages/ai-client/src/logo-generator/components/history-carousel.tsx b/projects/js-packages/ai-client/src/logo-generator/components/history-carousel.tsx new file mode 100644 index 0000000000000..d82b34b82d2df --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/components/history-carousel.tsx @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import { useAnalytics } from '@automattic/jetpack-shared-extension-utils'; +import { Button } from '@wordpress/components'; +import clsx from 'clsx'; +/** + * Internal dependencies + */ +import { EVENT_NAVIGATE } from '../constants.js'; +import useLogoGenerator from '../hooks/use-logo-generator.js'; +import './history-carousel.scss'; +/** + * Types + */ +import type React from 'react'; + +export const HistoryCarousel: React.FC = () => { + const { tracks } = useAnalytics(); + const { recordEvent: recordTracksEvent } = tracks; + const { logos, selectedLogo, setSelectedLogoIndex, context } = useLogoGenerator(); + + const handleClick = ( index: number ) => { + recordTracksEvent( EVENT_NAVIGATE, { + context, + logos_count: logos.length, + selected_logo: index + 1, + } ); + setSelectedLogoIndex( index ); + }; + + const thumbnailFrom = ( url: string ): string => { + const thumbnailURL = new URL( url ); + + if ( ! thumbnailURL.searchParams.has( 'resize' ) ) { + thumbnailURL.searchParams.append( 'resize', '48,48' ); + } + + return thumbnailURL.toString(); + }; + + return ( +
+ { logos.map( ( logo, index ) => ( + + ) ) } +
+ ); +}; diff --git a/projects/js-packages/ai-client/src/logo-generator/components/image-loader.tsx b/projects/js-packages/ai-client/src/logo-generator/components/image-loader.tsx new file mode 100644 index 0000000000000..ea7b54a2eecec --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/components/image-loader.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; +/** + * Internal dependencies + */ +import loader from '../assets/images/loader.gif'; +/** + * Types + */ +import type React from 'react'; + +export const ImageLoader: React.FC< { className?: string } > = ( { className = null } ) => { + return ( + Loading + ); +}; diff --git a/projects/js-packages/ai-client/src/logo-generator/components/logo-presenter.scss b/projects/js-packages/ai-client/src/logo-generator/components/logo-presenter.scss new file mode 100644 index 0000000000000..7086f47cf6638 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/components/logo-presenter.scss @@ -0,0 +1,116 @@ +@import '@automattic/jetpack-base-styles/root-variables'; + +.jetpack-ai-logo-generator-modal-presenter__wrapper { + display: flex; + flex-direction: column; + gap: 8px; +} + +.jetpack-ai-logo-generator-modal-presenter { + display: flex; +} + +.jetpack-ai-logo-generator-modal-presenter__content { + border-radius: 4px; + background: var(--studio-gray-0, #f6f7f7); + display: flex; + align-items: center; + flex-grow: 1; + max-height: 229px; + + @media (max-width: 700px) { + flex-direction: column; + max-height: unset; + } +} + +.jetpack-ai-logo-generator-modal-presenter__rectangle { + position: relative; + width: 0; + + &::after { + width: 15px; + height: 15px; + transform: rotate(-45deg); + background-color: var(--studio-gray-0, #f6f7f7); + content: ""; + position: absolute; + top: -7.5px; + left: -66px; + } +} + +.jetpack-ai-logo-generator-modal-presenter__loading-text { + flex-grow: 1; + text-align: center; +} + +.jetpack-ai-logo-generator-modal-presenter__logo { + width: 198px; + height: 198px; + margin: 16px 30px 16px 16px; + + @media (max-width: 700px) { + margin: 16px; + } +} + +.jetpack-ai-logo-generator-modal-presenter__action-wrapper { + height: 100%; + flex-grow: 1; + margin-right: 32px; + display: flex; + flex-direction: column; + padding: 16px 0; + box-sizing: border-box; + + @media (max-width: 700px) { + margin-left: 32px; + } +} + +.jetpack-ai-logo-generator-modal-presenter__description { + flex-grow: 1; + padding-top: 16px; + color: var(--studio-gray-50, #646970); + overflow-y: auto; +} + +.jetpack-ai-logo-generator-modal-presenter__actions { + padding-top: 16px; + margin-top: 16px; + border-top: 1px solid var(--studio-gray-5, #dcdcde); + display: flex; + gap: 24px; +} + +.jetpack-ai-logo-generator-modal-presenter__action { + display: flex; + align-items: center; + + &.components-button, + &.components-button:hover, + &.components-button:active { + color: var(--color-link, #3858e9); + } + + .action-text { + font-size: var(--font-body-extra-small); + } + + .jetpack-ai-logo-generator-icon { + margin-right: 8px; + } +} + +.jetpack-ai-logo-generator-modal-presenter__success-wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + width: 100%; + + @media (max-width: 700px) { + padding-bottom: 16px; + } +} diff --git a/projects/js-packages/ai-client/src/logo-generator/components/logo-presenter.tsx b/projects/js-packages/ai-client/src/logo-generator/components/logo-presenter.tsx new file mode 100644 index 0000000000000..1d8e48b50bb09 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/components/logo-presenter.tsx @@ -0,0 +1,234 @@ +/** + * External dependencies + */ +import { useAnalytics } from '@automattic/jetpack-shared-extension-utils'; +import { Button, Icon } from '@wordpress/components'; +import { useDispatch } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import debugFactory from 'debug'; +/** + * Internal dependencies + */ +import CheckIcon from '../assets/icons/check.js'; +import LogoIcon from '../assets/icons/logo.js'; +import MediaIcon from '../assets/icons/media.js'; +import { EVENT_SAVE, EVENT_USE } from '../constants.js'; +import useLogoGenerator from '../hooks/use-logo-generator.js'; +import useRequestErrors from '../hooks/use-request-errors.js'; +import { updateLogo } from '../lib/logo-storage.js'; +import { STORE_NAME } from '../store/index.js'; +import { ImageLoader } from './image-loader.js'; +import './logo-presenter.scss'; +/** + * Types + */ +import type { Logo } from '../store/types.js'; +import type { LogoPresenterProps } from '../types.js'; +import type React from 'react'; + +const debug = debugFactory( 'jetpack-ai-calypso:logo-presenter' ); + +const SaveInLibraryButton: React.FC< { siteId: string } > = ( { siteId } ) => { + const { tracks } = useAnalytics(); + const { recordEvent: recordTracksEvent } = tracks; + const { + saveLogo, + selectedLogo, + isSavingLogoToLibrary: saving, + logos, + selectedLogoIndex, + context, + } = useLogoGenerator(); + const saved = !! selectedLogo?.mediaId; + + const { loadLogoHistory } = useDispatch( STORE_NAME ); + + const handleClick = async () => { + if ( ! saved && ! saving ) { + recordTracksEvent( EVENT_SAVE, { + context, + logos_count: logos.length, + selected_logo: selectedLogoIndex ? selectedLogoIndex + 1 : 0, + } ); + + try { + const savedLogo = await saveLogo( selectedLogo ); + + // Update localStorage + updateLogo( { + siteId, + url: selectedLogo.url, + newUrl: savedLogo.mediaURL, + mediaId: savedLogo.mediaId, + } ); + + // Update state + loadLogoHistory( siteId ); + } catch ( error ) { + debug( 'Error saving logo', error ); + } + } + }; + + const savingLabel = __( 'Saving…', 'jetpack-ai-client' ); + const savedLabel = __( 'Saved', 'jetpack-ai-client' ); + + return ! saving && ! saved ? ( + + ) : ( + + ); +}; + +const UseOnSiteButton: React.FC< { onApplyLogo: () => void } > = ( { onApplyLogo } ) => { + const { tracks } = useAnalytics(); + const { recordEvent: recordTracksEvent } = tracks; + const { + applyLogo, + isSavingLogoToLibrary, + isApplyingLogo, + selectedLogo, + logos, + selectedLogoIndex, + context, + } = useLogoGenerator(); + + const handleClick = async () => { + if ( ! isApplyingLogo && ! isSavingLogoToLibrary ) { + recordTracksEvent( EVENT_USE, { + context, + logos_count: logos.length, + selected_logo: selectedLogoIndex != null ? selectedLogoIndex + 1 : 0, + } ); + + try { + await applyLogo(); + onApplyLogo(); + } catch ( error ) { + debug( 'Error applying logo', error ); + } + } + }; + + return isApplyingLogo && ! isSavingLogoToLibrary ? ( + + ) : ( + + ); +}; + +const LogoLoading: React.FC = () => { + return ( + <> + + + { __( 'Generating new logo…', 'jetpack-ai-client' ) } + + + ); +}; + +const LogoReady: React.FC< { siteId: string; logo: Logo; onApplyLogo: () => void } > = ( { + siteId, + logo, + onApplyLogo, +} ) => { + return ( + <> + { +
+ + { logo.description } + +
+ + +
+
+ + ); +}; + +const LogoUpdated: React.FC< { logo: Logo } > = ( { logo } ) => { + return ( + <> + { +
+ } /> + { __( 'Your logo has been successfully updated!', 'jetpack-ai-client' ) } +
+ + ); +}; + +export const LogoPresenter: React.FC< LogoPresenterProps > = ( { + logo = null, + loading = false, + onApplyLogo, + logoAccepted = false, + siteId, +} ) => { + const { isRequestingImage } = useLogoGenerator(); + const { saveToLibraryError, logoUpdateError } = useRequestErrors(); + + if ( ! logo ) { + return null; + } + + let logoContent: React.ReactNode; + + if ( loading || isRequestingImage ) { + logoContent = ; + } else if ( logoAccepted ) { + logoContent = ; + } else { + logoContent = ( + + ); + } + + return ( +
+
+
{ logoContent }
+ { ! logoAccepted && ( +
+ ) } +
+ { saveToLibraryError && ( +
+ { __( 'Error saving the logo to your library. Please try again.', 'jetpack-ai-client' ) } +
+ ) } + { logoUpdateError && ( +
+ { __( 'Error applying the logo to your site. Please try again.', 'jetpack-ai-client' ) } +
+ ) } +
+ ); +}; diff --git a/projects/js-packages/ai-client/src/logo-generator/components/prompt.scss b/projects/js-packages/ai-client/src/logo-generator/components/prompt.scss new file mode 100644 index 0000000000000..bbc7be95382b5 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/components/prompt.scss @@ -0,0 +1,102 @@ +@import '@automattic/jetpack-base-styles/root-variables'; + +.jetpack-ai-logo-generator__prompt { + display: flex; + flex-direction: column; + gap: 8px; + font-size: var(--font-body-small); +} + +.jetpack-ai-logo-generator__prompt-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + align-self: stretch; + + .jetpack-ai-logo-generator__prompt-label { + font-weight: 500; + } + + .jetpack-ai-logo-generator__prompt-actions { + display: flex; + font-size: var(--font-body-extra-small); + line-height: 20px; + + .jetpack-ai-logo-generator-icon { + margin-right: 4px; + } + } +} + +.jetpack-ai-logo-generator__prompt-query { + display: flex; + padding: 8px 8px 8px var(--grid-unit-15, 16px); + justify-content: space-between; + align-items: flex-end; + align-self: stretch; + border-radius: calc(4px * 2); + border: 1px solid var(--studio-gray-10, #ccc); + background: var(--studio-white, #fff); + gap: 8px; + + @media (min-width: 700px) { + gap: 48px; + } + + .prompt-query__input { + border: 0; + resize: none; + flex-grow: 1; + padding: 6px 0; + vertical-align: baseline; + color: var(--studio-gray-100); + line-height: 1.6; + word-break: break-word; + + &:focus, + &:active { + outline: 0; + } + + &[contentEditable="false"] { + color: var(--studio-gray-50, #646970); + } + + &[data-placeholder]:empty::before { + content: attr(data-placeholder); + color: var(--studio-gray-50, #646970); + } + + &[data-placeholder]:empty:focus::before { + content: ""; + } + } +} + +.jetpack-ai-logo-generator__prompt-footer { + display: flex; + flex-direction: column; + gap: 8px; +} + +.jetpack-ai-logo-generator__prompt-requests { + color: var(--studio-gray-50, #646970); + font-size: var(--font-body-extra-small); + line-height: 21px; + display: flex; + + & .prompt-footer__icon { + height: 20px; + width: 20px; + + path { + fill: var(--studio-gray-20, #a7aaad); + } + } +} + +.jetpack-ai-logo-generator__prompt-error { + color: var(--studio-red-50, #d63638); + font-size: var(--font-body-extra-small); + line-height: 21px; +} diff --git a/projects/js-packages/ai-client/src/logo-generator/components/prompt.tsx b/projects/js-packages/ai-client/src/logo-generator/components/prompt.tsx new file mode 100644 index 0000000000000..8f75fd9cbbc91 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/components/prompt.tsx @@ -0,0 +1,211 @@ +/** + * External dependencies + */ +import { useAnalytics } from '@automattic/jetpack-shared-extension-utils'; +import { Button, Tooltip } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { Icon, info } from '@wordpress/icons'; +import debugFactory from 'debug'; +import { useCallback, useEffect, useState, useRef } from 'react'; +/** + * Internal dependencies + */ +import AiIcon from '../assets/icons/ai.js'; +import { + EVENT_GENERATE, + MINIMUM_PROMPT_LENGTH, + EVENT_UPGRADE, + EVENT_PLACEMENT_INPUT_FOOTER, +} from '../constants.js'; +import { useCheckout } from '../hooks/use-checkout.js'; +import useLogoGenerator from '../hooks/use-logo-generator.js'; +import useRequestErrors from '../hooks/use-request-errors.js'; +import { UpgradeNudge } from './upgrade-nudge.js'; +import './prompt.scss'; + +const debug = debugFactory( 'jetpack-ai-calypso:prompt-box' ); + +export const Prompt: React.FC< { initialPrompt?: string } > = ( { initialPrompt = '' } ) => { + const { tracks } = useAnalytics(); + const { recordEvent: recordTracksEvent } = tracks; + const [ prompt, setPrompt ] = useState< string >( initialPrompt ); + const [ requestsRemaining, setRequestsRemaining ] = useState( 0 ); + const { enhancePromptFetchError, logoFetchError } = useRequestErrors(); + const { nextTierCheckoutURL: checkoutUrl, hasNextTier } = useCheckout(); + const hasPrompt = prompt?.length >= MINIMUM_PROMPT_LENGTH; + + const { + generateLogo, + enhancePrompt, + setIsEnhancingPrompt, + isBusy, + isEnhancingPrompt, + site, + getAiAssistantFeature, + requireUpgrade, + context, + } = useLogoGenerator(); + + const enhancingLabel = __( 'Enhancing…', 'jetpack-ai-client' ); + const enhanceLabel = __( 'Enhance prompt', 'jetpack-ai-client' ); + const enhanceButtonLabel = isEnhancingPrompt ? enhancingLabel : enhanceLabel; + + const inputRef = useRef< HTMLDivElement | null >( null ); + + const onEnhance = useCallback( async () => { + debug( 'Enhancing prompt', prompt ); + setIsEnhancingPrompt( true ); + recordTracksEvent( EVENT_GENERATE, { context, tool: 'enhance-prompt' } ); + + try { + const enhancedPrompt = await enhancePrompt( { prompt } ); + setPrompt( enhancedPrompt ); + setIsEnhancingPrompt( false ); + } catch ( error ) { + debug( 'Error enhancing prompt', error ); + setIsEnhancingPrompt( false ); + } + }, [ context, enhancePrompt, prompt, setIsEnhancingPrompt ] ); + + const featureData = getAiAssistantFeature( String( site?.id || '' ) ); + + const currentLimit = featureData?.currentTier?.value || 0; + const currentUsage = featureData?.usagePeriod?.requestsCount || 0; + const isUnlimited = currentLimit === 1; + + useEffect( () => { + if ( currentLimit - currentUsage <= 0 ) { + setRequestsRemaining( 0 ); + } else { + setRequestsRemaining( currentLimit - currentUsage ); + } + }, [ currentLimit, currentUsage ] ); + + useEffect( () => { + // Update prompt text node after enhancement + if ( inputRef.current && inputRef.current.textContent !== prompt ) { + inputRef.current.textContent = prompt; + } + }, [ prompt ] ); + + const onGenerate = useCallback( async () => { + recordTracksEvent( EVENT_GENERATE, { context, tool: 'image' } ); + generateLogo( { prompt } ); + }, [ context, generateLogo, prompt ] ); + + const onPromptInput = ( event: React.ChangeEvent< HTMLInputElement > ) => { + setPrompt( event.target.textContent || '' ); + }; + + const onPromptPaste = ( event: React.ClipboardEvent< HTMLInputElement > ) => { + event.preventDefault(); + + // Paste plain text only + const text = event.clipboardData.getData( 'text/plain' ); + + const selection = window.getSelection(); + if ( ! selection || ! selection.rangeCount ) { + return; + } + selection.deleteFromDocument(); + const range = selection.getRangeAt( 0 ); + range.insertNode( document.createTextNode( text ) ); + selection.collapseToEnd(); + + setPrompt( inputRef.current?.textContent || '' ); + }; + + const onUpgradeClick = () => { + recordTracksEvent( EVENT_UPGRADE, { context, placement: EVENT_PLACEMENT_INPUT_FOOTER } ); + }; + + return ( +
+
+
+ { __( 'Describe your site:', 'jetpack-ai-client' ) } +
+
+ +
+
+
+
+ +
+
+ { ! isUnlimited && ! requireUpgrade && ( +
+
+ { sprintf( + // translators: %u is the number of requests + __( '%u requests remaining.', 'jetpack-ai-client' ), + requestsRemaining + ) } +
+ { hasNextTier && ( + <> +   + + + ) } +   + + + +
+ ) } + { ! isUnlimited && requireUpgrade && } + { enhancePromptFetchError && ( +
+ { __( 'Error enhancing prompt. Please try again.', 'jetpack-ai-client' ) } +
+ ) } + { logoFetchError && ( +
+ { __( 'Error generating logo. Please try again.', 'jetpack-ai-client' ) } +
+ ) } +
+
+ ); +}; diff --git a/projects/js-packages/ai-client/src/logo-generator/components/upgrade-nudge.scss b/projects/js-packages/ai-client/src/logo-generator/components/upgrade-nudge.scss new file mode 100644 index 0000000000000..e7178248255ae --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/components/upgrade-nudge.scss @@ -0,0 +1,43 @@ +@import '@automattic/jetpack-base-styles/root-variables'; + +.jetpack-upgrade-plan-banner .jetpack-upgrade-plan-banner__wrapper { + display: flex; + justify-content: space-between; + align-items: center; + font-size: var(--font-body-small); + background: var(--jp-black); + padding: 8px 16px; + border-radius: 2px; +} + +.jetpack-upgrade-plan-banner .jetpack-upgrade-plan-banner__wrapper .jetpack-upgrade-plan-banner__banner-description { + color: var(--jp-white); + line-height: 21px; + word-wrap: break-word; + vertical-align: middle; +} + +.jetpack-upgrade-plan-banner .jetpack-upgrade-plan-banner__wrapper .jetpack-upgrade-plan-banner__icon { + width: 24px; + height: 24px; + position: relative; + vertical-align: middle; + margin-right: 8px; + + path { + fill: var(--jp-gray-30); + } +} + +.jetpack-upgrade-plan-banner .jetpack-upgrade-plan-banner__wrapper .components-button { + height: auto; + line-height: 20px; + font-size: var(--font-body-extra-small); + font-weight: 600; + padding: 4px 8px; +} + +.jetpack-upgrade-plan-banner .jetpack-upgrade-plan-banner__wrapper .components-button.is-primary { + background: var(--jp-white); + color: var(--jp-black); +} diff --git a/projects/js-packages/ai-client/src/logo-generator/components/upgrade-nudge.tsx b/projects/js-packages/ai-client/src/logo-generator/components/upgrade-nudge.tsx new file mode 100644 index 0000000000000..8e96434ceaa39 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/components/upgrade-nudge.tsx @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import { useAnalytics } from '@automattic/jetpack-shared-extension-utils'; +import { Button } from '@wordpress/components'; +import { createInterpolateElement } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Icon, warning } from '@wordpress/icons'; +/** + * Internal dependencies + */ +import { EVENT_PLACEMENT_UPGRADE_PROMPT, EVENT_UPGRADE } from '../constants.js'; +import { useCheckout } from '../hooks/use-checkout.js'; +import useLogoGenerator from '../hooks/use-logo-generator.js'; +import './upgrade-nudge.scss'; + +export const UpgradeNudge = () => { + const { tracks } = useAnalytics(); + const { recordEvent: recordTracksEvent } = tracks; + const buttonText = __( 'Upgrade', 'jetpack-ai-client' ); + const upgradeMessage = createInterpolateElement( + __( + 'Not enough requests left to generate a logo. Upgrade now to increase it.', + 'jetpack-ai-client' + ), + { + strong: , + } + ); + + const { nextTierCheckoutURL: checkoutUrl } = useCheckout(); + const { context } = useLogoGenerator(); + + const handleUpgradeClick = () => { + recordTracksEvent( EVENT_UPGRADE, { context, placement: EVENT_PLACEMENT_UPGRADE_PROMPT } ); + }; + + return ( +
+
+
+ + + { upgradeMessage } + +
+ +
+
+ ); +}; diff --git a/projects/js-packages/ai-client/src/logo-generator/components/upgrade-screen.tsx b/projects/js-packages/ai-client/src/logo-generator/components/upgrade-screen.tsx new file mode 100644 index 0000000000000..8fa8f5c5c2ad9 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/components/upgrade-screen.tsx @@ -0,0 +1,67 @@ +/** + * External dependencies + */ +import { useAnalytics } from '@automattic/jetpack-shared-extension-utils'; +import { Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import { EVENT_PLACEMENT_FREE_USER_SCREEN, EVENT_UPGRADE } from '../constants.js'; +import useLogoGenerator from '../hooks/use-logo-generator.js'; +/** + * Types + */ +import type React from 'react'; + +export const UpgradeScreen: React.FC< { + onCancel: () => void; + upgradeURL: string; + reason: 'feature' | 'requests'; +} > = ( { onCancel, upgradeURL, reason } ) => { + const { tracks } = useAnalytics(); + const { recordEvent: recordTracksEvent } = tracks; + const upgradeMessageFeature = __( + 'Upgrade your Jetpack AI for access to exclusive features, including logo generation. This upgrade will also increase the amount of requests you can use in all AI-powered features.', + 'jetpack-ai-client' + ); + + const upgradeMessageRequests = __( + 'Not enough requests left to generate a logo. Upgrade your Jetpack AI to increase the amount of requests you can use in all AI-powered features.', + 'jetpack-ai-client' + ); + + const { context } = useLogoGenerator(); + + const handleUpgradeClick = () => { + recordTracksEvent( EVENT_UPGRADE, { context, placement: EVENT_PLACEMENT_FREE_USER_SCREEN } ); + onCancel(); + }; + + return ( +
+
+ + { reason === 'feature' ? upgradeMessageFeature : upgradeMessageRequests } + +   + +
+
+ + +
+
+ ); +}; diff --git a/projects/js-packages/ai-client/src/logo-generator/components/visit-site-banner.scss b/projects/js-packages/ai-client/src/logo-generator/components/visit-site-banner.scss new file mode 100644 index 0000000000000..51335abe22401 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/components/visit-site-banner.scss @@ -0,0 +1,29 @@ +@import '@automattic/jetpack-base-styles/root-variables'; + +.jetpack-ai-logo-generator-modal-visit-site-banner { + border-radius: 4px; + background: var(--studio-gray-0, #f6f7f7); + padding: 16px 20px; + display: flex; + gap: 16px; + + @media (max-width: 700px) { + flex-direction: column; + } +} + +.jetpack-ai-logo-generator-modal-visit-site-banner__jetpack-logo { + display: flex; + justify-content: center; + align-items: center; +} + +.jetpack-ai-logo-generator-modal-visit-site-banner__content { + font-size: var(--font-body-small, 14px); + display: flex; + flex-direction: column; + + @media (max-width: 700px) { + gap: 8px; + } +} diff --git a/projects/js-packages/ai-client/src/logo-generator/components/visit-site-banner.tsx b/projects/js-packages/ai-client/src/logo-generator/components/visit-site-banner.tsx new file mode 100644 index 0000000000000..c9cb45868b027 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/components/visit-site-banner.tsx @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { Button, Icon } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { external } from '@wordpress/icons'; +import clsx from 'clsx'; +/** + * Internal dependencies + */ +import jetpackLogo from '../assets/images/jetpack-logo.svg'; +import './visit-site-banner.scss'; +/** + * Types + */ +import type React from 'react'; + +export const VisitSiteBanner: React.FC< { + className?: string; + siteURL?: string; + onVisitBlankTarget: () => void; +} > = ( { className = null, siteURL = '#', onVisitBlankTarget } ) => { + return ( +
+
+ Jetpack +
+
+ + { __( + 'Do you want to know all the amazing things you can do with Jetpack AI?', + 'jetpack-ai-client' + ) } + + + { __( + 'Generate and tweak content, create forms, get feedback and much more.', + 'jetpack-ai-client' + ) } + +
+ +
+
+
+ ); +}; diff --git a/projects/js-packages/ai-client/src/logo-generator/constants.ts b/projects/js-packages/ai-client/src/logo-generator/constants.ts new file mode 100644 index 0000000000000..073b29f9006d8 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/constants.ts @@ -0,0 +1,22 @@ +export const JWT_TOKEN_ID = 'jetpack-ai-jwt'; +export const JWT_TOKEN_EXPIRATION_TIME = 2 * 60 * 1000; // 2 minutes + +// Tracks event names +export const EVENT_MODAL_OPEN = 'jetpack_ai_logo_generator_modal_open'; +export const EVENT_MODAL_CLOSE = 'jetpack_ai_logo_generator_modal_close'; +export const EVENT_GENERATE = 'jetpack_ai_logo_generator_generate'; +export const EVENT_SAVE = 'jetpack_ai_logo_generator_save'; +export const EVENT_USE = 'jetpack_ai_logo_generator_use'; +export const EVENT_NAVIGATE = 'jetpack_ai_logo_generator_navigate'; +export const EVENT_FEEDBACK = 'jetpack_ai_logo_generator_feedback'; +export const EVENT_UPGRADE = 'jetpack_ai_upgrade_button'; + +// Event placement constants +export const EVENT_PLACEMENT_QUICK_LINKS = 'quick_links'; +export const EVENT_PLACEMENT_INPUT_FOOTER = 'input_footer'; +export const EVENT_PLACEMENT_FREE_USER_SCREEN = 'free_user_screen'; +export const EVENT_PLACEMENT_UPGRADE_PROMPT = 'upgrade_prompt'; + +// Feature constants +export const MINIMUM_PROMPT_LENGTH = 3; +export const DEFAULT_LOGO_COST = 10; diff --git a/projects/js-packages/ai-client/src/logo-generator/hooks/use-checkout.ts b/projects/js-packages/ai-client/src/logo-generator/hooks/use-checkout.ts new file mode 100644 index 0000000000000..d1a3ccdb72f38 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/hooks/use-checkout.ts @@ -0,0 +1,37 @@ +/** + * External dependencies + */ +import { useSelect } from '@wordpress/data'; +import debugFactory from 'debug'; +/** + * Internal dependencies + */ +import { STORE_NAME } from '../store/index.js'; +/** + * Types + */ +import type { Selectors } from '../store/types.js'; + +const debug = debugFactory( 'ai-client:logo-generator:use-checkout' ); + +export const useCheckout = () => { + const { nextTier, siteDetails } = useSelect( select => { + const selectors: Selectors = select( STORE_NAME ); + return { + nextTier: selectors.getAiAssistantFeature().nextTier, + siteDetails: selectors.getSiteDetails(), + }; + }, [] ); + + const upgradeURL = new URL( + `${ location.origin }/checkout/${ siteDetails?.domain }/${ nextTier?.slug }` + ); + upgradeURL.searchParams.set( 'redirect_to', location.href ); + + debug( 'Next tier checkout URL: ', upgradeURL.toString() ); + + return { + nextTierCheckoutURL: upgradeURL.toString(), + hasNextTier: !! nextTier, + }; +}; diff --git a/projects/js-packages/ai-client/src/logo-generator/hooks/use-logo-generator.ts b/projects/js-packages/ai-client/src/logo-generator/hooks/use-logo-generator.ts new file mode 100644 index 0000000000000..1448d7ac32f4c --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/hooks/use-logo-generator.ts @@ -0,0 +1,389 @@ +/** + * External dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data'; +import debugFactory from 'debug'; +import { useCallback } from 'react'; +/** + * Internal dependencies + */ +import useImageGenerator from '../../hooks/use-image-generator/index.js'; +import useSaveToMediaLibrary from '../../hooks/use-save-to-media-library/index.js'; +import requestJwt from '../../jwt/index.js'; +import { stashLogo } from '../lib/logo-storage.js'; +import { setSiteLogo } from '../lib/set-site-logo.js'; +import { STORE_NAME } from '../store/index.js'; +import useRequestErrors from './use-request-errors.js'; +/** + * Types + */ +import type { Logo, Selectors, SaveLogo } from '../store/types.js'; + +const debug = debugFactory( 'jetpack-ai-calypso:use-logo-generator' ); + +const useLogoGenerator = () => { + const { + setSelectedLogoIndex, + setIsSavingLogoToLibrary, + setIsApplyingLogo, + setIsRequestingImage, + setIsEnhancingPrompt, + increaseAiAssistantRequestsCount, + addLogoToHistory, + setContext, + } = useDispatch( STORE_NAME ); + + const { + logos, + selectedLogoIndex, + selectedLogo, + siteDetails, + isSavingLogoToLibrary, + isApplyingLogo, + isEnhancingPrompt, + isBusy, + isRequestingImage, + getAiAssistantFeature, + requireUpgrade, + context, + } = useSelect( select => { + const selectors: Selectors = select( STORE_NAME ); + + return { + logos: selectors.getLogos(), + selectedLogoIndex: selectors.getSelectedLogoIndex(), + selectedLogo: selectors.getSelectedLogo(), + siteDetails: selectors.getSiteDetails(), + isSavingLogoToLibrary: selectors.getIsSavingLogoToLibrary(), + isApplyingLogo: selectors.getIsApplyingLogo(), + isRequestingImage: selectors.getIsRequestingImage(), + isEnhancingPrompt: selectors.getIsEnhancingPrompt(), + isBusy: selectors.getIsBusy(), + getAiAssistantFeature: selectors.getAiAssistantFeature, + requireUpgrade: selectors.getRequireUpgrade(), + context: selectors.getContext(), + }; + }, [] ); + + const { + setFirstLogoPromptFetchError, + setEnhancePromptFetchError, + setLogoFetchError, + setSaveToLibraryError, + setLogoUpdateError, + } = useRequestErrors(); + + const { generateImageWithParameters } = useImageGenerator(); + const { saveToMediaLibrary } = useSaveToMediaLibrary(); + + const { ID = null, name = null, description = null } = siteDetails || {}; + const siteId = ID ? String( ID ) : null; + + const aiAssistantFeatureData = getAiAssistantFeature( siteId ); + const logoGenerationCost = aiAssistantFeatureData?.costs?.[ 'jetpack-ai-logo-generator' ]?.logo; + + const generateFirstPrompt = useCallback( + async function (): Promise< string > { + setFirstLogoPromptFetchError( null ); + increaseAiAssistantRequestsCount(); + + try { + const tokenData = await requestJwt(); + + if ( ! tokenData || ! tokenData.token ) { + throw new Error( 'No token provided' ); + } + + debug( 'Generating first prompt for site' ); + + const firstPromptGenerationPrompt = `Generate a simple and short prompt asking for a logo based on the site's name and description, keeping the same language. +Example for a site named "The minimalist fashion blog", described as "Daily inspiration for all things fashion": A logo for a minimalist fashion site focused on daily sartorial inspiration with a clean and modern aesthetic that is sleek and sophisticated. +Another example, now for a site called "El observatorio de aves", described as "Un sitio dedicado a nuestros compañeros y compañeras entusiastas de la observación de aves.": Un logo para un sitio web dedicado a la observación de aves, capturando la esencia de la naturaleza y la pasión por la avifauna en un diseño elegante y representativo, reflejando una estética natural y apasionada por la vida silvestre. + +Site name: ${ name } +Site description: ${ description }`; + + const body = { + question: firstPromptGenerationPrompt, + feature: 'jetpack-ai-logo-generator', + stream: false, + }; + + const URL = 'https://public-api.wordpress.com/wpcom/v2/jetpack-ai-query'; + const headers = { + Authorization: `Bearer ${ tokenData.token }`, + 'Content-Type': 'application/json', + }; + + const data = await fetch( URL, { + method: 'POST', + headers, + body: JSON.stringify( body ), + } ).then( response => response.json() ); + + return data?.choices?.[ 0 ]?.message?.content; + } catch ( error ) { + increaseAiAssistantRequestsCount( -1 ); + setFirstLogoPromptFetchError( error ); + throw error; + } + }, + [ setFirstLogoPromptFetchError, increaseAiAssistantRequestsCount ] + ); + + const enhancePrompt = async function ( { prompt }: { prompt: string } ): Promise< string > { + setEnhancePromptFetchError( null ); + increaseAiAssistantRequestsCount(); + + try { + const tokenData = await requestJwt(); + + if ( ! tokenData || ! tokenData.token ) { + throw new Error( 'No token provided' ); + } + + debug( 'Enhancing prompt', prompt ); + + const systemMessage = `Enhance the prompt you receive. +The prompt is meant for generating a logo. Return the same prompt enhanced, and make each enhancement wrapped in brackets. +Do not add any mention to text, letters, typography or the name of the site in the prompt. +For example: user's prompt: A logo for an ice cream shop. Returned prompt: A logo for an ice cream shop [that is pink] [and vibrant].`; + + const messages = [ + { + role: 'system', + content: systemMessage, + }, + { + role: 'user', + content: prompt, + }, + ]; + + const body = { + messages, + feature: 'jetpack-ai-logo-generator', + stream: false, + }; + + const URL = 'https://public-api.wordpress.com/wpcom/v2/jetpack-ai-query'; + const headers = { + Authorization: `Bearer ${ tokenData.token }`, + 'Content-Type': 'application/json', + }; + + const data = await fetch( URL, { + method: 'POST', + headers, + body: JSON.stringify( body ), + } ).then( response => response.json() ); + + return data?.choices?.[ 0 ]?.message?.content; + } catch ( error ) { + increaseAiAssistantRequestsCount( -1 ); + setEnhancePromptFetchError( error ); + throw error; + } + }; + + const generateImage = useCallback( async function ( { + prompt, + }: { + prompt: string; + } ): Promise< { data: Array< { url: string } > } > { + setLogoFetchError( null ); + + try { + const tokenData = await requestJwt(); + + if ( ! tokenData || ! tokenData.token ) { + throw new Error( 'No token provided' ); + } + + debug( 'Generating image with prompt', prompt ); + + const imageGenerationPrompt = `I NEED to test how the tool works with extremely simple prompts. DO NOT add any detail, just use it AS-IS: +Create a single text-free iconic vector logo that symbolically represents the user request, using abstract or symbolic imagery. +The design should be modern, with either a vivid color scheme full of gradients or a color scheme that's monochromatic. Use any of those styles based on the user request mood. +Ensure the logo is set against a clean solid background. +Ensure the logo works in small sizes. +The imagery in the logo should subtly hint at the mood of the user request but DO NOT use any text, letters, or the name of the site on the imagery. +The image should contain a single icon, without variations, color palettes or different versions. + +User request:${ prompt }`; + + const body = { + prompt: imageGenerationPrompt, + feature: 'jetpack-ai-logo-generator', + response_format: 'b64_json', + }; + + const data = await generateImageWithParameters( body ); + + return data as { data: { url: string }[] }; + } catch ( error ) { + setLogoFetchError( error ); + throw error; + } + }, [] ); + + const saveLogo = useCallback< SaveLogo >( + async logo => { + setSaveToLibraryError( null ); + + try { + debug( 'Saving logo for site' ); + + // If the logo is already saved, return its mediaId and mediaURL. + if ( logo.mediaId ) { + return { mediaId: logo.mediaId, mediaURL: logo.url }; + } + + const savedLogo = { + mediaId: 0, + mediaURL: '', + }; + + setIsSavingLogoToLibrary( true ); + + const { id: mediaId, url: mediaURL } = await saveToMediaLibrary( + logo.url, + 'site-logo.png' + ); + + savedLogo.mediaId = parseInt( mediaId ); + savedLogo.mediaURL = mediaURL; + + return savedLogo; + } catch ( error ) { + setSaveToLibraryError( error ); + throw error; + } finally { + setIsSavingLogoToLibrary( false ); + } + }, + [ setIsSavingLogoToLibrary, setSaveToLibraryError ] + ); + + const applyLogo = useCallback( async () => { + setLogoUpdateError( null ); + + try { + if ( ! siteId || ! selectedLogo ) { + throw new Error( 'Missing siteId or logo' ); + } + + debug( 'Applying logo for site', siteId ); + + setIsApplyingLogo( true ); + + const { mediaId } = selectedLogo; + + if ( ! mediaId ) { + throw new Error( 'Missing mediaId' ); + } + + await setSiteLogo( { + siteId: siteId, + imageId: String( mediaId ), + } ); + } catch ( error ) { + setLogoUpdateError( error ); + throw error; + } finally { + setIsApplyingLogo( false ); + } + }, [ selectedLogo, setIsApplyingLogo, setLogoUpdateError, siteId ] ); + + const storeLogo = useCallback( + ( logo: Logo ) => { + addLogoToHistory( logo ); + stashLogo( { ...logo, siteId: String( siteId ) } ); + }, + [ siteId, addLogoToHistory, stashLogo ] + ); + + const generateLogo = useCallback( + async function ( { prompt }: { prompt: string } ): Promise< void > { + debug( 'Generating logo for site' ); + + setIsRequestingImage( true ); + + try { + if ( ! logoGenerationCost ) { + throw new Error( 'Missing cost information' ); + } + + increaseAiAssistantRequestsCount( logoGenerationCost ); + + let image; + + try { + image = await generateImage( { prompt } ); + + if ( ! image || ! image.data.length ) { + throw new Error( 'No image returned' ); + } + } catch ( error ) { + increaseAiAssistantRequestsCount( -logoGenerationCost ); + throw error; + } + + // response_format=url returns object with url, otherwise b64_json + const logo: Logo = { + url: 'data:image/png;base64,' + image.data[ 0 ].b64_json, + description: prompt, + }; + + try { + const savedLogo = await saveLogo( logo ); + storeLogo( { + url: savedLogo.mediaURL, + description: prompt, + mediaId: savedLogo.mediaId, + } ); + } catch ( error ) { + storeLogo( logo ); + throw error; + } + } finally { + setIsRequestingImage( false ); + } + }, + [ logoGenerationCost, increaseAiAssistantRequestsCount, saveLogo, storeLogo, generateImage ] + ); + + return { + logos, + selectedLogoIndex, + selectedLogo, + setSelectedLogoIndex, + site: { + id: siteId, + name, + description, + }, + generateFirstPrompt, + saveLogo, + applyLogo, + generateImage, + enhancePrompt, + storeLogo, + generateLogo, + setIsEnhancingPrompt, + setIsRequestingImage, + setIsSavingLogoToLibrary, + setIsApplyingLogo, + setContext, + isEnhancingPrompt, + isRequestingImage, + isSavingLogoToLibrary, + isApplyingLogo, + isBusy, + getAiAssistantFeature, + requireUpgrade, + context, + }; +}; + +export default useLogoGenerator; diff --git a/projects/js-packages/ai-client/src/logo-generator/hooks/use-request-errors.ts b/projects/js-packages/ai-client/src/logo-generator/hooks/use-request-errors.ts new file mode 100644 index 0000000000000..8fd579b727073 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/hooks/use-request-errors.ts @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data'; +/** + * Internal dependencies + */ +import { STORE_NAME } from '../store/index.js'; +/** + * Types + */ +import type { Selectors } from '../store/types.js'; + +const useRequestErrors = () => { + const { + setFeatureFetchError, + setFirstLogoPromptFetchError, + setEnhancePromptFetchError, + setLogoFetchError, + setSaveToLibraryError, + setLogoUpdateError, + } = useDispatch( STORE_NAME ); + + const { + featureFetchError, + firstLogoPromptFetchError, + enhancePromptFetchError, + logoFetchError, + saveToLibraryError, + logoUpdateError, + } = useSelect( select => { + const selectors: Selectors = select( STORE_NAME ); + + return { + featureFetchError: selectors.getFeatureFetchError(), + firstLogoPromptFetchError: selectors.getFirstLogoPromptFetchError(), + enhancePromptFetchError: selectors.getEnhancePromptFetchError(), + logoFetchError: selectors.getLogoFetchError(), + saveToLibraryError: selectors.getSaveToLibraryError(), + logoUpdateError: selectors.getLogoUpdateError(), + }; + }, [] ); + + const clearErrors = () => { + setFeatureFetchError( null ); + setFirstLogoPromptFetchError( null ); + setEnhancePromptFetchError( null ); + setLogoFetchError( null ); + setSaveToLibraryError( null ); + setLogoUpdateError( null ); + }; + + return { + setFeatureFetchError, + setFirstLogoPromptFetchError, + setEnhancePromptFetchError, + setLogoFetchError, + setSaveToLibraryError, + setLogoUpdateError, + clearErrors, + featureFetchError, + firstLogoPromptFetchError, + enhancePromptFetchError, + logoFetchError, + saveToLibraryError, + logoUpdateError, + }; +}; + +export default useRequestErrors; diff --git a/projects/js-packages/ai-client/src/logo-generator/index.ts b/projects/js-packages/ai-client/src/logo-generator/index.ts new file mode 100644 index 0000000000000..e6a9b9dec67ce --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/index.ts @@ -0,0 +1 @@ +export * from './components/generator-modal.js'; diff --git a/projects/js-packages/ai-client/src/logo-generator/lib/logo-storage.ts b/projects/js-packages/ai-client/src/logo-generator/lib/logo-storage.ts new file mode 100644 index 0000000000000..a80c47f4b0507 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/lib/logo-storage.ts @@ -0,0 +1,166 @@ +/** + * Types + */ +import { Logo } from '../store/types.js'; +import { RemoveFromStorageProps, SaveToStorageProps, UpdateInStorageProps } from '../types.js'; +import { mediaExists } from './media-exists.js'; + +const MAX_LOGOS = 10; + +/** + * Add an entry to the site's logo history. + * + * @param {SaveToStorageProps} saveToStorageProps - The properties to save to storage + * @param {SaveToStorageProps.siteId} saveToStorageProps.siteId - The site ID + * @param {SaveToStorageProps.url} saveToStorageProps.url - The URL of the logo + * @param {SaveToStorageProps.description} saveToStorageProps.description - The description of the logo, based on the prompt used to generate it + * @param {SaveToStorageProps.mediaId} saveToStorageProps.mediaId - The media ID of the logo on the backend + * + * @returns {Logo} The logo that was saved + */ +export function stashLogo( { siteId, url, description, mediaId }: SaveToStorageProps ) { + const storedContent = getSiteLogoHistory( siteId ); + + const logo: Logo = { + url, + description, + mediaId, + }; + + storedContent.push( logo ); + + localStorage.setItem( + `logo-history-${ siteId }`, + JSON.stringify( storedContent.slice( -MAX_LOGOS ) ) + ); + + return logo; +} + +/** + * Update an entry in the site's logo history. + * + * @param {UpdateInStorageProps} updateInStorageProps - The properties to update in storage + * @param {UpdateInStorageProps.siteId} updateInStorageProps.siteId - The site ID + * @param {UpdateInStorageProps.url} updateInStorageProps.url - The URL of the logo to update + * @param {UpdateInStorageProps.newUrl} updateInStorageProps.newUrl - The new URL of the logo + * @param {UpdateInStorageProps.mediaId} updateInStorageProps.mediaId - The new media ID of the logo + * @returns {Logo} The logo that was updated + */ +export function updateLogo( { siteId, url, newUrl, mediaId }: UpdateInStorageProps ) { + const storedContent = getSiteLogoHistory( siteId ); + + const index = storedContent.findIndex( logo => logo.url === url ); + + if ( index > -1 ) { + storedContent[ index ].url = newUrl; + storedContent[ index ].mediaId = mediaId; + } + + localStorage.setItem( + `logo-history-${ siteId }`, + JSON.stringify( storedContent.slice( -MAX_LOGOS ) ) + ); + + return storedContent[ index ]; +} + +/** + * Get the logo history for a site. + * + * @param {string} siteId - The site ID to get the logo history for + * @returns {Logo[]} The logo history for the site + */ +export function getSiteLogoHistory( siteId: string ) { + const storedString = localStorage.getItem( `logo-history-${ siteId }` ); + let storedContent: Logo[] = storedString ? JSON.parse( storedString ) : []; + + // Ensure that the stored content is an array + if ( ! Array.isArray( storedContent ) ) { + storedContent = []; + } + + // Ensure a maximum of 10 logos are stored + storedContent = storedContent.slice( -MAX_LOGOS ); + + // Ensure that the stored content is an array of Logo objects + storedContent = storedContent + .filter( logo => { + return ( + typeof logo === 'object' && + typeof logo.url === 'string' && + typeof logo.description === 'string' + ); + } ) + .map( logo => ( { + url: logo.url, + description: logo.description, + mediaId: logo.mediaId, + } ) ); + + return storedContent; +} + +/** + * Check if the logo history for a site is empty. + * + * @param {string }siteId - The site ID to check the logo history for + * @returns {boolean} Whether the logo history for the site is empty + */ +export function isLogoHistoryEmpty( siteId: string ) { + const storedContent = getSiteLogoHistory( siteId ); + + return storedContent.length === 0; +} + +/** + * Remove an entry from the site's logo history. + * + * @param {RemoveFromStorageProps} removeFromStorageProps - The properties to remove from storage + * @param {RemoveFromStorageProps.siteId} removeFromStorageProps.siteId - The site ID + * @param {RemoveFromStorageProps.mediaId} removeFromStorageProps.mediaId - The media ID of the logo to remove + * @returns {void} + */ +export function removeLogo( { siteId, mediaId }: RemoveFromStorageProps ) { + const storedContent = getSiteLogoHistory( siteId ); + const index = storedContent.findIndex( logo => logo.mediaId === mediaId ); + + if ( index === -1 ) { + return; + } + + storedContent.splice( index, 1 ); + localStorage.setItem( `logo-history-${ siteId }`, JSON.stringify( storedContent ) ); +} + +/** + * Clear deleted media from the site's logo history, checking if the media still exists on the backend. + * + * @param {string} siteId - The site ID to clear deleted media for + * @returns {Promise} + */ +export async function clearDeletedMedia( siteId: string ) { + const storedContent = getSiteLogoHistory( siteId ); + + const checks = storedContent + .filter( ( { mediaId } ) => mediaId !== undefined ) + .map( + ( { mediaId } ) => + new Promise( ( resolve, reject ) => { + mediaExists( { siteId, mediaId } ) + .then( exists => resolve( { mediaId, exists } ) ) + .catch( error => reject( error ) ); + } ) + ); + + try { + const responses = ( await Promise.all( checks ) ) as { + mediaId: Logo[ 'mediaId' ]; + exists: boolean; + }[]; + + responses + .filter( ( { exists } ) => ! exists ) + .forEach( ( { mediaId } ) => removeLogo( { siteId, mediaId } ) ); + } catch ( error ) {} // Assume that the media exists if there was a network error and do nothing to avoid data loss. +} diff --git a/projects/js-packages/ai-client/src/logo-generator/lib/media-exists.ts b/projects/js-packages/ai-client/src/logo-generator/lib/media-exists.ts new file mode 100644 index 0000000000000..9df5950e1f6a2 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/lib/media-exists.ts @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import apiFetch from '../../api-fetch/index.js'; +/** + * Types + */ +import type { CheckMediaProps } from '../types.js'; + +/** + * Uses the media information to confirm it exists or not on the server. + * + * @param {CheckMediaProps} checkMediaProps - the media details to check + * @param {CheckMediaProps.mediaId} checkMediaProps.mediaId - the id of the media to check + * @returns {Promise} - true if the media exists, false otherwise + */ +export async function mediaExists( { mediaId }: CheckMediaProps ): Promise< boolean > { + const id = Number( mediaId ); + + if ( Number.isNaN( id ) ) { + return false; + } + + try { + // Using apiFetch directly here because we don't want to limit the number of concurrent media checks + // We store at most 10 logos in the local storage, so the number of concurrent requests should be limited + await apiFetch( { + path: `/wp/v2/media/${ Number( mediaId ) }`, + method: 'GET', + } ); + + return true; + } catch ( error ) { + const status = ( error as { data?: { status?: number } } )?.data?.status; + + if ( status === 404 ) { + return false; + } + + throw error; + } +} diff --git a/projects/js-packages/ai-client/src/logo-generator/lib/set-site-logo.ts b/projects/js-packages/ai-client/src/logo-generator/lib/set-site-logo.ts new file mode 100644 index 0000000000000..a677c74137c70 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/lib/set-site-logo.ts @@ -0,0 +1,32 @@ +/** + * Internal dependencies + */ +import wpcomLimitedRequest from './wpcom-limited-request.js'; +/** + * Types + */ +import type { SetSiteLogoProps, SetSiteLogoResponseProps } from '../types.js'; + +/** + * Set the site logo using a backend request. + * + * @param {SetSiteLogoProps} setSiteLogoProps - The properties to set the site logo + * @param {SetSiteLogoProps.siteId} setSiteLogoProps.siteId - The site ID + * @param {SetSiteLogoProps.imageId} setSiteLogoProps.imageId - The image ID to set as the site logo + * @returns {Promise} The response from the request + */ +export async function setSiteLogo( { siteId, imageId }: SetSiteLogoProps ) { + const body = { + site_logo: imageId, + site_icon: imageId, + }; + + return wpcomLimitedRequest< SetSiteLogoResponseProps >( { + path: `/sites/${ String( siteId ) }/settings`, + apiVersion: 'v2', + apiNamespace: 'wp/v2', + body, + query: 'source=jetpack-ai', + method: 'POST', + } ); +} diff --git a/projects/js-packages/ai-client/src/logo-generator/lib/wpcom-limited-request.ts b/projects/js-packages/ai-client/src/logo-generator/lib/wpcom-limited-request.ts new file mode 100644 index 0000000000000..d38cdbd383367 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/lib/wpcom-limited-request.ts @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import apiFetch from '../../api-fetch/index.js'; +/** + * Types + */ + +const MAX_CONCURRENT_REQUESTS = 5; + +let concurrentCounter = 0; +let lastCallTimestamp: number | null = null; + +/** + * Concurrency-limited request to wpcom-proxy-request. + * @param { object } params - The request params, as expected by apiFetch. + * @returns { Promise } The response. + * @throws { Error } If there are too many concurrent requests. + */ +export default async function wpcomLimitedRequest< T >( params: object ): Promise< T > { + concurrentCounter += 1; + + if ( concurrentCounter > MAX_CONCURRENT_REQUESTS ) { + concurrentCounter -= 1; + throw new Error( 'Too many requests' ); + } + + const now = Date.now(); + + // Check if the last call was made less than 100 milliseconds ago + if ( lastCallTimestamp && now - lastCallTimestamp < 100 ) { + concurrentCounter -= 1; + throw new Error( 'Too many requests' ); + } + + lastCallTimestamp = now; // Update the timestamp + + return apiFetch< T >( params ).finally( () => { + concurrentCounter -= 1; + } ); +} diff --git a/projects/js-packages/ai-client/src/logo-generator/store/actions.ts b/projects/js-packages/ai-client/src/logo-generator/store/actions.ts new file mode 100644 index 0000000000000..a166edf0b3939 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/store/actions.ts @@ -0,0 +1,251 @@ +/** + * Internal dependencies + */ +import { getSiteLogoHistory } from '../lib/logo-storage.js'; +import wpcomLimitedRequest from '../lib/wpcom-limited-request.js'; +/** + * Types & Constants + */ +import { + ACTION_INCREASE_AI_ASSISTANT_REQUESTS_COUNT, + ACTION_REQUEST_AI_ASSISTANT_FEATURE, + ACTION_SET_AI_ASSISTANT_FEATURE_REQUIRE_UPGRADE, + ACTION_SET_SITE_DETAILS, + ACTION_STORE_AI_ASSISTANT_FEATURE, + ACTION_SET_TIER_PLANS_ENABLED, + ACTION_SET_SELECTED_LOGO_INDEX, + ACTION_ADD_LOGO_TO_HISTORY, + ACTION_SET_IS_SAVING_LOGO_TO_LIBRARY, + ACTION_SAVE_SELECTED_LOGO, + ACTION_SET_IS_REQUESTING_IMAGE, + ACTION_SET_IS_APPLYING_LOGO, + ACTION_SET_IS_ENHANCING_PROMPT, + ACTION_SET_SITE_HISTORY, + ACTION_SET_FEATURE_FETCH_ERROR, + ACTION_SET_FIRST_LOGO_PROMPT_FETCH_ERROR, + ACTION_SET_ENHANCE_PROMPT_FETCH_ERROR, + ACTION_SET_LOGO_FETCH_ERROR, + ACTION_SET_LOGO_UPDATE_ERROR, + ACTION_SET_SAVE_TO_LIBRARY_ERROR, + ACTION_SET_CONTEXT, +} from './constants.js'; +import type { + AiFeatureProps, + AiAssistantFeatureEndpointResponseProps, + Logo, + RequestError, +} from './types.js'; +import type { SiteDetails } from '../types.js'; + +/** + * Map the response from the `sites/$site/ai-assistant-feature` + * endpoint to the AI Assistant feature props. + * @param { AiAssistantFeatureEndpointResponseProps } response - The response from the endpoint. + * @returns { AiFeatureProps } The AI Assistant feature props. + */ +export function mapAiFeatureResponseToAiFeatureProps( + response: AiAssistantFeatureEndpointResponseProps +): AiFeatureProps { + return { + hasFeature: !! response[ 'has-feature' ], + isOverLimit: !! response[ 'is-over-limit' ], + requestsCount: response[ 'requests-count' ], + requestsLimit: response[ 'requests-limit' ], + requireUpgrade: !! response[ 'site-require-upgrade' ], + errorMessage: response[ 'error-message' ], + errorCode: response[ 'error-code' ], + upgradeType: response[ 'upgrade-type' ], + usagePeriod: { + currentStart: response[ 'usage-period' ]?.[ 'current-start' ], + nextStart: response[ 'usage-period' ]?.[ 'next-start' ], + requestsCount: response[ 'usage-period' ]?.[ 'requests-count' ] || 0, + }, + currentTier: response[ 'current-tier' ], + nextTier: response[ 'next-tier' ], + tierPlansEnabled: !! response[ 'tier-plans-enabled' ], + costs: response.costs, + }; +} + +const actions = { + storeAiAssistantFeature( feature: AiFeatureProps ) { + return { + type: ACTION_STORE_AI_ASSISTANT_FEATURE, + feature, + }; + }, + + /** + * Thunk action to fetch the AI Assistant feature from the API. + * @returns {Function} The thunk action. + */ + fetchAiAssistantFeature() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return async ( { dispatch }: { dispatch: any } ) => { + // Dispatch isFetching action. + dispatch( { type: ACTION_REQUEST_AI_ASSISTANT_FEATURE } ); + + try { + const response: AiAssistantFeatureEndpointResponseProps = await wpcomLimitedRequest( { + path: '/wpcom/v2/jetpack-ai/ai-assistant-feature', + query: 'force=wpcom', + } ); + + // Store the feature in the store. + dispatch( + actions.storeAiAssistantFeature( mapAiFeatureResponseToAiFeatureProps( response ) ) + ); + } catch ( err ) { + // Mark the fetch as failed. + dispatch( { type: ACTION_SET_FEATURE_FETCH_ERROR, error: err } ); + } + }; + }, + + /** + * This thunk action is used to increase + * the requests count for the current usage period. + * @param {number} count - The number of requests to increase. Default is 1. + * @returns {Function} The thunk action. + */ + increaseAiAssistantRequestsCount( count: number = 1 ) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return ( { dispatch }: { dispatch: any } ) => { + dispatch( { + type: ACTION_INCREASE_AI_ASSISTANT_REQUESTS_COUNT, + count, + } ); + }; + }, + + setAiAssistantFeatureRequireUpgrade( requireUpgrade: boolean = true ) { + return { + type: ACTION_SET_AI_ASSISTANT_FEATURE_REQUIRE_UPGRADE, + requireUpgrade, + }; + }, + + setTierPlansEnabled( tierPlansEnabled: boolean = true ) { + return { + type: ACTION_SET_TIER_PLANS_ENABLED, + tierPlansEnabled, + }; + }, + + setSiteDetails( siteDetails: SiteDetails ) { + return { + type: ACTION_SET_SITE_DETAILS, + siteDetails, + }; + }, + + setSelectedLogoIndex( selectedLogoIndex: number ) { + return { + type: ACTION_SET_SELECTED_LOGO_INDEX, + selectedLogoIndex, + }; + }, + + addLogoToHistory( logo: Logo ) { + return { + type: ACTION_ADD_LOGO_TO_HISTORY, + logo, + }; + }, + + setIsSavingLogoToLibrary( isSavingLogoToLibrary: boolean ) { + return { + type: ACTION_SET_IS_SAVING_LOGO_TO_LIBRARY, + isSavingLogoToLibrary, + }; + }, + + setIsApplyingLogo( isApplyingLogo: boolean ) { + return { + type: ACTION_SET_IS_APPLYING_LOGO, + isApplyingLogo, + }; + }, + + updateSelectedLogo( mediaId: string, url: string ) { + return { + type: ACTION_SAVE_SELECTED_LOGO, + mediaId, + url, + }; + }, + + setIsRequestingImage( isRequestingImage: boolean ) { + return { + type: ACTION_SET_IS_REQUESTING_IMAGE, + isRequestingImage, + }; + }, + + setIsEnhancingPrompt( isEnhancingPrompt: boolean ) { + return { + type: ACTION_SET_IS_ENHANCING_PROMPT, + isEnhancingPrompt, + }; + }, + + loadLogoHistory( siteId: string ) { + const history = getSiteLogoHistory( siteId ); + + return { + type: ACTION_SET_SITE_HISTORY, + history, + }; + }, + + setFeatureFetchError( error: RequestError ) { + return { + type: ACTION_SET_FEATURE_FETCH_ERROR, + error, + }; + }, + + setFirstLogoPromptFetchError( error: RequestError ) { + return { + type: ACTION_SET_FIRST_LOGO_PROMPT_FETCH_ERROR, + error, + }; + }, + + setEnhancePromptFetchError( error: RequestError ) { + return { + type: ACTION_SET_ENHANCE_PROMPT_FETCH_ERROR, + error, + }; + }, + + setLogoFetchError( error: RequestError ) { + return { + type: ACTION_SET_LOGO_FETCH_ERROR, + error, + }; + }, + + setSaveToLibraryError( error: RequestError ) { + return { + type: ACTION_SET_SAVE_TO_LIBRARY_ERROR, + error, + }; + }, + + setLogoUpdateError( error: RequestError ) { + return { + type: ACTION_SET_LOGO_UPDATE_ERROR, + error, + }; + }, + + setContext( context: string ) { + return { + type: ACTION_SET_CONTEXT, + context, + }; + }, +}; + +export default actions; diff --git a/projects/js-packages/ai-client/src/logo-generator/store/constants.ts b/projects/js-packages/ai-client/src/logo-generator/store/constants.ts new file mode 100644 index 0000000000000..c9cc2d62eaba3 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/store/constants.ts @@ -0,0 +1,49 @@ +/** + * AI Assistant feature actions + */ +export const ACTION_STORE_AI_ASSISTANT_FEATURE = 'STORE_AI_ASSISTANT_FEATURE'; +export const ACTION_REQUEST_AI_ASSISTANT_FEATURE = 'REQUEST_AI_ASSISTANT_FEATURE'; +export const ACTION_INCREASE_AI_ASSISTANT_REQUESTS_COUNT = 'INCREASE_AI_ASSISTANT_REQUESTS_COUNT'; +export const ACTION_SET_AI_ASSISTANT_FEATURE_REQUIRE_UPGRADE = + 'SET_AI_ASSISTANT_FEATURE_REQUIRE_UPGRADE'; +export const ACTION_SET_TIER_PLANS_ENABLED = 'SET_TIER_PLANS_ENABLED'; +export const ACTION_SET_SITE_DETAILS = 'SET_SITE_DETAILS'; + +/** + * Endpoints + */ +export const ENDPOINT_AI_ASSISTANT_FEATURE = '/wpcom/v2/jetpack-ai/ai-assistant-feature'; + +/** + * New AI Assistant feature async request + */ +export const FREE_PLAN_REQUESTS_LIMIT = 20; +export const UNLIMITED_PLAN_REQUESTS_LIMIT = 999999999; +export const ASYNC_REQUEST_COUNTDOWN_INIT_VALUE = 3; +export const NEW_ASYNC_REQUEST_TIMER_INTERVAL = 5000; +export const ACTION_DECREASE_NEW_ASYNC_REQUEST_COUNTDOWN = 'DECREASE_NEW_ASYNC_REQUEST_COUNTDOWN'; +export const ACTION_ENQUEUE_ASYNC_REQUEST = 'ENQUEUE_ASYNC_COUNTDOWN_REQUEST'; +export const ACTION_DEQUEUE_ASYNC_REQUEST = 'DEQUEUE_ASYNC_COUNTDOWN_REQUEST'; + +/** + * Logo generator actions + */ +export const ACTION_SET_CONTEXT = 'SET_CONTEXT'; +export const ACTION_SET_SELECTED_LOGO_INDEX = 'SET_SELECTED_LOGO_INDEX'; +export const ACTION_ADD_LOGO_TO_HISTORY = 'ADD_LOGO_TO_HISTORY'; +export const ACTION_SET_IS_SAVING_LOGO_TO_LIBRARY = 'SET_IS_SAVING_LOGO_TO_LIBRARY'; +export const ACTION_SET_IS_APPLYING_LOGO = 'SET_IS_APPLYING_LOGO'; +export const ACTION_SAVE_SELECTED_LOGO = 'SAVE_SELECTED_LOGO'; +export const ACTION_SET_IS_REQUESTING_IMAGE = 'SET_IS_REQUESTING_IMAGE'; +export const ACTION_SET_IS_ENHANCING_PROMPT = 'SET_IS_ENHANCING_PROMPT'; +export const ACTION_SET_SITE_HISTORY = 'SET_SITE_HISTORY'; + +/** + * Logo generator error actions + */ +export const ACTION_SET_FEATURE_FETCH_ERROR = 'SET_FEATURE_FETCH_ERROR'; +export const ACTION_SET_FIRST_LOGO_PROMPT_FETCH_ERROR = 'SET_FIRST_LOGO_PROMPT_FETCH_ERROR'; +export const ACTION_SET_ENHANCE_PROMPT_FETCH_ERROR = 'SET_ENHANCE_PROMPT_FETCH_ERROR'; +export const ACTION_SET_LOGO_FETCH_ERROR = 'SET_LOGO_FETCH_ERROR'; +export const ACTION_SET_SAVE_TO_LIBRARY_ERROR = 'SET_SAVE_TO_LIBRARY_ERROR'; +export const ACTION_SET_LOGO_UPDATE_ERROR = 'SET_LOGO_UPDATE_ERROR'; diff --git a/projects/js-packages/ai-client/src/logo-generator/store/index.ts b/projects/js-packages/ai-client/src/logo-generator/store/index.ts new file mode 100644 index 0000000000000..12b4e6c8c7ef8 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/store/index.ts @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import { createReduxStore, register } from '@wordpress/data'; +/** + * Internal dependencies + */ +import actions from './actions.js'; +import reducer from './reducer.js'; +import selectors from './selectors.js'; + +export const STORE_NAME = 'jetpack-ai/logo-generator'; + +const jetpackAiLogoGeneratorStore = createReduxStore( STORE_NAME, { + // @ts-expect-error -- TSCONVERSION + __experimentalUseThunks: true, + + actions, + + reducer, + + selectors, +} ); + +register( jetpackAiLogoGeneratorStore ); diff --git a/projects/js-packages/ai-client/src/logo-generator/store/initial-state.ts b/projects/js-packages/ai-client/src/logo-generator/store/initial-state.ts new file mode 100644 index 0000000000000..7b130dc12e31e --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/store/initial-state.ts @@ -0,0 +1,43 @@ +/** + * Types & Constants + */ +import { ASYNC_REQUEST_COUNTDOWN_INIT_VALUE, FREE_PLAN_REQUESTS_LIMIT } from './constants.js'; +import { LogoGeneratorStateProp } from './types.js'; + +const INITIAL_STATE: LogoGeneratorStateProp = { + siteDetails: {}, + features: { + aiAssistantFeature: { + hasFeature: true, + isOverLimit: false, + requestsCount: 0, + requestsLimit: FREE_PLAN_REQUESTS_LIMIT, + requireUpgrade: false, + errorMessage: '', + errorCode: '', + upgradeType: 'default', + currentTier: { + slug: 'ai-assistant-tier-free', + value: 0, + limit: 20, + }, + usagePeriod: { + currentStart: '', + nextStart: '', + requestsCount: 0, + }, + nextTier: null, + tierPlansEnabled: false, + _meta: { + isRequesting: false, + asyncRequestCountdown: ASYNC_REQUEST_COUNTDOWN_INIT_VALUE, + asyncRequestTimerId: 0, + isRequestingImage: false, + }, + }, + }, + history: [], + selectedLogoIndex: 0, +}; + +export default INITIAL_STATE; diff --git a/projects/js-packages/ai-client/src/logo-generator/store/reducer.ts b/projects/js-packages/ai-client/src/logo-generator/store/reducer.ts new file mode 100644 index 0000000000000..50db694226385 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/store/reducer.ts @@ -0,0 +1,387 @@ +/** + * Types & Constants + */ +import { DEFAULT_LOGO_COST } from '../constants.js'; +import { + ACTION_INCREASE_AI_ASSISTANT_REQUESTS_COUNT, + ACTION_REQUEST_AI_ASSISTANT_FEATURE, + ACTION_SET_AI_ASSISTANT_FEATURE_REQUIRE_UPGRADE, + ACTION_STORE_AI_ASSISTANT_FEATURE, + ASYNC_REQUEST_COUNTDOWN_INIT_VALUE, + FREE_PLAN_REQUESTS_LIMIT, + UNLIMITED_PLAN_REQUESTS_LIMIT, + ACTION_SET_TIER_PLANS_ENABLED, + ACTION_SET_SITE_DETAILS, + ACTION_SET_SELECTED_LOGO_INDEX, + ACTION_ADD_LOGO_TO_HISTORY, + ACTION_SAVE_SELECTED_LOGO, + ACTION_SET_IS_SAVING_LOGO_TO_LIBRARY, + ACTION_SET_IS_REQUESTING_IMAGE, + ACTION_SET_IS_APPLYING_LOGO, + ACTION_SET_IS_ENHANCING_PROMPT, + ACTION_SET_SITE_HISTORY, + ACTION_SET_FEATURE_FETCH_ERROR, + ACTION_SET_FIRST_LOGO_PROMPT_FETCH_ERROR, + ACTION_SET_ENHANCE_PROMPT_FETCH_ERROR, + ACTION_SET_LOGO_FETCH_ERROR, + ACTION_SET_SAVE_TO_LIBRARY_ERROR, + ACTION_SET_LOGO_UPDATE_ERROR, + ACTION_SET_CONTEXT, +} from './constants.js'; +import INITIAL_STATE from './initial-state.js'; +import type { + AiFeatureStateProps, + LogoGeneratorStateProp, + RequestError, + TierLimitProp, +} from './types.js'; +import type { SiteDetails } from '../types.js'; + +/** + * Reducer for the Logo Generator store. + * + * @param {LogoGeneratorStateProp} state - The current state + * @param {object} action - The action to apply, as described by the properties below + * @param {string} action.type - The action type + * @param {AiFeatureStateProps} action.feature - The AI Assistant feature state + * @param {number} action.count - The number of requests to increase the counter by + * @param {boolean} action.requireUpgrade - Whether an upgrade is required + * @param {boolean} action.tierPlansEnabled - Whether tier plans are enabled + * @param {SiteDetails} action.siteDetails - The site details + * @param {number} action.selectedLogoIndex - The selected logo index + * @param {boolean} action.isSavingLogoToLibrary - Whether a logo is being saved to the library + * @param {boolean} action.isApplyingLogo - Whether a logo is being applied + * @param {object} action.logo - The logo to save, as described by the properties below + * @param {string} action.logo.url - The logo URL + * @param {string} action.logo.description - The logo description + * @param {number} action.mediaId - The media ID from backend + * @param {string} action.url - The URL to save + * @param {boolean} action.isRequestingImage - Whether an image is being requested + * @param {boolean} action.isEnhancingPrompt - Whether a prompt enhancement is being requested + * @param {Array< { url: string; description: string; mediaId?: number } >} action.history - The logo history + * @param {RequestError} action.error - The error to set + * @param {string} action.context - The context where the tool is being used + * @returns {LogoGeneratorStateProp} The new state + */ +export default function reducer( + state = INITIAL_STATE, + action: { + type: string; + feature?: AiFeatureStateProps; + count?: number; + requireUpgrade?: boolean; + tierPlansEnabled?: boolean; + siteDetails?: SiteDetails; + selectedLogoIndex?: number; + isSavingLogoToLibrary?: boolean; + isApplyingLogo?: boolean; + logo?: { url: string; description: string }; + mediaId?: number; + url?: string; + isRequestingImage?: boolean; + isEnhancingPrompt?: boolean; + history?: Array< { url: string; description: string; mediaId?: number } >; + error?: RequestError; + context?: string; + } +) { + switch ( action.type ) { + case ACTION_REQUEST_AI_ASSISTANT_FEATURE: + return { + ...state, + _meta: { + ...( state._meta ?? {} ), + // Reset the error state when requesting the feature. + featureFetchError: null, + }, + features: { + ...state.features, + aiAssistantFeature: { + ...state.features.aiAssistantFeature, + _meta: { + ...state?.features?.aiAssistantFeature?._meta, + isRequesting: true, + asyncRequestCountdown: ASYNC_REQUEST_COUNTDOWN_INIT_VALUE, // restore the countdown + asyncRequestTimerId: 0, // reset the timer id + }, + }, + }, + }; + + case ACTION_STORE_AI_ASSISTANT_FEATURE: { + const defaultCosts = { + 'jetpack-ai-logo-generator': { + logo: DEFAULT_LOGO_COST, + }, + }; + + return { + ...state, + features: { + ...state.features, + aiAssistantFeature: { + costs: defaultCosts, + ...action.feature, + // re evaluate requireUpgrade as the logo generator does not allow free usage + requireUpgrade: + action.feature?.requireUpgrade || action.feature?.currentTier?.value === 0, + _meta: { + ...state?.features?.aiAssistantFeature?._meta, + isRequesting: false, + }, + }, + }, + }; + } + + case ACTION_INCREASE_AI_ASSISTANT_REQUESTS_COUNT: { + // Usage Period data + const usagePeriod = state?.features?.aiAssistantFeature?.usagePeriod || { requestsCount: 0 }; + + // Increase requests counters + const requestsCount = + ( state?.features?.aiAssistantFeature?.requestsCount || 0 ) + ( action.count ?? 1 ); + usagePeriod.requestsCount += action.count ?? 1; + + // Current tier value + const currentTierValue = state?.features?.aiAssistantFeature?.currentTier?.value; + + const isFreeTierPlan = + ( typeof currentTierValue === 'undefined' && + ! state?.features?.aiAssistantFeature?.hasFeature ) || + currentTierValue === 0; + + const isUnlimitedTierPlan = + ( typeof currentTierValue === 'undefined' && + state?.features?.aiAssistantFeature?.hasFeature ) || + currentTierValue === 1; + + // Request limit defined with the current tier limit by default. + let requestsLimit: TierLimitProp = + state?.features?.aiAssistantFeature?.currentTier?.limit || FREE_PLAN_REQUESTS_LIMIT; + + if ( isUnlimitedTierPlan ) { + requestsLimit = UNLIMITED_PLAN_REQUESTS_LIMIT; + } else if ( isFreeTierPlan ) { + requestsLimit = state?.features?.aiAssistantFeature?.requestsLimit as TierLimitProp; + } + + const currentCount = + isUnlimitedTierPlan || isFreeTierPlan // @todo: update once tier data is available + ? requestsCount + : state?.features?.aiAssistantFeature?.usagePeriod?.requestsCount || 0; + + /** + * Compute the AI Assistant Feature data optimistically, + * based on the Jetpack_AI_Helper::get_ai_assistance_feature() helper. + * @see _inc/lib/class-jetpack-ai-helper.php + */ + const isOverLimit = currentCount >= requestsLimit; + + // highest tier holds a soft limit so requireUpgrade is false on that case (nextTier null means highest tier) + const requireUpgrade = + isFreeTierPlan || ( isOverLimit && state?.features?.aiAssistantFeature?.nextTier !== null ); + + return { + ...state, + features: { + ...state.features, + aiAssistantFeature: { + ...state.features.aiAssistantFeature, + isOverLimit, + requestsCount, + requireUpgrade, + usagePeriod: { ...usagePeriod }, + }, + }, + }; + } + + case ACTION_SET_AI_ASSISTANT_FEATURE_REQUIRE_UPGRADE: { + /* + * If we require an upgrade, we are also over the limit; + * The opposite is not true, we can be over the limit without + * requiring an upgrade, for example when we are on the highest tier. + * In this case, we don't want to set isOverLimit to false. + */ + return { + ...state, + features: { + ...state.features, + aiAssistantFeature: { + ...state.features.aiAssistantFeature, + requireUpgrade: action.requireUpgrade, + ...( action.requireUpgrade ? { isOverLimit: true } : {} ), + }, + }, + }; + } + + case ACTION_SET_TIER_PLANS_ENABLED: { + return { + ...state, + features: { + ...state.features, + aiAssistantFeature: { + ...state.features.aiAssistantFeature, + tierPlansEnabled: action.tierPlansEnabled, + }, + }, + }; + } + + case ACTION_SET_SITE_DETAILS: { + return { + ...state, + siteDetails: action.siteDetails, + }; + } + + case ACTION_SET_SELECTED_LOGO_INDEX: { + return { + ...state, + selectedLogoIndex: action.selectedLogoIndex, + }; + } + + case ACTION_ADD_LOGO_TO_HISTORY: { + const history = [ ...state.history, action.logo ]; + + return { + ...state, + history, + selectedLogoIndex: history.length - 1, + }; + } + + case ACTION_SET_IS_SAVING_LOGO_TO_LIBRARY: { + return { + ...state, + _meta: { + ...( state._meta ?? {} ), + isSavingLogoToLibrary: action.isSavingLogoToLibrary, + }, + }; + } + + case ACTION_SET_IS_APPLYING_LOGO: { + return { + ...state, + _meta: { + ...( state._meta ?? {} ), + isApplyingLogo: action.isApplyingLogo, + }, + }; + } + + case ACTION_SAVE_SELECTED_LOGO: { + const selectedLogo = state.history?.[ state.selectedLogoIndex ]; + + return { + ...state, + history: [ + ...state.history.slice( 0, state.selectedLogoIndex ), + { + ...selectedLogo, + mediaId: action.mediaId, + url: action.url, + }, + ...state.history.slice( state.selectedLogoIndex + 1 ), + ], + }; + } + + case ACTION_SET_IS_REQUESTING_IMAGE: { + return { + ...state, + _meta: { + ...( state._meta ?? {} ), + isRequestingImage: action.isRequestingImage, + }, + }; + } + + case ACTION_SET_IS_ENHANCING_PROMPT: { + return { + ...state, + _meta: { + ...( state._meta ?? {} ), + isEnhancingPrompt: action.isEnhancingPrompt, + }, + }; + } + + case ACTION_SET_SITE_HISTORY: { + return { + ...state, + history: action.history, + selectedLogoIndex: action.history?.length ? action.history.length - 1 : 0, + }; + } + + case ACTION_SET_FEATURE_FETCH_ERROR: + return { + ...state, + _meta: { + ...( state._meta ?? {} ), + featureFetchError: action.error, + }, + }; + + case ACTION_SET_FIRST_LOGO_PROMPT_FETCH_ERROR: + return { + ...state, + _meta: { + ...( state._meta ?? {} ), + firstLogoPromptFetchError: action.error, + }, + }; + + case ACTION_SET_ENHANCE_PROMPT_FETCH_ERROR: + return { + ...state, + _meta: { + ...( state._meta ?? {} ), + enhancePromptFetchError: action.error, + }, + }; + + case ACTION_SET_LOGO_FETCH_ERROR: + return { + ...state, + _meta: { + ...( state._meta ?? {} ), + logoFetchError: action.error, + }, + }; + + case ACTION_SET_SAVE_TO_LIBRARY_ERROR: + return { + ...state, + _meta: { + ...( state._meta ?? {} ), + saveToLibraryError: action.error, + }, + }; + + case ACTION_SET_LOGO_UPDATE_ERROR: + return { + ...state, + _meta: { + ...( state._meta ?? {} ), + logoUpdateError: action.error, + }, + }; + + case ACTION_SET_CONTEXT: + return { + ...state, + _meta: { + ...( state._meta ?? {} ), + context: action.context, + }, + }; + } + + return state; +} diff --git a/projects/js-packages/ai-client/src/logo-generator/store/selectors.ts b/projects/js-packages/ai-client/src/logo-generator/store/selectors.ts new file mode 100644 index 0000000000000..c9445b73ab635 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/store/selectors.ts @@ -0,0 +1,201 @@ +/** + * Types + */ +import { DEFAULT_LOGO_COST } from '../constants.js'; +import type { AiFeatureProps, LogoGeneratorStateProp, Logo, RequestError } from './types.js'; +import type { SiteDetails } from '../types.js'; + +const selectors = { + /** + * Return the AI Assistant feature. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {Partial} The AI Assistant feature data. + */ + getAiAssistantFeature( state: LogoGeneratorStateProp ): Partial< AiFeatureProps > { + // Clean up the _meta property. + const data = { ...state.features.aiAssistantFeature }; + delete data._meta; + + return data; + }, + + /** + * Return the site details. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {Partial | undefined} The site details. + */ + getSiteDetails( state: LogoGeneratorStateProp ): Partial< SiteDetails > | undefined { + return state.siteDetails; + }, + + /** + * Get the isRequesting flag for the AI Assistant feature. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {boolean} The isRequesting flag. + */ + getIsRequestingAiAssistantFeature( state: LogoGeneratorStateProp ): boolean { + return state.features.aiAssistantFeature?._meta?.isRequesting ?? false; + }, + + /** + * Get the logos history. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {Array} The logos history array. + */ + getLogos( state: LogoGeneratorStateProp ): Array< Logo > { + return state.history ?? []; + }, + + /** + * Get the selected logo index. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {number | null} The selected logo index. + */ + getSelectedLogoIndex( state: LogoGeneratorStateProp ): number | null { + return state.selectedLogoIndex ?? null; + }, + + /** + * Get the selected logo. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {Logo} The selected logo. + */ + getSelectedLogo( state: LogoGeneratorStateProp ): Logo { + return state.history?.[ state.selectedLogoIndex ] ?? null; + }, + + /** + * Get the isSavingToLibrary flag. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {boolean} The isSavingToLibrary flag. + */ + getIsSavingLogoToLibrary( state: LogoGeneratorStateProp ): boolean { + return state._meta?.isSavingLogoToLibrary ?? false; + }, + + /** + * Get the isApplyingLogo flag. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {boolean} The isApplyingLogo flag. + */ + getIsApplyingLogo( state: LogoGeneratorStateProp ): boolean { + return state._meta?.isApplyingLogo ?? false; + }, + + /** + * Get the isEnhancingPrompt flag. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {boolean} The isEnhancingPrompt flag. + */ + getIsEnhancingPrompt( state: LogoGeneratorStateProp ): boolean { + return state._meta?.isEnhancingPrompt ?? false; + }, + + /** + * Get the isRequestingImage flag. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {boolean} The isRequestingImage flag. + */ + getIsRequestingImage( state: LogoGeneratorStateProp ): boolean { + return state._meta?.isRequestingImage ?? false; + }, + + /** + * Get an aggregated isBusy flag, based on the loading states of the app. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {boolean} The isBusy flag. + */ + getIsBusy( state: LogoGeneratorStateProp ): boolean { + return ( + selectors.getIsApplyingLogo( state ) || + selectors.getIsSavingLogoToLibrary( state ) || + selectors.getIsRequestingImage( state ) || + selectors.getIsEnhancingPrompt( state ) + ); + }, + + /** + * Get the requireUpgrade value from aiAssistantFeature + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {boolean} The requireUpgrade flag. + */ + getRequireUpgrade( state: LogoGeneratorStateProp ): boolean { + const feature = state.features.aiAssistantFeature; + const logoCost = feature?.costs?.[ 'jetpack-ai-logo-generator' ]?.logo ?? DEFAULT_LOGO_COST; + const currentLimit = feature?.currentTier?.value || 0; + const currentUsage = feature?.usagePeriod?.requestsCount || 0; + const isUnlimited = currentLimit === 1; + const hasNoNextTier = ! feature?.nextTier; // If there is no next tier, the user cannot upgrade. + + // Add a local check on top of the feature flag, based on the current usage and logo cost. + return ( + state.features.aiAssistantFeature?.requireUpgrade || + ( ! isUnlimited && ! hasNoNextTier && currentLimit - currentUsage < logoCost ) + ); + }, + + /** + * Get the featureFetchError value. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {RequestError} The featureFetchError value. + */ + getFeatureFetchError( state: LogoGeneratorStateProp ): RequestError { + return state._meta?.featureFetchError ?? null; + }, + + /** + * Get the firstLogoPromptFetchError value. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {RequestError} The firstLogoPromptFetchError value. + */ + getFirstLogoPromptFetchError( state: LogoGeneratorStateProp ): RequestError { + return state._meta?.firstLogoPromptFetchError ?? null; + }, + + /** + * Get the enhancePromptFetchError value. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {RequestError} The enhancePromptFetchError value. + */ + getEnhancePromptFetchError( state: LogoGeneratorStateProp ): RequestError { + return state._meta?.enhancePromptFetchError ?? null; + }, + + /** + * Get the logoFetchError value. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {RequestError} The logoFetchError value. + */ + getLogoFetchError( state: LogoGeneratorStateProp ): RequestError { + return state._meta?.logoFetchError ?? null; + }, + + /** + * Get the saveToLibraryError value. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {RequestError} The saveToLibraryError value. + */ + getSaveToLibraryError( state: LogoGeneratorStateProp ): RequestError { + return state._meta?.saveToLibraryError ?? null; + }, + + /** + * Get the logoUpdateError value. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {RequestError} The logoUpdateError value. + */ + getLogoUpdateError( state: LogoGeneratorStateProp ): RequestError { + return state._meta?.logoUpdateError ?? null; + }, + + /** + * Get the context value. + * @param {LogoGeneratorStateProp} state - The app state tree. + * @returns {string} The context value. + */ + getContext( state: LogoGeneratorStateProp ): string { + return state._meta?.context ?? ''; + }, +}; + +export default selectors; diff --git a/projects/js-packages/ai-client/src/logo-generator/store/types.ts b/projects/js-packages/ai-client/src/logo-generator/store/types.ts new file mode 100644 index 0000000000000..a26e96a0cc9c1 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/store/types.ts @@ -0,0 +1,207 @@ +import type { SiteDetails } from '../types.js'; + +/** + * Types for the AI Assistant feature. + */ +export type Plan = { + product_id: number; + product_name: string; + product_slug: string; +}; +// AI Assistant feature props +export type UpgradeTypeProp = 'vip' | 'default'; + +export type TierUnlimitedProps = { + slug: 'ai-assistant-tier-unlimited'; + limit: 999999999; + value: 1; + readableLimit: string; +}; + +export type TierFreeProps = { + slug: 'ai-assistant-tier-free'; + limit: 20; + value: 0; +}; + +export type Tier100Props = { + slug: 'ai-assistant-tier-100'; + limit: 100; + value: 100; +}; + +export type Tier200Props = { + slug: 'ai-assistant-tier-200'; + limit: 200; + value: 200; +}; + +export type Tier500Props = { + slug: 'ai-assistant-tier-500'; + limit: 500; + value: 500; +}; + +export type Tier750Props = { + slug: 'ai-assistant-tier-750'; + limit: 750; + value: 750; +}; + +export type Tier1000Props = { + slug: 'ai-assistant-tier-1000'; + limit: 1000; + value: 1000; +}; + +export type TierProp = { + slug: TierSlugProp; + limit: TierLimitProp; + value: TierValueProp; + readableLimit?: string; +}; + +export type TierLimitProp = + | TierUnlimitedProps[ 'limit' ] + | TierFreeProps[ 'limit' ] + | Tier100Props[ 'limit' ] + | Tier200Props[ 'limit' ] + | Tier500Props[ 'limit' ] + | Tier750Props[ 'limit' ] + | Tier1000Props[ 'limit' ]; + +export type TierSlugProp = + | TierUnlimitedProps[ 'slug' ] + | TierFreeProps[ 'slug' ] + | Tier100Props[ 'slug' ] + | Tier200Props[ 'slug' ] + | Tier500Props[ 'slug' ] + | Tier750Props[ 'slug' ] + | Tier1000Props[ 'slug' ]; + +export type TierValueProp = + | TierUnlimitedProps[ 'value' ] + | TierFreeProps[ 'value' ] + | Tier100Props[ 'value' ] + | Tier200Props[ 'value' ] + | Tier500Props[ 'value' ] + | Tier750Props[ 'value' ] + | Tier1000Props[ 'value' ]; + +export type AiFeatureProps = { + hasFeature: boolean; + isOverLimit: boolean; + requestsCount: number; + requestsLimit: number; + requireUpgrade: boolean; + errorMessage?: string; + errorCode?: string; + upgradeType: UpgradeTypeProp; + currentTier?: TierProp; + usagePeriod?: { + currentStart: string; + nextStart: string; + requestsCount: number; + }; + nextTier?: TierProp | null; + tierPlansEnabled?: boolean; + costs?: { + 'jetpack-ai-logo-generator': { + logo: number; + }; + }; +}; + +// Type used in the `wordpress-com/plans` store. +export type AiFeatureStateProps = AiFeatureProps & { + _meta?: { + isRequesting: boolean; + asyncRequestCountdown: number; + asyncRequestTimerId: number; + isRequestingImage: boolean; + }; +}; + +export type Logo = { + url: string; + description: string; + mediaId?: number; +}; + +export type RequestError = string | Error | null; + +export type LogoGeneratorStateProp = { + _meta?: { + isSavingLogoToLibrary: boolean; + isApplyingLogo: boolean; + isRequestingImage: boolean; + isEnhancingPrompt: boolean; + featureFetchError?: RequestError; + firstLogoPromptFetchError?: RequestError; + enhancePromptFetchError?: RequestError; + logoFetchError?: RequestError; + saveToLibraryError?: RequestError; + logoUpdateError?: RequestError; + context: string; + }; + siteDetails?: SiteDetails | Record< string, never >; + features: { + aiAssistantFeature?: AiFeatureStateProps; + }; + history: Array< Logo >; + selectedLogoIndex: number; +}; + +export type Selectors = { + getAiAssistantFeature( siteId?: string ): Partial< AiFeatureProps >; + getIsRequestingAiAssistantFeature(): boolean; + getLogos(): Array< Logo >; + getSelectedLogoIndex(): number | null; + getSelectedLogo(): Logo; + getSiteDetails(): SiteDetails; + getIsSavingLogoToLibrary(): boolean; + getIsApplyingLogo(): boolean; + getIsRequestingImage(): boolean; + getIsEnhancingPrompt(): boolean; + getIsBusy(): boolean; + getRequireUpgrade(): boolean; + getFeatureFetchError(): RequestError; + getFirstLogoPromptFetchError(): RequestError; + getEnhancePromptFetchError(): RequestError; + getLogoFetchError(): RequestError; + getSaveToLibraryError(): RequestError; + getLogoUpdateError(): RequestError; + getContext(): string; +}; + +/* + * `sites/$site/ai-assistant-feature` endpoint response body props + */ +export type AiAssistantFeatureEndpointResponseProps = { + 'is-enabled': boolean; + 'has-feature': boolean; + 'is-over-limit': boolean; + 'requests-count': number; + 'requests-limit': number; + 'usage-period': { + 'current-start': string; + 'next-start': string; + 'requests-count': number; + }; + 'site-require-upgrade': boolean; + 'error-message'?: string; + 'error-code'?: string; + 'is-playground-visible'?: boolean; + 'upgrade-type': UpgradeTypeProp; + 'current-tier': TierProp; + 'tier-plans': Array< TierProp >; + 'next-tier'?: TierProp | null; + 'tier-plans-enabled': boolean; + costs: { + 'jetpack-ai-logo-generator': { + logo: number; + }; + }; +}; + +export type SaveLogo = ( logo: Logo ) => Promise< { mediaId: number; mediaURL: string } >; diff --git a/projects/js-packages/ai-client/src/logo-generator/types.ts b/projects/js-packages/ai-client/src/logo-generator/types.ts new file mode 100644 index 0000000000000..43ad5947878f7 --- /dev/null +++ b/projects/js-packages/ai-client/src/logo-generator/types.ts @@ -0,0 +1,97 @@ +/** + * Types + */ +import type { Logo } from './store/types.js'; + +export type SiteDetails = { + ID: number; + URL: string; + domain: string; + name: string; + description: string; +}; + +export interface GeneratorModalProps { + siteDetails?: SiteDetails; + isOpen: boolean; + onClose: () => void; + context: string; +} + +export interface LogoPresenterProps { + logo?: Logo; + loading?: boolean; + onApplyLogo: () => void; + logoAccepted?: boolean; + siteId: string | number; +} + +export type SaveToMediaLibraryProps = { + siteId: string | number; + url: string; + attrs?: { + caption?: string; + description?: string; + title?: string; + alt?: string; + }; +}; + +export type SaveToMediaLibraryResponseProps = { + code: number; + media: [ + { + ID: number; + URL: string; + }, + ]; +}; + +export type CheckMediaProps = { + siteId: string | number; + mediaId: Logo[ 'mediaId' ]; +}; + +export type SetSiteLogoProps = { + siteId: string | number; + imageId: string | number; +}; + +export type SetSiteLogoResponseProps = { + id: number; + url: string; +}; + +// Token +export type RequestTokenOptions = { + siteDetails?: SiteDetails; + isJetpackSite?: boolean; + expirationTime?: number; +}; + +export type TokenDataProps = { + token: string; + blogId: string | undefined; + expire: number; +}; + +export type TokenDataEndpointResponseProps = { + token: string; + blog_id: string; +}; + +export type SaveToStorageProps = Logo & { + siteId: string; +}; + +export type UpdateInStorageProps = { + siteId: string; + url: Logo[ 'url' ]; + newUrl: Logo[ 'url' ]; + mediaId: Logo[ 'mediaId' ]; +}; + +export type RemoveFromStorageProps = { + mediaId: Logo[ 'mediaId' ]; + siteId: string; +}; diff --git a/projects/js-packages/ai-client/src/types.ts b/projects/js-packages/ai-client/src/types.ts index 3ab53f3fcd109..d984a1c49471a 100644 --- a/projects/js-packages/ai-client/src/types.ts +++ b/projects/js-packages/ai-client/src/types.ts @@ -1,3 +1,5 @@ +import type * as BlockEditorSelectors from '@wordpress/block-editor/store/selectors.js'; + export const ERROR_SERVICE_UNAVAILABLE = 'error_service_unavailable' as const; export const ERROR_QUOTA_EXCEEDED = 'error_quota_exceeded' as const; export const ERROR_MODERATION = 'error_moderation' as const; @@ -113,3 +115,9 @@ export type TranscriptionState = RecordingState | 'validating' | 'processing' | * Lib types */ export type { RenderHTMLRules } from './libs/index.js'; + +export interface BlockEditorStore { + selectors: { + [ key in keyof typeof BlockEditorSelectors ]: ( typeof BlockEditorSelectors )[ key ]; + }; +} diff --git a/projects/plugins/jetpack/changelog/update-jetpack-ai-copy-logo-generator-code b/projects/plugins/jetpack/changelog/update-jetpack-ai-copy-logo-generator-code new file mode 100644 index 0000000000000..a9174c1860e69 --- /dev/null +++ b/projects/plugins/jetpack/changelog/update-jetpack-ai-copy-logo-generator-code @@ -0,0 +1,4 @@ +Significance: minor +Type: other + +Jetpack AI: add logo generator codebase to ai-client package and solve issues. diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/index.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/index.tsx index a2eb4ec29d942..1708323ec2eb3 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/index.tsx +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/index.tsx @@ -1,13 +1,15 @@ /** * External dependencies */ +import { GeneratorModal } from '@automattic/jetpack-ai-client'; import { JetpackEditorPanelLogo } from '@automattic/jetpack-shared-extension-utils'; import { useAnalytics } from '@automattic/jetpack-shared-extension-utils'; -import { PanelBody, PanelRow, BaseControl } from '@wordpress/components'; +import { PanelBody, PanelRow, BaseControl, Button } from '@wordpress/components'; import { store as coreStore } from '@wordpress/core-data'; import { useSelect } from '@wordpress/data'; import { PluginPrePublishPanel, PluginDocumentSettingPanel } from '@wordpress/edit-post'; import { store as editorStore } from '@wordpress/editor'; +import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import debugFactory from 'debug'; import React from 'react'; @@ -50,13 +52,23 @@ const isAITitleOptimizationAvailable = window?.Jetpack_Editor_Initial_State?.available_blocks?.[ 'ai-title-optimization' ]?.available || false; +const siteDetails = { + ID: parseInt( window?.Jetpack_Editor_Initial_State?.wpcomBlogId ), + URL: window?.Jetpack_Editor_Initial_State?.siteFragment, + domain: window?.Jetpack_Editor_Initial_State?.siteFragment, + name: '', + description: '', +}; + const JetpackAndSettingsContent = ( { placement, requireUpgrade, upgradeType, }: JetpackSettingsContentProps ) => { const isBreveAvailable = getFeatureAvailability( 'ai-proofread-breve' ); + const isLogoGeneratorAvailable = getFeatureAvailability( 'ai-assistant-site-logo-support' ); const { checkoutUrl } = useAICheckout(); + const [ showLogoGeneratorModal, setShowLogoGeneratorModal ] = useState( false ); return ( <> @@ -66,6 +78,7 @@ const JetpackAndSettingsContent = ( { + { isAITitleOptimizationAvailable && ( @@ -85,6 +98,28 @@ const JetpackAndSettingsContent = ( { ) } + { isLogoGeneratorAvailable && ( + + + setShowLogoGeneratorModal( false ) } + context="jetpack-ai-sidebar" + siteDetails={ siteDetails } + /> + +

+ { __( + 'Experimental panel to trigger the logo generator modal. Will be dropped after the extension is ready.', + 'jetpack' + ) } +

+ +
+
+ ) } { isUsagePanelAvailable && ( diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/style.scss b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/style.scss index 7a5d4528c85f0..2929208008fb8 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/style.scss +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/style.scss @@ -1,3 +1,4 @@ +.jetpack-ai-logo-generator-control__header, .jetpack-ai-proofread-control__header, .jetpack-ai-featured-image-control__header, .jetpack-ai-title-optimization__header { diff --git a/projects/plugins/jetpack/global.d.ts b/projects/plugins/jetpack/global.d.ts index e2937d470ee8a..8fc521f8a48a6 100644 --- a/projects/plugins/jetpack/global.d.ts +++ b/projects/plugins/jetpack/global.d.ts @@ -1 +1,2 @@ declare module '*.png'; +declare module '*.gif'; diff --git a/tools/js-tools/types/global.d.ts b/tools/js-tools/types/global.d.ts index fb55e3a752ac6..db62e6422b591 100644 --- a/tools/js-tools/types/global.d.ts +++ b/tools/js-tools/types/global.d.ts @@ -99,6 +99,7 @@ interface Window { userid: string; username: string; }; + siteFragment?: string; }; myJetpackInitialState?: { adminUrl?: string;