initial
This commit is contained in:
+73
@@ -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 }
|
||||
/>
|
||||
);
|
||||
}
|
||||
+107
@@ -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>
|
||||
);
|
||||
}
|
||||
+68
@@ -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>
|
||||
);
|
||||
}
|
||||
+69
@@ -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>
|
||||
);
|
||||
}
|
||||
+43
@@ -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>
|
||||
);
|
||||
}
|
||||
+89
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const STORE_NAME = 'core/guidelines';
|
||||
export const VALID_SECTIONS = [ 'site', 'copy', 'images', 'additional' ];
|
||||
export const API_PATH = '/wpcom/v2/jetpack-ai/suggest-guidelines';
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useAiFeature } from '@automattic/jetpack-ai-client';
|
||||
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, VALID_SECTIONS } from '../constants';
|
||||
import { suggestGuidelines } from '../lib/api';
|
||||
import { recordGuidelinesEvent } from '../lib/tracks';
|
||||
import { AI_STORE_NAME } from '../store';
|
||||
|
||||
/**
|
||||
* Hook that returns a callback to generate suggestions for all sections.
|
||||
*
|
||||
* @return {{ generate: Function, loading: boolean }} Generate callback and loading state.
|
||||
*/
|
||||
export default function useGenerateAll() {
|
||||
const { createErrorNotice } = useDispatch( noticesStore );
|
||||
const { startLoading, stopLoading, setSuggestion } = useDispatch( AI_STORE_NAME );
|
||||
const loading = useSelect( select => select( AI_STORE_NAME ).isLoading(), [] );
|
||||
const { hasFeature } = useAiFeature();
|
||||
|
||||
const allGuidelines = useSelect( select => {
|
||||
const store = select( STORE_NAME );
|
||||
return Object.fromEntries( VALID_SECTIONS.map( slug => [ slug, store.getGuideline( slug ) ] ) );
|
||||
}, [] );
|
||||
|
||||
const generate = useCallback( async () => {
|
||||
if ( ! hasFeature ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allEmpty = VALID_SECTIONS.every( slug => ! allGuidelines[ slug ] );
|
||||
recordGuidelinesEvent( 'generate_all', {
|
||||
action: allEmpty ? 'generate' : 'improve',
|
||||
} );
|
||||
|
||||
startLoading();
|
||||
try {
|
||||
const existingContent = Object.fromEntries(
|
||||
VALID_SECTIONS.filter( slug => allGuidelines[ slug ] ).map( slug => [
|
||||
slug,
|
||||
allGuidelines[ slug ],
|
||||
] )
|
||||
);
|
||||
|
||||
const response = await suggestGuidelines( VALID_SECTIONS, existingContent );
|
||||
const suggestions = response?.suggestions || {};
|
||||
const appliedSlugs = VALID_SECTIONS.filter( slug => suggestions[ slug ] );
|
||||
|
||||
// No usable suggestions came back — surface it like any other failure.
|
||||
if ( appliedSlugs.length === 0 ) {
|
||||
throw new Error( 'No suggestions returned.' );
|
||||
}
|
||||
|
||||
appliedSlugs.forEach( slug => setSuggestion( slug, suggestions[ slug ] ) );
|
||||
} catch {
|
||||
createErrorNotice( __( 'Failed to generate guidelines. Please try again.', 'jetpack' ), {
|
||||
type: 'snackbar',
|
||||
} );
|
||||
} finally {
|
||||
stopLoading();
|
||||
}
|
||||
}, [ hasFeature, allGuidelines, startLoading, stopLoading, setSuggestion, createErrorNotice ] );
|
||||
|
||||
return { generate, loading, hasFeature };
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import apiFetch from '@wordpress/api-fetch';
|
||||
import { API_PATH, VALID_SECTIONS } from '../constants';
|
||||
|
||||
/**
|
||||
* Generate or improve guidelines for the given sections/blocks.
|
||||
*
|
||||
* Translates between the internal format used by our components and the
|
||||
* API's categories-based format:
|
||||
*
|
||||
* API request: { categories: { site: {}, copy: { guidelines: "..." }, blocks: { "core/paragraph": {} } } }
|
||||
* API response: { site: { guidelines: "..." }, blocks: { "core/paragraph": { guidelines: "..." } } }
|
||||
*
|
||||
* @param {string[]} slugs - Section slugs or block names to generate.
|
||||
* @param {Object.<string, string>} [existingContent] - Existing content keyed by slug.
|
||||
* @return {Promise<Object>} Response with `suggestions` keyed by slug.
|
||||
*/
|
||||
export async function suggestGuidelines( slugs, existingContent = {} ) {
|
||||
// Build categories object for the API.
|
||||
// Standard sections go as top-level keys, block names go under `blocks`.
|
||||
const categories = {};
|
||||
const blockEntries = {};
|
||||
|
||||
for ( const slug of slugs ) {
|
||||
const existing = existingContent[ slug ];
|
||||
const entry = existing ? { guidelines: existing } : {};
|
||||
|
||||
if ( VALID_SECTIONS.includes( slug ) ) {
|
||||
categories[ slug ] = entry;
|
||||
} else {
|
||||
blockEntries[ slug ] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
if ( Object.keys( blockEntries ).length > 0 ) {
|
||||
categories.blocks = blockEntries;
|
||||
}
|
||||
|
||||
const response = await apiFetch( {
|
||||
path: API_PATH,
|
||||
method: 'POST',
|
||||
data: { categories },
|
||||
} );
|
||||
|
||||
// Normalize API response to { suggestions: { slug: text } }.
|
||||
const suggestions = {};
|
||||
for ( const slug of slugs ) {
|
||||
if ( VALID_SECTIONS.includes( slug ) ) {
|
||||
const guidelines = response?.[ slug ]?.guidelines;
|
||||
if ( guidelines ) {
|
||||
suggestions[ slug ] = guidelines;
|
||||
}
|
||||
} else {
|
||||
const guidelines = response?.blocks?.[ slug ]?.guidelines;
|
||||
if ( guidelines ) {
|
||||
suggestions[ slug ] = guidelines;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { suggestions };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Programmatically set a React-controlled textarea's value.
|
||||
* Uses the native setter so React's synthetic onChange fires.
|
||||
*
|
||||
* @param {HTMLTextAreaElement} textarea - The textarea element.
|
||||
* @param {string} value - The new value.
|
||||
*/
|
||||
export function setTextareaValue( textarea, value ) {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value'
|
||||
).set;
|
||||
setter.call( textarea, value );
|
||||
textarea.dispatchEvent( new Event( 'input', { bubbles: true } ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a block suggestion: write text to the modal textarea and clear the store.
|
||||
*
|
||||
* @param {HTMLElement} blockModal - The block guideline modal element.
|
||||
* @param {string} blockName - Block name key in the store.
|
||||
* @param {string} suggestion - Suggestion text to write.
|
||||
* @param {Function} clearSuggestion - Store action to clear the suggestion.
|
||||
*/
|
||||
export function acceptBlockSuggestion( blockModal, blockName, suggestion, clearSuggestion ) {
|
||||
const textarea = blockModal?.querySelector( '.components-textarea-control__input' );
|
||||
if ( textarea ) {
|
||||
setTextareaValue( textarea, suggestion );
|
||||
}
|
||||
clearSuggestion( blockName );
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import { select } from '@wordpress/data';
|
||||
import { createRoot, createElement } from '@wordpress/element';
|
||||
import BlockSuggestionActions from '../components/block-suggestion-actions';
|
||||
import BlockSuggestionButtons from '../components/block-suggestion-buttons';
|
||||
import EmptyStateBanner from '../components/empty-state-banner';
|
||||
import SectionGenerateButton from '../components/section-generate-button';
|
||||
import SuggestAllButton from '../components/suggest-all-button';
|
||||
import SuggestionActions from '../components/suggestion-actions';
|
||||
import SuggestionBadge from '../components/suggestion-badge';
|
||||
import UpgradeNotice from '../components/upgrade-notice';
|
||||
import { VALID_SECTIONS } from '../constants';
|
||||
|
||||
// Each injection point tracks both the DOM container and its React root.
|
||||
// Before re-injecting, we unmount the old root to ensure proper cleanup
|
||||
// of effects and subscriptions. We verify containers via isConnected since
|
||||
// Gutenberg's <Navigator> removes/re-adds the main screen DOM when
|
||||
// navigating to revision history and back.
|
||||
|
||||
const slots = {
|
||||
header: { container: null, root: null },
|
||||
'upgrade-notice': { container: null, root: null },
|
||||
banner: { container: null, root: null },
|
||||
};
|
||||
|
||||
for ( const slug of VALID_SECTIONS ) {
|
||||
slots[ `badge-${ slug }` ] = { container: null, root: null };
|
||||
slots[ `actions-${ slug }` ] = { container: null, root: null };
|
||||
slots[ `button-${ slug }` ] = { container: null, root: null };
|
||||
}
|
||||
|
||||
slots[ 'block-actions' ] = { container: null, root: null };
|
||||
slots[ 'block-suggestion-buttons' ] = { container: null, root: null };
|
||||
|
||||
// Block name the block-modal slots were last rendered with. Lets runAll()
|
||||
// detect when the create-mode combobox switches to a different block while
|
||||
// the slots are still mounted.
|
||||
let lastBlockName = null;
|
||||
|
||||
/**
|
||||
* Inject a React component into the DOM, reusing or replacing the slot.
|
||||
*
|
||||
* @param {string} key - Slot key in the slots map.
|
||||
* @param {Function} findParent - Returns { parent, before, className } or null.
|
||||
* @param {Function} Component - React component to render.
|
||||
* @param {Object} [props] - Props to pass to the component.
|
||||
*/
|
||||
function inject( key, findParent, Component, props ) {
|
||||
const slot = slots[ key ];
|
||||
|
||||
// Already injected and still in DOM — nothing to do.
|
||||
if ( slot.container?.isConnected ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Container was removed — unmount the old root to clean up effects.
|
||||
if ( slot.root ) {
|
||||
slot.root.unmount();
|
||||
slot.root = null;
|
||||
slot.container = null;
|
||||
}
|
||||
|
||||
const target = findParent();
|
||||
if ( ! target ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { parent, before, className, tag } = target;
|
||||
const container = document.createElement( tag || 'div' );
|
||||
container.className = className;
|
||||
|
||||
if ( before ) {
|
||||
parent.insertBefore( container, before );
|
||||
} else {
|
||||
parent.appendChild( container );
|
||||
}
|
||||
|
||||
const root = createRoot( container );
|
||||
root.render( createElement( Component, props ) );
|
||||
|
||||
slot.container = container;
|
||||
slot.root = root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmount a slot's React root and remove its container from the DOM.
|
||||
*
|
||||
* Unlike inject()'s cleanup path, the container may still be connected —
|
||||
* used when a slot must re-render with different props (e.g. the block
|
||||
* modal's combobox switching blocks).
|
||||
*
|
||||
* @param {string} key - Slot key in the slots map.
|
||||
*/
|
||||
function unmountSlot( key ) {
|
||||
const slot = slots[ key ];
|
||||
if ( slot.root ) {
|
||||
slot.root.unmount();
|
||||
slot.root = null;
|
||||
}
|
||||
if ( slot.container ) {
|
||||
slot.container.remove();
|
||||
slot.container = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the block name from the block guideline modal.
|
||||
* In editing mode, reads the disabled TextControl and matches against block types.
|
||||
* In creating mode, reads the ComboboxControl's selected value.
|
||||
*/
|
||||
function getBlockNameFromModal( modal ) {
|
||||
const blockTypes = select( 'core/blocks' ).getBlockTypes();
|
||||
|
||||
// Editing mode: disabled input shows block title.
|
||||
const disabledInput = modal.querySelector( 'input[disabled]' );
|
||||
if ( disabledInput?.value ) {
|
||||
return blockTypes.find( b => b.title === disabledInput.value )?.name;
|
||||
}
|
||||
|
||||
// Creating mode: combobox with selected value.
|
||||
const combobox = modal.querySelector( 'input[role="combobox"]' );
|
||||
if ( combobox?.value ) {
|
||||
return blockTypes.find( b => b.title === combobox.value )?.name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function runAll() {
|
||||
// Header button — right-aligned in the wp-admin Page header, where the
|
||||
// native header actions would render. The gutenberg page passes no
|
||||
// `actions` to <Page>, so that slot is never created; instead we target the
|
||||
// header-content row (flex, justify: space-between) that holds the title and
|
||||
// append the button as its second child so space-between pushes it to the
|
||||
// right. All header classes are hashed CSS-module names, so we locate the
|
||||
// row structurally: the space-between flex row containing the page <h1>.
|
||||
inject(
|
||||
'header',
|
||||
() => {
|
||||
// Wait until the guidelines have loaded before mounting the button.
|
||||
// Gutenberg renders the list only after its async fetch resolves
|
||||
// (a spinner shows until then); mounting earlier reads the empty
|
||||
// default store and flickers the label "Generate" -> "Improve".
|
||||
if ( ! document.querySelector( '.guidelines__list' ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const region = document.querySelector( '.admin-ui-navigable-region' );
|
||||
const heading = region?.querySelector( 'h1' );
|
||||
let row = heading?.parentElement;
|
||||
while ( row && row !== region ) {
|
||||
const style = window.getComputedStyle( row );
|
||||
if (
|
||||
style.display === 'flex' &&
|
||||
style.flexDirection === 'row' &&
|
||||
style.justifyContent.includes( 'between' )
|
||||
) {
|
||||
break;
|
||||
}
|
||||
row = row.parentElement;
|
||||
}
|
||||
return row && row !== region
|
||||
? {
|
||||
parent: row,
|
||||
className: 'jetpack-content-guidelines-ai__header-container',
|
||||
}
|
||||
: null;
|
||||
},
|
||||
SuggestAllButton
|
||||
);
|
||||
|
||||
// Upgrade notice — shown above the guideline list when AI is unavailable.
|
||||
inject(
|
||||
'upgrade-notice',
|
||||
() => {
|
||||
const list = document.querySelector( '.guidelines__list' );
|
||||
return list
|
||||
? {
|
||||
parent: list.parentElement,
|
||||
before: list,
|
||||
className: 'jetpack-content-guidelines-ai__upgrade-notice-container',
|
||||
}
|
||||
: null;
|
||||
},
|
||||
UpgradeNotice
|
||||
);
|
||||
|
||||
// Empty state banner.
|
||||
inject(
|
||||
'banner',
|
||||
() => {
|
||||
const list = document.querySelector( '.guidelines__list' );
|
||||
return list
|
||||
? {
|
||||
parent: list.parentElement,
|
||||
before: list,
|
||||
className: 'jetpack-content-guidelines-ai__banner-container',
|
||||
}
|
||||
: null;
|
||||
},
|
||||
EmptyStateBanner
|
||||
);
|
||||
|
||||
// Per-section injections. Sections are matched by the stable `data-slug`
|
||||
// attribute on each `.guidelines__list-item`. The per-section form is
|
||||
// always present in the DOM (the CollapsibleCard keeps its content mounted
|
||||
// while collapsed).
|
||||
for ( const slug of VALID_SECTIONS ) {
|
||||
// Steady-state fast path: once this section's three slots are injected
|
||||
// and still connected, there is nothing to do — skip the DOM queries so
|
||||
// the observer's per-frame work stays cheap. If the <Navigator> removes
|
||||
// the screen, the containers disconnect and we fall through to re-inject.
|
||||
if (
|
||||
slots[ `badge-${ slug }` ].container?.isConnected &&
|
||||
slots[ `actions-${ slug }` ].container?.isConnected &&
|
||||
slots[ `button-${ slug }` ].container?.isConnected
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = document.querySelector( `.guidelines__list-item[data-slug="${ slug }"]` );
|
||||
const form = item?.querySelector( 'form' );
|
||||
if ( ! form ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Badge in the accordion header (the section's heading element).
|
||||
inject(
|
||||
`badge-${ slug }`,
|
||||
() => {
|
||||
const heading = item.querySelector( 'h1, h2, h3, h4, h5, h6' );
|
||||
const titleStack = heading?.firstElementChild ?? heading;
|
||||
return titleStack
|
||||
? {
|
||||
parent: titleStack,
|
||||
className: 'jetpack-content-guidelines-ai__badge-container',
|
||||
tag: 'span',
|
||||
}
|
||||
: null;
|
||||
},
|
||||
SuggestionBadge,
|
||||
{ slug }
|
||||
);
|
||||
|
||||
// Suggestion actions (diff + accept/dismiss) at top of form.
|
||||
inject(
|
||||
`actions-${ slug }`,
|
||||
() => {
|
||||
const vStack = form.firstElementChild;
|
||||
return vStack
|
||||
? {
|
||||
parent: vStack,
|
||||
before: vStack.firstChild,
|
||||
className: 'jetpack-content-guidelines-ai__actions-container',
|
||||
}
|
||||
: null;
|
||||
},
|
||||
SuggestionActions,
|
||||
{ slug }
|
||||
);
|
||||
|
||||
// Per-section generate button next to the Save button (the form's
|
||||
// primary submit button lives in an HStack with the Clear button).
|
||||
inject(
|
||||
`button-${ slug }`,
|
||||
() => {
|
||||
const saveButton = form.querySelector( 'button[type="submit"]' );
|
||||
const hStack = saveButton?.parentElement;
|
||||
return hStack
|
||||
? {
|
||||
parent: hStack,
|
||||
className: 'jetpack-content-guidelines-ai__section-button-container',
|
||||
}
|
||||
: null;
|
||||
},
|
||||
SectionGenerateButton,
|
||||
{ slug }
|
||||
);
|
||||
}
|
||||
|
||||
// Block guideline modal injections.
|
||||
const blockModal = document.querySelector( '.block-guideline-modal' );
|
||||
|
||||
// Resolve the block name on every pass while the modal is open — even when
|
||||
// the slots are already mounted. In create mode the user can switch the
|
||||
// combobox to a different block after our components rendered, and
|
||||
// blockName is bound as a prop at render time. When the resolved name
|
||||
// changes, tear the mounted slots down so they re-inject below with the
|
||||
// new name; otherwise a suggestion generated for the previous block would
|
||||
// be accepted into the newly selected one. Unmounting also runs
|
||||
// BlockSuggestionActions' cleanup, clearing the stale suggestion from the
|
||||
// store.
|
||||
const blockName = blockModal ? getBlockNameFromModal( blockModal ) : null;
|
||||
|
||||
if ( blockName !== lastBlockName ) {
|
||||
unmountSlot( 'block-actions' );
|
||||
unmountSlot( 'block-suggestion-buttons' );
|
||||
lastBlockName = blockName;
|
||||
}
|
||||
|
||||
// Always invoke inject so previously mounted roots get unmounted when
|
||||
// the modal closes — findParent() returns null for "no place to inject"
|
||||
// and inject()'s cleanup path handles the disconnected container.
|
||||
inject(
|
||||
'block-actions',
|
||||
() => {
|
||||
if ( ! blockName || ! blockModal ) {
|
||||
return null;
|
||||
}
|
||||
const textareaInput = blockModal.querySelector( '.components-textarea-control__input' );
|
||||
const field = textareaInput?.parentElement;
|
||||
return field
|
||||
? {
|
||||
parent: field,
|
||||
before: textareaInput,
|
||||
className: 'jetpack-content-guidelines-ai__block-actions-container',
|
||||
}
|
||||
: null;
|
||||
},
|
||||
BlockSuggestionActions,
|
||||
{ blockName, blockModal }
|
||||
);
|
||||
|
||||
inject(
|
||||
'block-suggestion-buttons',
|
||||
() => {
|
||||
if ( ! blockName || ! blockModal ) {
|
||||
return null;
|
||||
}
|
||||
const actionsBar = blockModal.querySelector( '.block-guideline-modal__actions' );
|
||||
const vStack = actionsBar?.parentElement;
|
||||
return vStack
|
||||
? {
|
||||
parent: vStack,
|
||||
before: actionsBar,
|
||||
className: 'jetpack-content-guidelines-ai__block-suggestion-buttons-container',
|
||||
}
|
||||
: null;
|
||||
},
|
||||
BlockSuggestionButtons,
|
||||
{ blockName, blockModal }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start observing DOM and inject all components.
|
||||
*
|
||||
* We observe document.body (not a narrower container) for two reasons:
|
||||
* 1. WordPress Modal portals render directly on document.body — the block
|
||||
* guideline modal lives outside any Gutenberg container, so a narrower
|
||||
* root would miss it appearing.
|
||||
* 2. Gutenberg's Navigator can remove and re-add the main screen DOM
|
||||
* (e.g. revision history navigation), so we can't rely on a specific
|
||||
* container staying connected.
|
||||
*
|
||||
* The observer never disconnects for the same reasons. Callbacks are
|
||||
* debounced via requestAnimationFrame so runAll() fires at most once
|
||||
* per frame, and each inject() call is a no-op when its container is
|
||||
* still connected.
|
||||
*/
|
||||
let injectionStarted = false;
|
||||
|
||||
export function startInjection() {
|
||||
if ( injectionStarted ) {
|
||||
return;
|
||||
}
|
||||
injectionStarted = true;
|
||||
|
||||
runAll();
|
||||
|
||||
let scheduled = false;
|
||||
const observer = new MutationObserver( () => {
|
||||
if ( ! scheduled ) {
|
||||
scheduled = true;
|
||||
requestAnimationFrame( () => {
|
||||
scheduled = false;
|
||||
runAll();
|
||||
} );
|
||||
}
|
||||
} );
|
||||
|
||||
observer.observe( document.body, { childList: true, subtree: true } );
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import analytics from '@automattic/jetpack-analytics';
|
||||
|
||||
/**
|
||||
* Record a content guidelines Tracks event.
|
||||
*
|
||||
* @param {string} eventName - Event name suffix (appended to `jetpack_ai_guidelines_`).
|
||||
* @param {Object} properties - Event properties.
|
||||
*/
|
||||
export function recordGuidelinesEvent( eventName, properties = {} ) {
|
||||
analytics.tracks.recordEvent( `jetpack_ai_guidelines_${ eventName }`, properties );
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a generic Jetpack AI Tracks event.
|
||||
* Use for shared events like `jetpack_ai_upgrade_button`.
|
||||
*
|
||||
* @param {string} eventName - Full event name.
|
||||
* @param {Object} properties - Event properties.
|
||||
*/
|
||||
export function recordAiEvent( eventName, properties = {} ) {
|
||||
analytics.tracks.recordEvent( eventName, properties );
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import apiFetch from '@wordpress/api-fetch';
|
||||
import { createReduxStore, register } from '@wordpress/data';
|
||||
|
||||
// Per-user dismissed flag, persisted server-side so it follows the user across
|
||||
// devices/browsers. The initial value is preloaded into the page by PHP (see
|
||||
// _inc/content-guidelines-ai.php); dismissal is written via this REST route.
|
||||
const DISMISS_PATH = '/wpcom/v2/jetpack-ai/guidelines-banner-dismissed';
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
loading: false,
|
||||
loadingSections: {},
|
||||
suggestions: {},
|
||||
bannerDismissed: !! window.jetpackContentGuidelinesAi?.bannerDismissed,
|
||||
};
|
||||
|
||||
const actions = {
|
||||
startLoading() {
|
||||
return { type: 'START_LOADING' };
|
||||
},
|
||||
stopLoading() {
|
||||
return { type: 'STOP_LOADING' };
|
||||
},
|
||||
setSuggestion( slug, text ) {
|
||||
return { type: 'SET_SUGGESTION', slug, text };
|
||||
},
|
||||
clearSuggestion( slug ) {
|
||||
return { type: 'CLEAR_SUGGESTION', slug };
|
||||
},
|
||||
startSectionLoading( slug ) {
|
||||
return { type: 'START_SECTION_LOADING', slug };
|
||||
},
|
||||
stopSectionLoading( slug ) {
|
||||
return { type: 'STOP_SECTION_LOADING', slug };
|
||||
},
|
||||
dismissBanner() {
|
||||
return ( { select, dispatch } ) => {
|
||||
// One-way flag — skip the write if already dismissed.
|
||||
if ( select.isBannerDismissed() ) {
|
||||
return;
|
||||
}
|
||||
// Optimistic: update state now, persist per-user in the background.
|
||||
// A failed write is ignored — re-syncs from the preloaded value on
|
||||
// the next page load.
|
||||
dispatch( { type: 'DISMISS_BANNER' } );
|
||||
apiFetch( {
|
||||
method: 'PUT',
|
||||
path: DISMISS_PATH,
|
||||
} ).catch( () => {} );
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function reducer( state = DEFAULT_STATE, action ) {
|
||||
switch ( action.type ) {
|
||||
case 'START_LOADING':
|
||||
return { ...state, loading: true };
|
||||
case 'STOP_LOADING':
|
||||
return { ...state, loading: false };
|
||||
case 'SET_SUGGESTION':
|
||||
return {
|
||||
...state,
|
||||
suggestions: { ...state.suggestions, [ action.slug ]: action.text },
|
||||
};
|
||||
case 'CLEAR_SUGGESTION': {
|
||||
const suggestions = { ...state.suggestions };
|
||||
delete suggestions[ action.slug ];
|
||||
return { ...state, suggestions };
|
||||
}
|
||||
case 'DISMISS_BANNER':
|
||||
return { ...state, bannerDismissed: true };
|
||||
case 'START_SECTION_LOADING':
|
||||
return {
|
||||
...state,
|
||||
loadingSections: { ...state.loadingSections, [ action.slug ]: true },
|
||||
};
|
||||
case 'STOP_SECTION_LOADING': {
|
||||
const loadingSections = { ...state.loadingSections };
|
||||
delete loadingSections[ action.slug ];
|
||||
return { ...state, loadingSections };
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const selectors = {
|
||||
isLoading( state ) {
|
||||
return state.loading;
|
||||
},
|
||||
getSuggestion( state, slug ) {
|
||||
return state.suggestions[ slug ] || '';
|
||||
},
|
||||
hasSuggestion( state, slug ) {
|
||||
return !! state.suggestions[ slug ];
|
||||
},
|
||||
isSectionLoading( state, slug ) {
|
||||
return state.loading || !! state.loadingSections[ slug ];
|
||||
},
|
||||
isBannerDismissed( state ) {
|
||||
return state.bannerDismissed;
|
||||
},
|
||||
};
|
||||
|
||||
export const AI_STORE_NAME = 'jetpack/content-guidelines-ai';
|
||||
|
||||
const aiStore = createReduxStore( AI_STORE_NAME, {
|
||||
reducer,
|
||||
actions,
|
||||
selectors,
|
||||
} );
|
||||
|
||||
register( aiStore );
|
||||
@@ -0,0 +1,267 @@
|
||||
// Shared color tokens for AI suggestion UI.
|
||||
// Jetpack ThemeProvider doesn't wrap this page,
|
||||
// so we define fallback values here.
|
||||
$cg-green: #069e08;
|
||||
$cg-green-light: rgba(0, 135, 0, 0.08);
|
||||
$cg-diff-added: #d4edda;
|
||||
$cg-diff-removed: #f8d7da;
|
||||
|
||||
.jetpack-content-guidelines-ai__header-container {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.jetpack-content-guidelines-ai__suggest-all-button .jetpack-logo {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
// Empty state banner.
|
||||
.jetpack-content-guidelines-ai__banner {
|
||||
position: relative;
|
||||
background: #003010;
|
||||
border-radius: 8px;
|
||||
padding: 32px;
|
||||
overflow: hidden;
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: min(680px, 100%);
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto 24px;
|
||||
}
|
||||
|
||||
.jetpack-content-guidelines-ai__banner-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 520px;
|
||||
|
||||
h2 {
|
||||
color: #daffdc;
|
||||
font-size: 32px;
|
||||
line-height: 40px;
|
||||
font-weight: 500;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #daffdc;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
margin: 0 0 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.jetpack-content-guidelines-ai__banner-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.jetpack-content-guidelines-ai__banner-cta {
|
||||
background-color: #48ff50 !important;
|
||||
color: #003010 !important;
|
||||
border-color: #48ff50 !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #3de046 !important;
|
||||
border-color: #3de046 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.jetpack-content-guidelines-ai__banner-close-text {
|
||||
color: #48ff50 !important;
|
||||
|
||||
&:hover {
|
||||
color: #3de046 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.jetpack-content-guidelines-ai__banner-close {
|
||||
position: absolute;
|
||||
inset-block-start: 8px;
|
||||
inset-inline-end: 8px;
|
||||
z-index: 1;
|
||||
color: #fff !important;
|
||||
|
||||
&:hover {
|
||||
color: #daffdc !important;
|
||||
}
|
||||
}
|
||||
|
||||
.jetpack-content-guidelines-ai__banner-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: linear-gradient(to right, #04c, #48ff50);
|
||||
// Shared by both orbs so the top glow and the bottom sphere line up
|
||||
// on the same vertical axis.
|
||||
inset-inline-end: 56px;
|
||||
|
||||
&--top {
|
||||
inset-block-start: -96px;
|
||||
filter: blur(25px);
|
||||
}
|
||||
|
||||
&--bottom {
|
||||
inset-block-end: -96px;
|
||||
}
|
||||
}
|
||||
|
||||
// Badge in accordion headers.
|
||||
// Hide badge when the accordion is expanded — the Accept/Dismiss
|
||||
// buttons inside already communicate the suggestion state. The badge is
|
||||
// injected inside the CollapsibleCard trigger, which carries aria-expanded.
|
||||
[aria-expanded="true"] .jetpack-content-guidelines-ai__badge-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Jetpack CSS custom properties are normally set by
|
||||
// ThemeProvider, which doesn't wrap this page. Define
|
||||
// the tokens our Badge component needs as fallbacks.
|
||||
.jetpack-content-guidelines-ai__badge-container {
|
||||
--jp-green-5: #d0e6b8;
|
||||
--jp-green-50: #008710;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.jetpack-content-guidelines-ai__badge--loading .components-spinner {
|
||||
margin: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
// Suggestion preview inside accordion.
|
||||
// Match the VStack spacing={4} (16px gap) used by the Gutenberg form.
|
||||
.jetpack-content-guidelines-ai__suggestion {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// Word-level diff display.
|
||||
// Styled to match the Gutenberg textarea — green border indicates
|
||||
// a suggestion is active. Clicking anywhere accepts the suggestion.
|
||||
.jetpack-content-guidelines-ai__diff {
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--jp-green-40, $cg-green);
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
overflow-y: auto;
|
||||
resize: vertical;
|
||||
cursor: text;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.jetpack-content-guidelines-ai__diff-added {
|
||||
background: $cg-diff-added;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.jetpack-content-guidelines-ai__diff-removed {
|
||||
background: $cg-diff-removed;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.jetpack-content-guidelines-ai__suggestion-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// Green shimmer overlay on the textarea during AI generation.
|
||||
// We make the textarea wrapper positioned so the ::after overlay works.
|
||||
.is-jetpack-loading .components-textarea-control {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, transparent 0%, $cg-green-light 50%, transparent 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: jetpack-cg-shimmer 1.5s infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.is-jetpack-loading textarea {
|
||||
border-color: var(--jp-green-40, $cg-green) !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes jetpack-cg-shimmer {
|
||||
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Hide Gutenberg's DataForm and Save/Clear when a suggestion is active.
|
||||
// Also hide the per-section generate button since Accept/Dismiss replaces it.
|
||||
.has-jetpack-suggestion:not(.block-guideline-modal) > :first-child > :not(.jetpack-content-guidelines-ai__actions-container) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Injected containers should not affect layout when their content is empty.
|
||||
// React root inside means :empty won't work,
|
||||
// so we use display:contents when not active.
|
||||
.jetpack-content-guidelines-ai__actions-container,
|
||||
.jetpack-content-guidelines-ai__section-button-container {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
// Per-section generate button.
|
||||
.jetpack-content-guidelines-ai__section-generate-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
// Upgrade notice.
|
||||
.jetpack-content-guidelines-ai__upgrade-notice-container {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.jetpack-content-guidelines-ai__upgrade-notice {
|
||||
width: min(680px, 100%);
|
||||
margin: 0 auto 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// Block guideline modal — hide only the textarea input
|
||||
// when suggestion is active, keeping the label visible.
|
||||
.block-guideline-modal.has-jetpack-suggestion .components-textarea-control__input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Block modal — shimmer only on the textarea,
|
||||
// not the label wrapper. Suppress the ::after overlay
|
||||
// and apply shimmer as a background on the textarea.
|
||||
.block-guideline-modal.is-jetpack-loading .components-textarea-control::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.block-guideline-modal.is-jetpack-loading .components-textarea-control__input {
|
||||
background: linear-gradient(90deg, transparent 0%, $cg-green-light 50%, transparent 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: jetpack-cg-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
// Block modal injected containers — collapse when empty.
|
||||
.jetpack-content-guidelines-ai__block-actions-container,
|
||||
.jetpack-content-guidelines-ai__block-suggestion-buttons-container {
|
||||
display: contents;
|
||||
}
|
||||
Reference in New Issue
Block a user