Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 | 3x 50x 50x 50x 55x 209x 213x 213x 209x 71x 8x 71x 71x 71x 50x 50x 50x 50x 50x 50x 50x 50x 8x 8x 5x 50x 71x 50x 71x 71x 66x 71x | /**
* AI Features view — per-feature toggles for Jetpack AI, grouped by area
* (Content, Media, SEO, Search) inside a single "Agent capabilities" card
* per the AI-Settings design.
*
* Each feature has its own on/off switch, backed by the feature-settings
* endpoint. A disabled feature must genuinely stop loading (its assets are
* not enqueued), not just disappear from view.
*/
import { getRedirectUrl } from '@automattic/jetpack-components';
import { ToggleControl } from '@wordpress/components';
import { Fragment, useCallback } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { Badge, Card, Link, Notice, Popover, Stack, Text, VisuallyHidden } from '@wordpress/ui';
import analytics from 'lib/analytics';
// Server-computed link targets, read at call time so the injected page data
// is honoured wherever this mounts. Missing key = wp-admin default (tests,
// older payloads); explicit '' = surface absent on this host, link hidden.
const getLinkTargets = () => {
const {
seoSettingsUrl = 'admin.php?page=jetpack#/traffic',
searchSettingsUrl = 'admin.php?page=jetpack-search#/ai-answers',
myJetpackUrl = 'admin.php?page=my-jetpack#/products',
} = window?.jetpackAiSettings ?? {};
return { seoSettingsUrl, searchSettingsUrl, myJetpackUrl };
};
// Per the design, a row's action link depends on the toggle state: enabled
// features invite you to try them, disabled ones link to documentation. A
// single `action` shows in both states; a missing enabledAction shows none.
export const getSections = ( { seoSettingsUrl, searchSettingsUrl } ) => [
{
key: 'content',
title: __( 'Content', 'jetpack' ),
features: [
{
key: 'writing_assistant',
label: __( 'Writing Assistant', 'jetpack' ),
description: __(
'Draft, rewrite, translate, and adjust tone for your content right in the block editor.',
'jetpack'
),
// Docs in both states for now. The editor link
// (post-new.php?openSidebar=jetpack-ai-assistant) returns with the
// sidebar agent; its editor-side handler is still in place.
action: {
label: __( 'Learn more', 'jetpack' ),
href: getRedirectUrl( 'jetpack-ai-settings-writing-assistant-learn-more' ),
external: true,
},
},
],
},
{
key: 'media',
title: __( 'Media', 'jetpack' ),
features: [
{
key: 'image_editor',
label: __( 'Image Editor', 'jetpack' ),
description: __(
'Generate and edit professional-quality images without leaving WordPress.',
'jetpack'
),
enabledAction: {
label: __( 'Try it out', 'jetpack' ),
// ai-assistant makes the Image Studio bundle open Generate mode
// on the Media Library (it strips the param once handled).
href: 'upload.php?ai-assistant',
},
disabledAction: {
label: __( 'Learn more', 'jetpack' ),
href: getRedirectUrl( 'jetpack-ai-settings-image-editor-learn-more' ),
external: true,
},
},
],
},
{
key: 'seo',
title: __( 'SEO', 'jetpack' ),
features: [
{
key: 'ai_seo',
label: __( 'AI SEO', 'jetpack' ),
description: __(
'AI recommendations to optimize titles, meta descriptions, and content for search engines.',
'jetpack'
),
enabledAction: seoSettingsUrl
? {
label: __( 'Open SEO Settings', 'jetpack' ),
href: seoSettingsUrl,
}
: undefined,
disabledAction: {
label: __( 'Learn more', 'jetpack' ),
href: getRedirectUrl( 'jetpack-ai-settings-seo-learn-more' ),
external: true,
},
},
],
},
{
key: 'search',
title: __( 'Search', 'jetpack' ),
features: [
{
key: 'ai_search',
label: __( 'AI Search', 'jetpack' ),
description: __(
'Help visitors and AI agents find answers in your content, via Jetpack Search.',
'jetpack'
),
// The toggle lives on the Search dashboard's AI tab — a page some
// hosts (WordPress.com Simple) never register.
enabledAction: searchSettingsUrl
? {
label: __( 'Open Search Settings', 'jetpack' ),
href: searchSettingsUrl,
}
: undefined,
disabledAction: {
label: __( 'Learn more', 'jetpack' ),
href: getRedirectUrl( 'jetpack-ai-settings-search-learn-more' ),
external: true,
},
},
],
},
];
/**
* Filter sections down to the feature rows the endpoint reported as usable.
* A toggle without a backend would render stuck at "off" and save nothing,
* so a row only renders when its key is present in the settings response —
* and not explicitly marked unavailable (e.g. the SEO enhancer where the
* seo-tools module is off). Sections left with no rows are dropped entirely.
*
* @param {Array} sections - SECTIONS-shaped list.
* @param {object} features - The features object from the settings response.
* @return {Array} Sections containing only reported, available feature rows.
*/
export function visibleSections( sections, features ) {
return sections
.map( section => ( {
...section,
features: section.features.filter( feature => {
const reported = features[ feature.key ];
return reported !== undefined && reported.available !== false;
} ),
} ) )
.filter( section => section.features.length > 0 );
}
/**
* A single feature row: toggle + description + optional action link.
*
* @param {object} props - Component props.
* @param {object} props.feature - Entry from SECTIONS[].features.
* @param {object} props.reported - This feature's object from the settings response.
* @param {boolean} props.checked - Whether the feature is enabled.
* @param {boolean} props.isSaving - Whether this toggle is being saved.
* @param {boolean} props.masterEnabled - Whether the site-wide AI master switch is on.
* @param {boolean} props.isConnected - Whether the AI connection gate passes (connected owner, not offline).
* @param {Function} props.onChange - Called with (key, enabled) on toggle.
* @return {object} Component markup.
*/
function FeatureRow( {
feature,
reported,
checked,
isSaving,
masterEnabled,
isConnected,
onChange,
} ) {
const handleChange = useCallback(
enabled => onChange( feature.key, enabled ),
[ feature.key, onChange ]
);
const action = ( checked ? feature.enabledAction : feature.disabledAction ) ?? feature.action;
// The toggle keeps showing the SAVED value but can't be used while the
// connection gate fails (no feature can load without it), while the master
// switch is off (the saved choice returns when master does), or while the
// plan doesn't include the individual feature. There is deliberately no
// site-wide plan gate here: every connected site can run the free tier.
const isDisabled = isSaving || ! isConnected || ! masterEnabled || !! reported?.requires_upgrade;
return (
<Stack direction="column" gap="xs" className="jetpack-ai-features__row">
<ToggleControl
__nextHasNoMarginBottom
checked={ checked }
disabled={ isDisabled }
label={ feature.label }
help={ feature.description }
onChange={ handleChange }
/>
{ action && masterEnabled && isConnected && (
<Link
className="jetpack-ai-features__action"
href={ action.href }
openInNewTab={ !! action.external }
>
{ action.label }
</Link>
) }
</Stack>
);
}
/**
* AI Features view component.
*
* @param {object} props - Component props.
* @param {object} props.settings - Full settings shape from the feature-settings endpoint.
* @param {Set} props.savingKeys - Keys currently being saved.
* @param {Function} props.onUpdate - Called with a partial settings update payload; resolves true when the save succeeded.
* @return {object} Component markup.
*/
export default function AiFeatures( { settings, savingKeys, onUpdate } ) {
const features = settings?.features ?? {};
// Children keep their saved values while the master switch is off — the
// page shows them greyed with a site-wide notice instead of misreporting
// the user's choices as off.
const masterEnabled = settings?.master_enabled !== false;
// The connection gate sits outside the master switch: false covers both a
// site without a connected owner and one in offline mode, and in either
// case no AI feature can load. Saved values stay visible but inert, and
// the connection ask comes before any upgrade messaging.
const isConnected = settings?.is_connected !== false;
// There is no site-wide plan gate: a plan without paid Jetpack AI still has
// the free tier, so a connected site can always run the free-tier features.
// Paid-only features are gated per-feature via requires_upgrade instead.
// The badge tooltip names the remedy for the gated Search section. A site
// with a paid Search plan is pointed at Search setup; one with no Search
// entitlement — or only the free tier, which reports supports_search but
// cannot run AI Search — is asked to upgrade instead. Unlike the gates
// above this defaults to the upgrade copy: pointing an unentitled site at
// setup would send it down the wrong path, and the badge cannot render
// before the payload (which carries `plan`) has arrived anyway.
// NOTE: the badge itself is generic (any section whose features report
// requires_upgrade), but this copy is Search-specific — ai_search is the
// only feature the endpoint gates today. A second gated feature needs its
// own copy here, not a silent reuse of this one.
const hasPaidSearchPlan =
settings?.plan?.supports_search === true && settings?.plan?.is_free_search_plan !== true;
const upgradeBadgeTooltip = hasPaidSearchPlan
? __( 'Set up Jetpack Search to enable this feature', 'jetpack' )
: __(
'Requires Jetpack Search or Complete plans',
'jetpack',
/* dummy arg to avoid bad minification */ 0
);
const { seoSettingsUrl, searchSettingsUrl, myJetpackUrl } = getLinkTargets();
const sections = visibleSections(
getSections( { seoSettingsUrl, searchSettingsUrl } ),
features
);
const handleToggle = useCallback(
( key, enabled ) => {
onUpdate( { features: { [ key ]: enabled } } ).then( saved => {
// Track outcomes, not attempts: a failed save changed nothing.
if ( saved ) {
analytics.tracks.recordEvent( 'jetpack_ai_feature_toggled', {
feature: key,
enabled,
} );
}
} );
},
[ onUpdate ]
);
const renderRow = feature => (
<FeatureRow
key={ feature.key }
feature={ feature }
reported={ features[ feature.key ] }
checked={ !! features[ feature.key ]?.enabled }
isSaving={ savingKeys.has( feature.key ) }
masterEnabled={ masterEnabled }
isConnected={ isConnected }
onChange={ handleToggle }
/>
);
return (
<Stack direction="column" gap="md">
{ ! isConnected && (
<Notice.Root intent="warning">
<Notice.Title>
{ __( 'Jetpack is not connected to WordPress.com.', 'jetpack' ) }
</Notice.Title>
<Notice.Description>
{ __(
'AI features need a connection to run. Your saved settings will apply once the site is connected.',
'jetpack'
) }{ ' ' }
<Link href="admin.php?page=my-jetpack#/connection">
{ __( 'Connect Jetpack', 'jetpack' ) }
</Link>
</Notice.Description>
</Notice.Root>
) }
{ isConnected && ! masterEnabled && (
<Notice.Root intent="warning">
<Notice.Title>
{ __( 'Jetpack AI is turned off for this site.', 'jetpack' ) }
</Notice.Title>
<Notice.Description>
{ __(
'Your feature settings are saved and will apply again when AI is turned back on.',
'jetpack'
) }
{ /* No remedy link on hosts without My Jetpack (Simple). */ }
{ myJetpackUrl && (
<>
{ ' ' }
<Link href={ myJetpackUrl } className="jetpack-ai-features__notice-link">
{ __( 'Manage in My Jetpack', 'jetpack' ) }
</Link>
</>
) }
</Notice.Description>
</Notice.Root>
) }
{ sections.length > 0 && (
<Card.Root className="jetpack-ai-features__card">
{ /* Single Card.Content, no Card.Header: the FullBleed dividers —
including the one under the header — must stay direct children
of Card.Content per the component's contract. */ }
<Card.Content className="jetpack-ai-features__card-content">
<Stack direction="column" gap="sm">
<Card.Title render={ <h2 /> }>{ __( 'Agent capabilities', 'jetpack' ) }</Card.Title>
<Text variant="body-sm" className="jetpack-ai-features__card-subtitle">
{ __(
'Choose what your WordPress Agent can help with across your site.',
'jetpack'
) }
</Text>
</Stack>
{ sections.map( section => {
const titleId = `jetpack-ai-features-${ section.key }-title`;
return (
<Fragment key={ section.key }>
<Card.FullBleed render={ <hr /> } className="jetpack-ai-features__divider" />
{ /* Labelled by its heading so each group is a named region —
screen readers can jump between them inside the merged card. */ }
<Stack
direction="column"
gap="2xl"
render={ <section aria-labelledby={ titleId } /> }
>
<div className="jetpack-ai-features__section-header">
<Text variant="heading-lg" render={ <h3 id={ titleId } /> }>
{ section.title }
</Text>
{ isConnected &&
section.features.some( f => features[ f.key ]?.requires_upgrade ) && (
<Popover.Root>
{ /* A popover rather than a tooltip: click opens it, touch
works, and screen readers announce the remedy copy —
the shape agreed with design on the PR. The trigger's
accessible name is its visible badge text. */ }
<Popover.Trigger
openOnHover
delay={ 200 }
closeDelay={ 200 }
className="jetpack-ai-features__upgrade-badge"
>
<Badge intent="informational">
{ __( 'Requires upgrade', 'jetpack' ) }
</Badge>
</Popover.Trigger>
<Popover.Popup>
<Popover.Arrow />
<VisuallyHidden render={ <Popover.Title /> }>
{ __( 'Requires upgrade', 'jetpack' ) }
</VisuallyHidden>
<Popover.Description>{ upgradeBadgeTooltip }</Popover.Description>
</Popover.Popup>
</Popover.Root>
) }
</div>
{ section.features.map( feature => renderRow( feature ) ) }
</Stack>
</Fragment>
);
} ) }
</Card.Content>
</Card.Root>
) }
</Stack>
);
}
|