This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,73 @@
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback, useEffect, useState } from '@wordpress/element';
import { acceptBlockSuggestion } from '../lib/dom';
import { recordGuidelinesEvent } from '../lib/tracks';
import { AI_STORE_NAME } from '../store';
import DiffView from './diff-view';
// Renders only the diff view. Accept/Dismiss and Improve buttons live in
// BlockSuggestionButtons, injected as a separate row above the modal action bar.
export default function BlockSuggestionActions( { blockName, blockModal } ) {
const suggestion = useSelect(
select => select( AI_STORE_NAME ).getSuggestion( blockName ),
[ blockName ]
);
const blockLoading = useSelect(
select => select( AI_STORE_NAME ).isSectionLoading( blockName ),
[ blockName ]
);
const { clearSuggestion } = useDispatch( AI_STORE_NAME );
const [ original, setOriginal ] = useState( '' );
const [ textareaHeight, setTextareaHeight ] = useState( null );
// Clear stale suggestion when the modal closes (component unmounts).
useEffect( () => {
return () => clearSuggestion( blockName );
}, [ blockName, clearSuggestion ] );
// Toggle shimmer and suggestion classes on the modal.
useEffect( () => {
if ( ! blockModal ) {
return;
}
// Capture textarea content and height before hiding it.
if ( suggestion && ! blockModal.classList.contains( 'has-jetpack-suggestion' ) ) {
const textarea = blockModal.querySelector( '.components-textarea-control__input' );
if ( textarea ) {
setOriginal( textarea.value || '' );
if ( textarea.offsetHeight > 0 ) {
setTextareaHeight( textarea.offsetHeight );
} else {
const rows = parseInt( textarea.getAttribute( 'rows' ), 10 ) || 6;
setTextareaHeight( rows * 20 + 20 );
}
}
}
blockModal.classList.toggle( 'has-jetpack-suggestion', !! suggestion );
blockModal.classList.toggle( 'is-jetpack-loading', blockLoading && ! suggestion );
return () => {
blockModal.classList.remove( 'has-jetpack-suggestion', 'is-jetpack-loading' );
};
}, [ blockModal, suggestion, blockLoading ] );
const handleAccept = useCallback( () => {
recordGuidelinesEvent( 'accept', { type: 'block', slug: blockName } );
acceptBlockSuggestion( blockModal, blockName, suggestion, clearSuggestion );
}, [ blockModal, blockName, suggestion, clearSuggestion ] );
if ( ! suggestion ) {
return null;
}
return (
<DiffView
original={ original }
suggestion={ suggestion }
onAccept={ handleAccept }
height={ textareaHeight }
/>
);
}
@@ -0,0 +1,107 @@
import { useAiFeature } from '@automattic/jetpack-ai-client';
import { Button } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
import { STORE_NAME } from '../constants';
import { suggestGuidelines } from '../lib/api';
import { acceptBlockSuggestion } from '../lib/dom';
import { recordGuidelinesEvent } from '../lib/tracks';
import { AI_STORE_NAME } from '../store';
export default function BlockSuggestionButtons( { blockName, blockModal } ) {
const { createErrorNotice } = useDispatch( noticesStore );
const { startSectionLoading, stopSectionLoading, setSuggestion, clearSuggestion } =
useDispatch( AI_STORE_NAME );
const { hasFeature } = useAiFeature();
const blockLoading = useSelect(
select => select( AI_STORE_NAME ).isSectionLoading( blockName ),
[ blockName ]
);
const suggestion = useSelect(
select => select( AI_STORE_NAME ).getSuggestion( blockName ),
[ blockName ]
);
const saved = useSelect(
select => select( STORE_NAME ).getBlockGuideline( blockName ),
[ blockName ]
);
const handleGenerate = useCallback( async () => {
const action = saved ? 'improve' : 'generate';
recordGuidelinesEvent( 'generate', { type: 'block', slug: blockName, action } );
const textarea = blockModal?.querySelector( '.components-textarea-control__input' );
const currentText = textarea?.value || '';
startSectionLoading( blockName );
try {
const existingContent = currentText ? { [ blockName ]: currentText } : {};
const response = await suggestGuidelines( [ blockName ], existingContent );
const text = response?.suggestions?.[ blockName ];
if ( ! text ) {
throw new Error( 'No suggestion returned.' );
}
setSuggestion( blockName, text );
} catch {
createErrorNotice( __( 'Failed to generate guidelines. Please try again.', 'jetpack' ), {
type: 'snackbar',
} );
} finally {
stopSectionLoading( blockName );
}
}, [
blockModal,
blockName,
saved,
startSectionLoading,
stopSectionLoading,
setSuggestion,
createErrorNotice,
] );
const handleAccept = useCallback( () => {
recordGuidelinesEvent( 'accept', { type: 'block', slug: blockName } );
acceptBlockSuggestion( blockModal, blockName, suggestion, clearSuggestion );
}, [ blockModal, blockName, suggestion, clearSuggestion ] );
const handleDismiss = useCallback( () => {
recordGuidelinesEvent( 'dismiss', { type: 'block', slug: blockName } );
clearSuggestion( blockName );
}, [ blockName, clearSuggestion ] );
if ( suggestion ) {
return (
<div className="jetpack-content-guidelines-ai__suggestion-actions">
<Button variant="primary" onClick={ handleAccept }>
{ __( 'Accept suggestion', 'jetpack' ) }
</Button>
<Button variant="tertiary" onClick={ handleDismiss }>
{ __( 'Dismiss', 'jetpack' ) }
</Button>
</div>
);
}
const generateLabel = __( 'Generate guidelines', 'jetpack' );
const improveLabel = __( 'Improve guidelines', 'jetpack' );
const label = saved ? improveLabel : generateLabel;
return (
<div className="jetpack-content-guidelines-ai__suggestion-actions">
<Button
variant="secondary"
onClick={ handleGenerate }
disabled={ blockLoading || ! hasFeature }
accessibleWhenDisabled
className="jetpack-content-guidelines-ai__section-generate-button"
>
{ label }
</Button>
</div>
);
}
@@ -0,0 +1,55 @@
import { useCallback, useMemo } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { diffWords } from 'diff';
export default function DiffView( { original, suggestion, onAccept, height } ) {
const diff = useMemo( () => {
if ( ! suggestion ) {
return [];
}
return diffWords( original, suggestion );
}, [ original, suggestion ] );
const handleKeyDown = useCallback(
e => {
if ( e.key === 'Enter' || e.key === ' ' ) {
e.preventDefault();
onAccept();
}
},
[ onAccept ]
);
return (
<div
className="jetpack-content-guidelines-ai__diff"
style={ height ? { height } : undefined }
role="button"
tabIndex={ 0 }
aria-label={ __( 'Click to accept suggested changes', 'jetpack' ) }
onClick={ onAccept }
onKeyDown={ handleKeyDown }
>
<span className="screen-reader-text">
{ __( 'Changes from current to suggested guidelines:', 'jetpack' ) }
</span>
{ diff.map( ( part, i ) => {
if ( part.added ) {
return (
<ins key={ i } className="jetpack-content-guidelines-ai__diff-added">
{ part.value }
</ins>
);
}
if ( part.removed ) {
return (
<del key={ i } className="jetpack-content-guidelines-ai__diff-removed">
{ part.value }
</del>
);
}
return <span key={ i }>{ part.value }</span>;
} ) }
</div>
);
}
@@ -0,0 +1,68 @@
import { useAiFeature } from '@automattic/jetpack-ai-client';
import { Button } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { closeSmall } from '@wordpress/icons';
import useGenerateAll from '../hooks/use-generate-all';
import { AI_STORE_NAME } from '../store';
export default function EmptyStateBanner() {
const { generate } = useGenerateAll();
const { hasFeature } = useAiFeature();
const { dismissBanner } = useDispatch( AI_STORE_NAME );
const dismissed = useSelect( select => select( AI_STORE_NAME ).isBannerDismissed(), [] );
const handleDismiss = useCallback( () => {
dismissBanner();
}, [ dismissBanner ] );
const handleGetStarted = useCallback( () => {
dismissBanner();
generate();
}, [ dismissBanner, generate ] );
if ( dismissed || ! hasFeature ) {
return null;
}
return (
<div className="jetpack-content-guidelines-ai__banner">
<div className="jetpack-content-guidelines-ai__banner-content">
<h2>{ __( 'Generate your guidelines in seconds', 'jetpack' ) }</h2>
<p>
{ __(
'Use Jetpack to analyze your site and create draft guidelines based on your actual content.',
'jetpack'
) }
</p>
<div className="jetpack-content-guidelines-ai__banner-actions">
<Button
className="jetpack-content-guidelines-ai__banner-cta"
variant="primary"
onClick={ handleGetStarted }
>
{ __( 'Get started', 'jetpack' ) }
</Button>
<Button
className="jetpack-content-guidelines-ai__banner-close-text"
variant="tertiary"
onClick={ handleDismiss }
>
{ __( 'Close', 'jetpack' ) }
</Button>
</div>
</div>
<Button
className="jetpack-content-guidelines-ai__banner-close"
icon={ closeSmall }
label={ __( 'Dismiss banner', 'jetpack' ) }
size="small"
onClick={ handleDismiss }
/>
<div className="jetpack-content-guidelines-ai__banner-orb jetpack-content-guidelines-ai__banner-orb--top" />
<div className="jetpack-content-guidelines-ai__banner-orb jetpack-content-guidelines-ai__banner-orb--bottom" />
</div>
);
}
@@ -0,0 +1,69 @@
import { useAiFeature } from '@automattic/jetpack-ai-client';
import { Button } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
import { STORE_NAME } from '../constants';
import { suggestGuidelines } from '../lib/api';
import { recordGuidelinesEvent } from '../lib/tracks';
import { AI_STORE_NAME } from '../store';
export default function SectionGenerateButton( { slug } ) {
const { createErrorNotice } = useDispatch( noticesStore );
const { startSectionLoading, stopSectionLoading, setSuggestion } = useDispatch( AI_STORE_NAME );
const { hasFeature } = useAiFeature();
const sectionLoading = useSelect(
select => select( AI_STORE_NAME ).isSectionLoading( slug ),
[ slug ]
);
const draft = useSelect( select => select( STORE_NAME ).getGuideline( slug ), [ slug ] );
const isEmpty = ! draft;
const generateLabel = __( 'Generate guidelines', 'jetpack' );
const improveLabel = __( 'Improve guidelines', 'jetpack' );
const label = isEmpty ? generateLabel : improveLabel;
const handleClick = useCallback( async () => {
const action = isEmpty ? 'generate' : 'improve';
recordGuidelinesEvent( 'generate', { type: 'section', slug, action } );
startSectionLoading( slug );
try {
const existingContent = draft ? { [ slug ]: draft } : {};
const response = await suggestGuidelines( [ slug ], existingContent );
const suggestion = response?.suggestions?.[ slug ];
if ( ! suggestion ) {
throw new Error( 'No suggestion returned.' );
}
setSuggestion( slug, suggestion );
} catch {
createErrorNotice( __( 'Failed to generate guidelines. Please try again.', 'jetpack' ), {
type: 'snackbar',
} );
} finally {
stopSectionLoading( slug );
}
}, [
slug,
draft,
isEmpty,
startSectionLoading,
stopSectionLoading,
setSuggestion,
createErrorNotice,
] );
return (
<Button
variant="tertiary"
onClick={ handleClick }
disabled={ sectionLoading || ! hasFeature }
accessibleWhenDisabled
className="jetpack-content-guidelines-ai__section-generate-button"
>
{ label }
</Button>
);
}
@@ -0,0 +1,43 @@
import { JetpackLogo } from '@automattic/jetpack-components';
import { Button } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { STORE_NAME, VALID_SECTIONS } from '../constants';
import useGenerateAll from '../hooks/use-generate-all';
import { AI_STORE_NAME } from '../store';
export default function SuggestAllButton() {
const { generate, loading, hasFeature } = useGenerateAll();
const bannerDismissed = useSelect( select => select( AI_STORE_NAME ).isBannerDismissed(), [] );
const allGuidelines = useSelect( select => {
const store = select( STORE_NAME );
return Object.fromEntries( VALID_SECTIONS.map( slug => [ slug, store.getGuideline( slug ) ] ) );
}, [] );
const allEmpty = VALID_SECTIONS.every( slug => ! allGuidelines[ slug ] );
const generateLabel = __( 'Generate guidelines', 'jetpack' );
const improveLabel = __( 'Improve guidelines', 'jetpack' );
const label = allEmpty ? generateLabel : improveLabel;
// Hide when the banner is visible (not yet dismissed) or user lacks AI plan.
const hidden = ! bannerDismissed || ! hasFeature;
const hiddenProps = hidden ? { style: { display: 'none' }, 'aria-hidden': true } : {};
return (
<Button
{ ...hiddenProps }
variant="primary"
icon={ <JetpackLogo showText={ false } height={ 18 } logoColor="#fff" /> }
onClick={ generate }
disabled={ loading || ! hasFeature }
accessibleWhenDisabled
isBusy={ loading }
className="jetpack-content-guidelines-ai__suggest-all-button"
>
{ label }
</Button>
);
}
@@ -0,0 +1,89 @@
import { Button } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback, useEffect, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { STORE_NAME } from '../constants';
import { recordGuidelinesEvent } from '../lib/tracks';
import { AI_STORE_NAME } from '../store';
import DiffView from './diff-view';
export default function SuggestionActions( { slug } ) {
const suggestion = useSelect( select => select( AI_STORE_NAME ).getSuggestion( slug ), [ slug ] );
const sectionLoading = useSelect(
select => select( AI_STORE_NAME ).isSectionLoading( slug ),
[ slug ]
);
const { clearSuggestion } = useDispatch( AI_STORE_NAME );
const { setGuideline } = useDispatch( STORE_NAME );
const [ original, setOriginal ] = useState( '' );
const [ textareaHeight, setTextareaHeight ] = useState( null );
// Direct DOM class manipulation is necessary because this component is rendered in
// a separate React root injected into Gutenberg's page — we can't control classes
// on Gutenberg-owned elements through React props.
useEffect( () => {
const item = document.querySelector( `.guidelines__list-item[data-slug="${ slug }"]` );
const form = item?.querySelector( 'form' );
if ( ! form ) {
return;
}
// Capture textarea draft and height before hiding it.
if ( suggestion && ! form.classList.contains( 'has-jetpack-suggestion' ) ) {
const textarea = form.querySelector( 'textarea' );
if ( textarea ) {
setOriginal( textarea.value || '' );
if ( textarea.offsetHeight > 0 ) {
setTextareaHeight( textarea.offsetHeight );
} else {
// Fallback when textarea is hidden (e.g. collapsed accordion).
// Compute height from rows attribute to match the textarea.
const rows = parseInt( textarea.getAttribute( 'rows' ), 10 ) || 4;
// line-height: 20px, padding: 9px top + 9px bottom, border: 1px + 1px.
setTextareaHeight( rows * 20 + 20 );
}
}
}
form.classList.toggle( 'has-jetpack-suggestion', !! suggestion );
form.classList.toggle( 'is-jetpack-loading', sectionLoading && ! suggestion );
return () => {
form.classList.remove( 'has-jetpack-suggestion', 'is-jetpack-loading' );
};
}, [ slug, suggestion, sectionLoading ] );
const handleAccept = useCallback( () => {
recordGuidelinesEvent( 'accept', { type: 'section', slug } );
setGuideline( slug, suggestion );
clearSuggestion( slug );
}, [ slug, suggestion, setGuideline, clearSuggestion ] );
const handleDismiss = useCallback( () => {
recordGuidelinesEvent( 'dismiss', { type: 'section', slug } );
clearSuggestion( slug );
}, [ slug, clearSuggestion ] );
if ( ! suggestion ) {
return null;
}
return (
<div className="jetpack-content-guidelines-ai__suggestion">
<DiffView
original={ original }
suggestion={ suggestion }
onAccept={ handleAccept }
height={ textareaHeight }
/>
<div className="jetpack-content-guidelines-ai__suggestion-actions">
<Button variant="primary" onClick={ handleAccept }>
{ __( 'Accept suggestion', 'jetpack' ) }
</Button>
<Button variant="tertiary" onClick={ handleDismiss }>
{ __( 'Dismiss', 'jetpack' ) }
</Button>
</div>
</div>
);
}
@@ -0,0 +1,30 @@
import { Spinner } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { Badge } from '@wordpress/ui';
import { AI_STORE_NAME } from '../store';
export default function SuggestionBadge( { slug } ) {
const sectionLoading = useSelect(
select => select( AI_STORE_NAME ).isSectionLoading( slug ),
[ slug ]
);
const hasSuggestion = useSelect(
select => select( AI_STORE_NAME ).hasSuggestion( slug ),
[ slug ]
);
if ( sectionLoading && ! hasSuggestion ) {
return (
<span className="jetpack-content-guidelines-ai__badge--loading">
<Spinner />
</span>
);
}
if ( hasSuggestion ) {
return <Badge intent="stable">{ __( 'Suggestion', 'jetpack' ) }</Badge>;
}
return null;
}
@@ -0,0 +1,52 @@
import { useAICheckout, useAiFeature } from '@automattic/jetpack-ai-client';
import { Button, Notice } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { recordAiEvent } from '../lib/tracks';
import { AI_STORE_NAME } from '../store';
export default function UpgradeNotice() {
const { hasFeature } = useAiFeature();
const { checkoutUrl } = useAICheckout();
const { dismissBanner } = useDispatch( AI_STORE_NAME );
const dismissed = useSelect( select => select( AI_STORE_NAME ).isBannerDismissed(), [] );
const handleUpgradeClick = useCallback( () => {
// Record the click only. Dismissal is persisted by the close button
// ( onRemove ), so the nudge returns if checkout is opened and abandoned.
recordAiEvent( 'jetpack_ai_upgrade_button', {
placement: 'content-guidelines',
} );
}, [] );
if ( hasFeature || dismissed ) {
return null;
}
return (
<Notice
status="success"
isDismissible
onRemove={ dismissBanner }
className="jetpack-content-guidelines-ai__upgrade-notice"
>
<p>
{ __(
'Not sure where to start? Jetpack can read your site and suggest guidelines tailored to your content. Upgrade to get started.',
'jetpack'
) }
</p>
{ checkoutUrl && (
<Button
variant="primary"
href={ checkoutUrl }
target="_blank"
onClick={ handleUpgradeClick }
>
{ __( 'Upgrade', 'jetpack' ) }
</Button>
) }
</Notice>
);
}