diff --git a/projects/plugins/jetpack/changelog/add-jetpack-seo-assistant-poc b/projects/plugins/jetpack/changelog/add-jetpack-seo-assistant-poc new file mode 100644 index 0000000000000..9bda40718c680 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-jetpack-seo-assistant-poc @@ -0,0 +1,4 @@ +Significance: minor +Type: other + +Jetpack AI: add PoC for SEO assistant, hardcoded and no actionables yet 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 66878a350339b..221a2f509c3b9 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 @@ -17,14 +17,14 @@ import useAICheckout from '../../../../blocks/ai-assistant/hooks/use-ai-checkout import useAiFeature from '../../../../blocks/ai-assistant/hooks/use-ai-feature'; import useAiProductPage from '../../../../blocks/ai-assistant/hooks/use-ai-product-page'; import { getFeatureAvailability } from '../../../../blocks/ai-assistant/lib/utils/get-feature-availability'; -import { isBetaExtension } from '../../../../editor'; +// import { isBetaExtension } from '../../../../editor'; import JetpackPluginSidebar from '../../../../shared/jetpack-plugin-sidebar'; import { PLAN_TYPE_FREE, PLAN_TYPE_UNLIMITED, usePlanType } from '../../../../shared/use-plan-type'; import { FeaturedImage } from '../ai-image'; import { Breve, registerBreveHighlights, Highlight } from '../breve'; import { getBreveAvailability, canWriteBriefBeEnabled } from '../breve/utils/get-availability'; import Feedback from '../feedback'; -import SeoAssistant from '../seo-assistant'; +// import SeoAssistant from '../seo-assistant'; import TitleOptimization from '../title-optimization'; import UsagePanel from '../usage-panel'; import { @@ -60,7 +60,7 @@ const isAITitleOptimizationKeywordsFeatureAvailable = getFeatureAvailability( 'ai-title-optimization-keywords-support' ); -const isSeoAssistantEnabled = getFeatureAvailability( 'ai-seo-assistant' ); +// const isSeoAssistantEnabled = getFeatureAvailability( 'ai-seo-assistant' ); const JetpackAndSettingsContent = ( { placement, @@ -72,6 +72,14 @@ const JetpackAndSettingsContent = ( { const { checkoutUrl } = useAICheckout(); const { productPageUrl } = useAiProductPage(); const isBreveAvailable = getBreveAvailability(); + // const isViewable = useSelect( select => { + // const postTypeName = select( editorStore ).getCurrentPostType(); + // const postTypeObject = ( select( coreStore ) as unknown as CoreSelect ).getPostType( + // postTypeName + // ); + + // return postTypeObject?.viewable; + // }, [] ); const currentTitleOptimizationSectionLabel = __( 'Optimize Publishing', 'jetpack' ); const SEOTitleOptimizationSectionLabel = __( 'Optimize Title', 'jetpack' ); @@ -89,7 +97,7 @@ const JetpackAndSettingsContent = ( { ) } - { isSeoAssistantEnabled && ( + { /* { isSeoAssistantEnabled && isViewable && ( { __( 'SEO', 'jetpack' ) } - + - ) } + ) } */ } { canWriteBriefBeEnabled() && isBreveAvailable && ( diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/big-sky-icon.svg b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/big-sky-icon.svg new file mode 100644 index 0000000000000..9ae8660e1f429 --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/big-sky-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/index.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/index.tsx index 436af29236eac..167dfa8d948c0 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/index.tsx +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/index.tsx @@ -1,21 +1,48 @@ +import { useModuleStatus } from '@automattic/jetpack-shared-extension-utils'; import { Button } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import debugFactory from 'debug'; +import { SeoPlaceholder } from '../../../../plugins/seo/components/placeholder'; +import './style.scss'; +import bigSkyIcon from './big-sky-icon.svg'; +import SeoAssistantWizard from './seo-assistant-wizard'; +import type { SeoAssistantProps } from './types'; const debug = debugFactory( 'jetpack-ai:seo-assistant' ); -export default function SeoAssistant( { busy, disabled } ) { +export default function SeoAssistant( { disabled, onStep }: SeoAssistantProps ) { + const [ isOpen, setIsOpen ] = useState( false ); + const postIsEmpty = useSelect( select => select( editorStore ).isEditedPostEmpty(), [] ); + const { isLoadingModules, isChangingStatus, isModuleActive, changeStatus } = + useModuleStatus( 'seo-tools' ); + + debug( 'rendering seo-assistant entry point' ); return (

{ __( 'Improve post engagement.', 'jetpack' ) }

- + { ( isModuleActive || isLoadingModules ) && ( + + ) } + { ! isModuleActive && ! isLoadingModules && ( + + ) } + setIsOpen( false ) } />
); } diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/seo-assistant-wizard.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/seo-assistant-wizard.tsx new file mode 100644 index 0000000000000..4fd874e0f2c16 --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/seo-assistant-wizard.tsx @@ -0,0 +1,167 @@ +import { Button, Icon, Tooltip } from '@wordpress/components'; +import { useState, useCallback, useEffect, useRef, useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { next, closeSmall, chevronLeft } from '@wordpress/icons'; +import debugFactory from 'debug'; +import './style.scss'; +import { useCompletionStep } from './use-completion-step'; +import { useKeywordsStep } from './use-keywords-step'; +import { useMetaDescriptionStep } from './use-meta-description-step'; +import { useTitleStep } from './use-title-step'; +import WizardInput from './wizard-input'; +import WizardMessages from './wizard-messages'; +import type { SeoAssistantProps, Step, Message } from './types'; + +const debug = debugFactory( 'jetpack-ai:seo-assistant-wizard' ); + +export default function SeoAssistantWizard( { isOpen, close, onStep }: SeoAssistantProps ) { + const [ currentStep, setCurrentStep ] = useState( 0 ); + const [ messages, setMessages ] = useState< Message[] >( [] ); + const messagesEndRef = useRef< HTMLDivElement >( null ); + const [ isBusy, setIsBusy ] = useState( false ); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView( { behavior: 'smooth' } ); + }; + + useEffect( () => { + scrollToBottom(); + }, [ messages ] ); + + const addMessage = useCallback( async ( message: Message ) => { + const newMessage = { + ...message, + showIcon: message.showIcon === false ? false : ! message.isUser, + } as Message; + + setMessages( prev => [ ...prev, { ...newMessage, id: `message-${ prev.length }` } ] ); + }, [] ); + + /* Removes last message */ + const removeLastMessage = () => { + setMessages( prev => prev.slice( 0, -1 ) ); + }; + + const keywordsStep: Step = useKeywordsStep( { + addMessage, + onStep, + } ); + + const titleStep: Step = useTitleStep( { + addMessage, + removeLastMessage, + onStep, + contextData: keywordsStep.value, + setIsBusy, + } ); + + const metaStep: Step = useMetaDescriptionStep( { + addMessage, + removeLastMessage, + onStep, + setIsBusy, + } ); + + const completionStep: Step = useCompletionStep( { + steps: [ keywordsStep, titleStep, metaStep ], + addMessage, + } ); + + const steps: Step[] = useMemo( + () => [ keywordsStep, titleStep, metaStep, completionStep ], + [ keywordsStep, metaStep, titleStep, completionStep ] + ); + + const currentStepData = useMemo( () => steps[ currentStep ], [ steps, currentStep ] ); + + // initialize wizard, set completion monitors + useEffect( () => { + if ( ! isOpen ) { + return; + } + // add messageQueue.length check here for delayed messages + if ( messages.length === 0 ) { + debug( 'init' ); + // Initialize with first step messages + currentStepData.messages.forEach( addMessage ); + } + }, [ isOpen, currentStepData.messages, messages, addMessage ] ); + + const handleNext = useCallback( () => { + if ( currentStep < steps.length - 1 ) { + debug( 'moving to ' + ( currentStep + 1 ), steps[ currentStep + 1 ] ); + setCurrentStep( currentStep + 1 ); + // Add next step messages + // TODO: can we capture completion step here and craft the messages? + // Nothing else has worked so far to keep track of step completions + steps[ currentStep + 1 ].messages.forEach( addMessage ); + steps[ currentStep + 1 ].onStart?.(); + } + }, [ currentStep, steps, setCurrentStep, addMessage ] ); + + const handleSubmit = useCallback( async () => { + await currentStepData.onSubmit?.(); + handleNext(); + }, [ currentStepData, handleNext ] ); + + const handleBack = () => { + if ( currentStep > 0 ) { + setCurrentStep( currentStep - 1 ); + // Re-add previous step messages + steps[ currentStep - 1 ].messages.forEach( message => + addMessage( { + content: message.content, + showIcon: message.showIcon, + } ) + ); + } + }; + + const handleSkip = async () => { + await currentStepData?.onSkip?.(); + handleNext(); + }; + + // Reset states and close the wizard + const handleDone = useCallback( () => { + close(); + setCurrentStep( 0 ); + setMessages( [] ); + steps + .filter( step => step.type !== 'completion' ) + .forEach( step => step.setCompleted( false ) ); + }, [ close, steps ] ); + + return ( + isOpen && ( +
+
+ +

{ currentStepData.title }

+
+ + + + +
+
+ +
+ + + +
+
+ ) + ); +} diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/style.scss b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/style.scss new file mode 100644 index 0000000000000..e541444b6a5e1 --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/style.scss @@ -0,0 +1,221 @@ +.seo-assistant-wizard { + position: fixed; + bottom: 32px; + left: 50%; + transform: translateX(-50%); + width: 384px; + height: 434px; + background: white; + border-radius: 24px; + outline: 0.5px solid var( --jp-gray-5 ); + z-index: 1000; + display: flex; + flex-direction: column; + + &__header { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + // border-bottom: 1px solid #e5e7eb; + background: white; + border-radius: 16px 16px 0 0; + + h2 { + margin: 0; + font-size: 16px; + font-weight: 600; + } + + button { + background: none; + border: none; + cursor: pointer; + padding: 8px; + color: #6b7280; + + &:hover { + color: #374151; + } + } + } + + &__content { + flex: 1 1 auto; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + } + + &__messages { + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px 24px; + overflow-y: auto; + scroll-behavior: smooth; + align-items: flex-start; + mask-image: linear-gradient( 180deg, transparent, white 24px ); + } + + &__message { + border-radius: 16px; + white-space: pre-line; + animation: messageAppear 0.3s ease-out; + font-size: 13px; + line-height: 1.5; + display: flex; + align-items: center; + min-width: 48px; + + .seo-assistant-wizard__message-icon { + flex-shrink: 0; + align-self: center; + flex-basis: 26px; + + img { + vertical-align: middle; + } + } + + .seo-assistant-wizard__message-text { + padding: 4px 12px; + // flex: 1 0 200px; + } + + &.is-user { + background: #f3f4f6; + align-self: flex-end; + max-width: 85%; + + .seo-assistant-wizard__message-icon { + display: none; + } + } + } + + &__input-container { + flex: 0 0 auto; + padding: 16px; + background: white; + border-radius: 0 0 16px 16px; + border-top: 1px solid var( --jp-gray-5, #e5e7eb ); + } + + &__input { + display: flex; + gap: 8px; + border-radius: 12px; + outline: 1px solid var( --jp-gray-10, #c3c3c3 ); + align-items: center; + padding-right: 6px; + height: 44px; + + &:focus-within { + outline-width: 2px; + outline-color: var( --wp-components-color-accent, var( --wp-admin-theme-color, #007cba ) ); + } + + .components-base-control { + flex-grow: 1; + } + .components-text-control__input, + .components-text-control__input:focus { + padding: 8px; + border: 0; + border-radius: 12px; + box-shadow: none; + outline: 0; + } + } + + &__submit { + border-radius: 20px; + max-height: 34px; + } + + &__options { + display: flex; + flex-direction: column; + gap: 12px; + } + + &__option { + width: 100%; + padding: 12px; + background: #f3f4f6; + border: 2px solid transparent; + border-radius: 12px; + text-align: left; + font-size: 14px; + line-height: 1.4; + transition: all 0.2s ease; + animation: messageAppear 0.3s ease-out; + color: inherit; + + &.is-selected { + background: #fff; + border-color: var( --wp-components-color-accent, var( --wp-admin-theme-color, #007cba ) ); + } + + // target buttons only + &:not( div ) { + cursor: pointer; + } + + &:hover:not( div ):not( .is-selected ) { + background: #e5e7eb; + } + } + + &__actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 16px; + + .components-button { + border-radius: 20px; + } + } + + &__completion { + display: flex; + justify-content: flex-end; + + .components-button { + border-radius: 20px; + } + } +} + +@keyframes messageAppear { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// Keep this around for magic: +@keyframes typing-blink { + 30% { cy: 30; } + 50% { cy: 25; fill: lightgrey } + 70% { cy: 30; } +} +.typing-dot { + animation: 1s typing-blink linear infinite; + fill: grey; +} +.typing-dot:nth-child(2) { animation-delay: 150ms } +.typing-dot:nth-child(3) { animation-delay: 250ms } + +.typing-loader { + color: grey; +} diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/types.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/types.tsx new file mode 100644 index 0000000000000..d14a0d2578d19 --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/types.tsx @@ -0,0 +1,71 @@ +type StepType = 'input' | 'options' | 'completion'; + +export interface Message { + id?: string; + content?: string | React.ReactNode; + isUser?: boolean; + showIcon?: boolean; + type?: string; + options?: Option[]; +} + +export interface Option { + id: string; + content: string; + selected?: boolean; +} + +interface BaseStep { + id: string; + title: string; + label?: string; + messages: StepMessage[]; + type: StepType; + onStart?: () => void; + onSubmit?: () => void; + onSkip?: () => void; + value: string; + setValue: + | React.Dispatch< React.SetStateAction< string > > + | React.Dispatch< React.SetStateAction< Array< string > > >; + setCompleted?: React.Dispatch< React.SetStateAction< boolean > >; + completed?: boolean; +} + +interface InputStep extends BaseStep { + type: 'input'; + placeholder: string; +} + +interface OptionsStep extends BaseStep { + type: 'options'; + options: Option[]; + onSelect: ( option: Option ) => void; + submitCtaLabel?: string; + onRetry?: () => void; + onRetryCtaLabel?: string; +} + +interface CompletionStep extends BaseStep { + type: 'completion'; +} + +interface StepMessage { + content: string | React.ReactNode; + showIcon?: boolean; +} + +export type Step = InputStep | OptionsStep | CompletionStep; + +export type CompletionStepHookProps = { + steps: Step[]; + addMessage?: ( message: Message | string ) => void; +}; + +export interface SeoAssistantProps { + isBusy?: boolean; + disabled?: boolean; + onStep?: ( data: { value: string | Option | null } ) => void; + isOpen?: boolean; + close?: () => void; +} diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/typing-message.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/typing-message.tsx new file mode 100644 index 0000000000000..540231b59be73 --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/typing-message.tsx @@ -0,0 +1,11 @@ +import { SVG, Circle } from '@wordpress/components'; + +export default function TypingMessage() { + return ( + + + + + + ); +} diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/use-completion-step.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/use-completion-step.tsx new file mode 100644 index 0000000000000..a46bc41eadd5f --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/use-completion-step.tsx @@ -0,0 +1,49 @@ +import { createInterpolateElement, useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import type { Step, CompletionStepHookProps } from './types'; + +export const useCompletionStep = ( { steps }: CompletionStepHookProps ): Step => { + const getSummaryCheck = useCallback( () => { + const summaryString = steps + .map( step => { + const stepLabel = step.label || step.title; + return step.completed ? `✅ ${ stepLabel }` : `❌ ${ stepLabel }`; + } ) + .join( '
' ); + return createInterpolateElement( summaryString, { br:
} ); + }, [ steps ] ); + + return { + id: 'completion', + title: __( 'Your post is SEO-ready', 'jetpack' ), + // onStart: handleSummaryChecks, + messages: [ + { + content: __( "Here's your updated checklist:", 'jetpack' ), + showIcon: true, + }, + { + content: getSummaryCheck(), + showIcon: false, + }, + { + content: createInterpolateElement( + __( + 'SEO optimization complete! 🎉
Your blog post is now search-engine friendly.', + 'jetpack' + ), + { br:
} + ), + showIcon: true, + }, + { + content: __( 'Happy blogging! 😊', 'jetpack' ), + showIcon: false, + }, + ], + type: 'completion', + // onStart: handleStart, + value: null, + setValue: () => null, + }; +}; diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/use-keywords-step.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/use-keywords-step.tsx new file mode 100644 index 0000000000000..ac811cd2546af --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/use-keywords-step.tsx @@ -0,0 +1,87 @@ +import { createInterpolateElement, useCallback, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import type { Step } from './types'; + +export const useKeywordsStep = ( { addMessage, onStep } ): Step => { + const [ keywords, setKeywords ] = useState( '' ); + const [ completed, setCompleted ] = useState( false ); + + const handleSkip = useCallback( () => { + addMessage( { content: __( 'Skipped!', 'jetpack' ) } ); + if ( onStep ) { + onStep( { value: '' } ); + } + }, [ addMessage, onStep ] ); + + const handleKeywordsSubmit = useCallback( () => { + if ( ! keywords.trim() ) { + return handleSkip(); + } + addMessage( { content: keywords, isUser: true } ); + + const keywordlist = keywords + .split( ',' ) + .map( k => k.trim() ) + .reduce( ( acc, curr, i, arr ) => { + if ( arr.length === 1 ) { + return curr; + } + if ( i === arr.length - 1 ) { + return `${ acc } & ${ curr }`; + } + return i === 0 ? curr : `${ acc }, ${ curr }`; + }, '' ); + const message = createInterpolateElement( + /* Translators: wrapped string is list of keywords user has entered */ + sprintf( __( `Got it! You're targeting %s. ✨✅`, 'jetpack' ), keywordlist ), + { + b: , + } + ); + addMessage( { content: message } ); + setCompleted( true ); + if ( onStep ) { + onStep( { value: keywords } ); + } + }, [ onStep, addMessage, keywords, handleSkip ] ); + + return { + id: 'keywords', + title: __( 'Optimise for SEO', 'jetpack' ), + label: __( 'Keywords', 'jetpack' ), + messages: [ + { + content: createInterpolateElement( + __( "Hi there! 👋 Let's optimise your blog post for SEO.", 'jetpack' ), + { b: } + ), + showIcon: true, + }, + { + content: createInterpolateElement( + __( + "Here's what we can improve:
1. Keywords
2. Title
3. Meta description", + 'jetpack' + ), + { br:
} + ), + showIcon: false, + }, + { + content: __( + 'To start, please enter 1–3 focus keywords that describe your blog post.', + 'jetpack' + ), + showIcon: true, + }, + ], + type: 'input', + placeholder: __( 'Photography, plants', 'jetpack' ), + onSubmit: handleKeywordsSubmit, + onSkip: handleSkip, + completed, + setCompleted, + value: keywords, + setValue: setKeywords, + }; +}; diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/use-meta-description-step.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/use-meta-description-step.tsx new file mode 100644 index 0000000000000..5b4699fd95872 --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/use-meta-description-step.tsx @@ -0,0 +1,117 @@ +import { useDispatch } from '@wordpress/data'; +import { useCallback, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import TypingMessage from './typing-message'; +import type { Step, Option } from './types'; + +export const useMetaDescriptionStep = ( { + addMessage, + removeLastMessage, + onStep, + setIsBusy, +} ): Step => { + const [ selectedMetaDescription, setSelectedMetaDescription ] = useState< string >(); + const [ metaDescriptionOptions, setMetaDescriptionOptions ] = useState< Option[] >( [] ); + const { editPost } = useDispatch( 'core/editor' ); + const [ completed, setCompleted ] = useState( false ); + + const handleMetaDescriptionSelect = useCallback( ( option: Option ) => { + setSelectedMetaDescription( option.content ); + setMetaDescriptionOptions( prev => + prev.map( opt => ( { + ...opt, + selected: opt.id === option.id, + } ) ) + ); + }, [] ); + + const handleMetaDescriptionSubmit = useCallback( async () => { + addMessage( { content: } ); + await editPost( { meta: { advanced_seo_description: selectedMetaDescription } } ); + removeLastMessage(); + addMessage( { content: selectedMetaDescription, isUser: true } ); + addMessage( { content: __( 'Meta description updated! ✅', 'jetpack' ) } ); + setCompleted( true ); + if ( onStep ) { + onStep( { value: selectedMetaDescription } ); + } + }, [ selectedMetaDescription, onStep, addMessage, editPost, removeLastMessage ] ); + + const handleMetaDescriptionGenerate = useCallback( async () => { + setIsBusy( true ); + let newMetaDescriptions; + // we only generate if options are empty + if ( metaDescriptionOptions.length === 0 ) { + addMessage( { content: } ); + newMetaDescriptions = await new Promise( resolve => + setTimeout( + () => + resolve( [ + { + id: 'meta-1', + content: + 'Explore breathtaking flower and plant photography in our Flora Guide, featuring tips and inspiration for gardening and plant enthusiasts to enhance their outdoor spaces.', + }, + ] ), + 2000 + ) + ); + removeLastMessage(); + } + addMessage( { content: __( "Here's a suggestion:", 'jetpack' ) } ); + setMetaDescriptionOptions( newMetaDescriptions || metaDescriptionOptions ); + setIsBusy( false ); + }, [ metaDescriptionOptions, addMessage, removeLastMessage, setIsBusy ] ); + + const handleMetaDescriptionRegenerate = useCallback( async () => { + setMetaDescriptionOptions( [] ); + addMessage( { content: } ); + const newMetaDescription = await new Promise< Array< Option > >( resolve => + setTimeout( + () => + resolve( [ + { + id: 'meta-1', + content: + 'Explore breathtaking flower and plant photography in our Flora Guide, featuring tips and inspiration for gardening and plant enthusiasts to enhance their outdoor spaces.', + }, + ] ), + 2000 + ) + ); + removeLastMessage(); + addMessage( { content: __( "Here's a new suggestion:", 'jetpack' ) } ); + setMetaDescriptionOptions( newMetaDescription ); + }, [ addMessage, removeLastMessage ] ); + + const handleSkip = useCallback( () => { + addMessage( { content: __( 'Skipped!', 'jetpack' ) } ); + if ( onStep ) { + onStep(); + } + }, [ addMessage, onStep ] ); + + return { + id: 'meta', + title: __( 'Add meta description', 'jetpack' ), + messages: [ + { + content: __( "Now, let's optimize your meta description.", 'jetpack' ), + showIcon: true, + }, + ], + type: 'options', + options: metaDescriptionOptions, + onSelect: handleMetaDescriptionSelect, + onSubmit: handleMetaDescriptionSubmit, + submitCtaLabel: __( 'Insert', 'jetpack' ), + onRetry: handleMetaDescriptionRegenerate, + onRetryCtaLabel: __( 'Regenerate', 'jetpack' ), + onStart: handleMetaDescriptionGenerate, + onSkip: handleSkip, + value: selectedMetaDescription, + setValue: setSelectedMetaDescription, + completed, + setCompleted, + }; +}; diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/use-title-step.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/use-title-step.tsx new file mode 100644 index 0000000000000..0153dab8dfaab --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/use-title-step.tsx @@ -0,0 +1,175 @@ +import { useDispatch } from '@wordpress/data'; +import { useCallback, useState, useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import TypingMessage from './typing-message'; +import type { Step, Option } from './types'; + +export const useTitleStep = ( { + addMessage, + removeLastMessage, + onStep, + contextData, + setIsBusy, +} ): Step => { + const [ selectedTitle, setSelectedTitle ] = useState< string >(); + const [ titleOptions, setTitleOptions ] = useState< Option[] >( [] ); + const { editPost } = useDispatch( 'core/editor' ); + const [ completed, setCompleted ] = useState( false ); + + const handleTitleSelect = useCallback( ( option: Option ) => { + setSelectedTitle( option.content ); + setTitleOptions( prev => + prev.map( opt => ( { + ...opt, + selected: opt.id === option.id, + } ) ) + ); + }, [] ); + + useEffect( () => setTitleOptions( [] ), [ contextData ] ); + + const handleTitleGenerate = useCallback( async () => { + setIsBusy( true ); + let newTitles; + // we only generate if options are empty + if ( titleOptions.length === 0 ) { + addMessage( { content: } ); + newTitles = await new Promise( resolve => + setTimeout( + () => + resolve( [ + { + id: '1', + content: 'A Photo Gallery for Gardening Enthusiasths: Flora Guide', + }, + { + id: '2', + content: + 'Flora Guide: Beautiful Photos of Flowers and Plants for Gardening Enthusiasts', + }, + ] ), + 2000 + ) + ); + removeLastMessage(); + } + if ( contextData ) { + addMessage( { + content: __( + 'Here are two suggestions based on your keywords. Select the one you prefer:', + 'jetpack' + ), + } ); + } else { + addMessage( { + content: __( 'Here are two suggestions. Select the one you prefer:', 'jetpack' ), + } ); + } + setTitleOptions( newTitles || titleOptions ); + setIsBusy( false ); + }, [ titleOptions, addMessage, removeLastMessage, contextData, setIsBusy ] ); + + const replaceOptionsWithFauxUseMessages = useCallback( () => { + const optionsMessage = { + id: 'title-options-' + Math.random(), + content: '', + type: 'past-options', + options: [], + showIcon: false, + }; + // removeLastMessage(); + titleOptions.forEach( titleOption => { + optionsMessage.options.push( { ...titleOption } ); + } ); + addMessage( optionsMessage ); + }, [ titleOptions, addMessage ] ); + + const handleTitleRegenerate = useCallback( async () => { + // let the controller know we're working + setIsBusy( true ); + + // This would typically be an async call to generate new titles + replaceOptionsWithFauxUseMessages(); + setTitleOptions( [] ); + addMessage( { content: } ); + const newTitles = await new Promise< Array< Option > >( resolve => + setTimeout( + () => + resolve( [ + { + id: '1', + content: 'A Photo Gallery for Gardening Enthusiasths: Flora Guide', + }, + { + id: '2', + content: + 'Flora Guide: Beautiful Photos of Flowers and Plants for Gardening Enthusiasts', + }, + ] ), + 2000 + ) + ); + removeLastMessage(); + addMessage( { + content: __( + 'Here are two new suggestions based on your keywords. Select the one you prefer:', + 'jetpack' + ), + } ); + setTitleOptions( newTitles ); + setIsBusy( false ); + }, [ addMessage, removeLastMessage, replaceOptionsWithFauxUseMessages, setIsBusy ] ); + + const handleTitleSubmit = useCallback( async () => { + replaceOptionsWithFauxUseMessages(); + addMessage( { content: } ); + await editPost( { title: selectedTitle, meta: { jetpack_seo_html_title: selectedTitle } } ); + removeLastMessage(); + addMessage( { content: __( 'Title updated! ✅', 'jetpack' ) } ); + setCompleted( true ); + if ( onStep ) { + onStep( { value: selectedTitle } ); + } + }, [ + selectedTitle, + onStep, + addMessage, + replaceOptionsWithFauxUseMessages, + editPost, + removeLastMessage, + ] ); + + const handleSkip = useCallback( () => { + if ( titleOptions.length ) { + replaceOptionsWithFauxUseMessages(); + } + addMessage( __( 'Skipped!', 'jetpack' ) ); + if ( onStep ) { + onStep(); + } + }, [ addMessage, onStep, titleOptions, replaceOptionsWithFauxUseMessages ] ); + + return { + id: 'title', + title: __( 'Optimise Title', 'jetpack' ), + messages: [ + { + content: __( "Let's optimise your title.", 'jetpack' ), + showIcon: true, + }, + ], + type: 'options', + options: titleOptions, + onSelect: handleTitleSelect, + onSubmit: handleTitleSubmit, + submitCtaLabel: __( 'Insert', 'jetpack' ), + onRetry: handleTitleRegenerate, + onRetryCtaLabel: __( 'Regenerate', 'jetpack' ), + onStart: handleTitleGenerate, + onSkip: handleSkip, + value: selectedTitle, + setValue: setSelectedTitle, + completed, + setCompleted, + }; +}; diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/wizard-input.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/wizard-input.tsx new file mode 100644 index 0000000000000..f9263e0188088 --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/wizard-input.tsx @@ -0,0 +1,51 @@ +import { Button, TextControl, Icon } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { arrowRight } from '@wordpress/icons'; + +export default function WizardInput( { currentStepData, handleSubmit, handleDone } ) { + const selectedOption = + currentStepData.type === 'options' ? currentStepData.options.find( opt => opt.selected ) : null; + return ( +
+ { currentStepData.type === 'input' && ( +
+ + +
+ ) } + + { currentStepData.type === 'options' && ( +
+ + + +
+ ) } + + { currentStepData.type === 'completion' && ( +
+ +
+ ) } +
+ ); +} diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/wizard-messages.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/wizard-messages.tsx new file mode 100644 index 0000000000000..fc6e0af1dc98c --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/seo-assistant/wizard-messages.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import clsx from 'clsx'; +import bigSkyIcon from './big-sky-icon.svg'; + +const Message = ( { message } ) => { + return ( +
+
+ { message.showIcon && ( + { + ) } +
+ + { message.type === 'past-options' && ( +
+ { message.options.map( option => ( +
+ { option.content } +
+ ) ) } +
+ ) } + + { ( ! message.type || message.type === 'chat' ) && ( +
{ message.content }
+ ) } +
+ ); +}; + +const OptionMessages = ( { currentStepData } ) => { + if ( currentStepData.type !== 'options' || ! currentStepData.options.length ) { + return null; + } + + return ( +
+
+
+
+ { currentStepData.options.map( option => ( + + ) ) } +
+
+
+ ); +}; + +export default function Messages( { currentStepData, messages } ) { + const messagesEndRef = useRef< HTMLDivElement >( null ); + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView( { behavior: 'smooth' } ); + }; + + useEffect( () => { + scrollToBottom(); + }, [ messages ] ); + + return ( +
+ { messages.map( message => ( + + ) ) } + +
+
+ ); +} diff --git a/projects/plugins/jetpack/extensions/plugins/seo/index.js b/projects/plugins/jetpack/extensions/plugins/seo/index.js index 4ebdeb0185543..a3618e5a60fa0 100644 --- a/projects/plugins/jetpack/extensions/plugins/seo/index.js +++ b/projects/plugins/jetpack/extensions/plugins/seo/index.js @@ -3,6 +3,7 @@ import { useModuleStatus, isSimpleSite, isAtomicSite, + getJetpackExtensionAvailability, getRequiredPlan, } from '@automattic/jetpack-shared-extension-utils'; import { PanelBody, PanelRow } from '@wordpress/components'; @@ -12,7 +13,9 @@ import { PluginPrePublishPanel } from '@wordpress/edit-post'; import { store as editorStore } from '@wordpress/editor'; import { Fragment } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { isBetaExtension } from '../../editor'; import JetpackPluginSidebar from '../../shared/jetpack-plugin-sidebar'; +import SeoAssistant from '../ai-assistant-plugin/components/seo-assistant'; import { SeoPlaceholder } from './components/placeholder'; import { SeoSkeletonLoader } from './components/skeleton-loader'; import UpsellNotice from './components/upsell'; @@ -24,6 +27,9 @@ import './editor.scss'; export const name = 'seo'; +const isSeoAssistantEnabled = + getJetpackExtensionAvailability( 'ai-seo-assistant' )?.available === true; + const Seo = () => { const { isLoadingModules, isChangingStatus, isModuleActive, changeStatus } = useModuleStatus( 'seo-tools' ); @@ -95,6 +101,15 @@ const Seo = () => { + { isSeoAssistantEnabled && isViewable && ( + + + + ) }