Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions projects/packages/seo/_inc/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { __ } from '@wordpress/i18n';
import { useNavigate, useSearch } from '@wordpress/route';
import { Tabs } from '@wordpress/ui';
import getOverview from './data/get-overview';
import { useAiForm } from './data/use-ai';
import { useSettingsForm } from './data/use-settings';
import NoticesList from './notices-list';
import AiScreen from './screens/ai';
import ContentScreen from './screens/content';
import OverviewScreen from './screens/overview';
import SettingsScreen from './screens/settings';
Expand All @@ -15,7 +17,7 @@ import type { ContentCoverage } from './data/overview-types';
import type { FC } from 'react';

type StageSearch = Record< string, unknown > & { tab?: string };
type SeoTab = 'overview' | 'settings' | 'content';
type SeoTab = 'overview' | 'settings' | 'content' | 'ai';

/**
* Root of the Jetpack SEO admin app, mounted by `@wordpress/build` as the
Expand All @@ -28,9 +30,12 @@ type SeoTab = 'overview' | 'settings' | 'content';
const App: FC = () => {
const search = useSearch( { from: '/' as unknown as never, strict: false } ) as StageSearch;
const activeTab: SeoTab =
search.tab === 'settings' || search.tab === 'content' ? search.tab : 'overview';
search.tab === 'settings' || search.tab === 'content' || search.tab === 'ai'
? search.tab
: 'overview';
const navigate = useNavigate();
const settingsForm = useSettingsForm();
const aiForm = useAiForm();

// Coverage counts live here (above the tabs) so a Content-tab edit reflects
// on the Overview card immediately on tab switch, without a reload. Seeded
Expand All @@ -55,7 +60,7 @@ const App: FC = () => {

const onTabChange = useCallback(
( next: string | null ) => {
if ( next !== 'overview' && next !== 'settings' && next !== 'content' ) {
if ( next !== 'overview' && next !== 'settings' && next !== 'content' && next !== 'ai' ) {
return;
}
navigate( {
Expand Down Expand Up @@ -85,6 +90,7 @@ const App: FC = () => {
<Tabs.Tab value="overview">{ __( 'Overview', 'jetpack-seo' ) }</Tabs.Tab>
<Tabs.Tab value="settings">{ __( 'Settings', 'jetpack-seo' ) }</Tabs.Tab>
<Tabs.Tab value="content">{ __( 'Content', 'jetpack-seo' ) }</Tabs.Tab>
<Tabs.Tab value="ai">{ __( 'AI', 'jetpack-seo' ) }</Tabs.Tab>
</Tabs.List>
</div>
<Tabs.Panel value="overview" focusable={ false }>
Expand All @@ -102,6 +108,11 @@ const App: FC = () => {
<ContentScreen onSaved={ onContentSaved } />
</div>
</Tabs.Panel>
<Tabs.Panel value="ai" focusable={ false }>
<div className="jetpack-seo-page-content">
<AiScreen form={ aiForm } />
</div>
</Tabs.Panel>
</Tabs.Root>
<NoticesList />
</AdminPage>
Expand Down
13 changes: 13 additions & 0 deletions projects/packages/seo/_inc/data/ai-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Shape of the AI tab's initial state, bootstrapped onto
// `window.JetpackScriptData.seo.ai` (see `Initializer::get_ai_data()`).
// The AI SEO Enhancer toggle writes through the existing `/jetpack/v4/settings`
// endpoint (`ai_seo_enhancer_enabled`).

export interface AiState {
enhancer: {
/** Whether the plan + feature filter make the SEO Enhancer available. */
available: boolean;
/** Whether the SEO Enhancer is currently enabled. */
enabled: boolean;
};
}
92 changes: 92 additions & 0 deletions projects/packages/seo/_inc/data/use-ai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { getScriptData } from '@automattic/jetpack-script-data';
import apiFetch from '@wordpress/api-fetch';
import { useDispatch } from '@wordpress/data';
import { useCallback, useMemo, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
import type { AiState } from './ai-types';

// Single snackbar id reused across a save so "Updating settings…" is replaced
// in place by "Settings saved." (or an error) — matches the Settings tab.
const SAVE_NOTICE_ID = 'jetpack-seo-ai-save';

type SeoScriptData = {
seo?: {
ai?: AiState;
};
};

/**
* Read the AI tab state bootstrapped onto `window.JetpackScriptData.seo.ai` by
* the server. Synchronous — present on first paint, no request. Returns `null`
* if the bootstrap is missing.
*
* @return The AI state, or `null` when unavailable.
*/
export function getAi(): AiState | null {
const scriptData = getScriptData() as SeoScriptData | undefined;
return scriptData?.seo?.ai ?? null;
}

export interface AiForm {
enhancer: AiState[ 'enhancer' ] | null;
isSaving: boolean;
/** Toggle the AI SEO Enhancer and save immediately. */
setEnhancerEnabled: ( next: boolean ) => void;
}

/**
* Owns the AI tab's form state: seeds from the page bootstrap and auto-saves the
* AI SEO Enhancer toggle through `/jetpack/v4/settings` (the same endpoint the
* legacy Traffic page used). On failure the local value reverts. There's no Save
* button — the toggle saves on change, surfacing the shared
* "Updating settings…"→"Settings saved." snackbar.
*
* Lives above the tab panels (in the page root) so the value survives tab
* switches — the script-data bootstrap is the initial load only.
*
* @return The AI form controller.
*/
export function useAiForm(): AiForm {
const initial = useMemo( () => getAi(), [] );
const [ enhancer, setEnhancer ] = useState< AiState[ 'enhancer' ] | null >(
initial?.enhancer ?? null
);
const [ isSaving, setIsSaving ] = useState( false );
const { createInfoNotice, createSuccessNotice, createErrorNotice } = useDispatch( noticesStore );

const setEnhancerEnabled = useCallback(
( next: boolean ) => {
setEnhancer( prev => ( prev ? { ...prev, enabled: next } : prev ) );
setIsSaving( true );
createInfoNotice( __( 'Updating settings…', 'jetpack-seo' ), {
id: SAVE_NOTICE_ID,
type: 'snackbar',
isDismissible: false,
} );
apiFetch( {
path: '/jetpack/v4/settings',
method: 'POST',
data: { ai_seo_enhancer_enabled: next },
} )
.then( () => {
createSuccessNotice( __( 'Settings saved.', 'jetpack-seo' ), {
id: SAVE_NOTICE_ID,
type: 'snackbar',
} );
} )
.catch( ( error: { message?: string } ) => {
// Revert the optimistic toggle so the UI reflects the persisted value.
setEnhancer( prev => ( prev ? { ...prev, enabled: ! next } : prev ) );
createErrorNotice(
error?.message ?? __( 'Could not save settings. Please try again.', 'jetpack-seo' ),
{ id: SAVE_NOTICE_ID, type: 'snackbar' }
);
} )
.finally( () => setIsSaving( false ) );
},
[ createInfoNotice, createSuccessNotice, createErrorNotice ]
);

return { enhancer, isSaving, setEnhancerEnabled };
}
73 changes: 73 additions & 0 deletions projects/packages/seo/_inc/screens/ai/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { ToggleControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { Card, CollapsibleCard, Notice } from '@wordpress/ui';
import type { AiForm } from '../../data/use-ai';
import type { FC } from 'react';

interface Props {
form: AiForm;
}

/**
* AI tab. Hosts the AI SEO Enhancer toggle today; llms.txt and AI-crawler
* controls land here later (tracked separately). State + auto-save live in the
* `form` controller (passed from the page root so it survives tab switches);
* this component is the presentation.
*
* The tab itself is always shown — only the Enhancer card is plan-gated, so the
* tab stays a home for the free AI settings still to come.
*
* @param props - Component props.
* @param props.form - The AI form controller from `useAiForm`.
* @return The AI tab content.
*/
const AiScreen: FC< Props > = ( { form } ) => {
const { enhancer, isSaving, setEnhancerEnabled } = form;

if ( ! enhancer ) {
return (
<Notice.Root intent="error">
<Notice.Description>
{ __( 'Unable to load AI settings.', 'jetpack-seo' ) }
</Notice.Description>
</Notice.Root>
);
}

// The Enhancer requires a supporting plan; when unavailable the card is
// hidden (parity with the legacy Traffic page). The tab stays in place for
// the free AI settings still to come.
if ( ! enhancer.available ) {
return (
<Notice.Root intent="default">
<Notice.Description>
{ __( 'More AI tools for your SEO are on the way.', 'jetpack-seo' ) }
</Notice.Description>
</Notice.Root>
);
}

return (
<div className="jetpack-seo-ai">
<CollapsibleCard.Root defaultOpen>
<CollapsibleCard.Header>
<Card.Title>{ __( 'SEO Enhancer', 'jetpack-seo' ) }</Card.Title>
</CollapsibleCard.Header>
<CollapsibleCard.Content>
<ToggleControl
label={ __(
'Automatically generate SEO title, SEO description, and image alt text for new posts',
'jetpack-seo'
) }
checked={ enhancer.enabled }
onChange={ setEnhancerEnabled }
disabled={ isSaving }
__nextHasNoMarginBottom
/>
</CollapsibleCard.Content>
</CollapsibleCard.Root>
</div>
);
};

export default AiScreen;
4 changes: 4 additions & 0 deletions projects/packages/seo/changelog/add-ai-tab
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Add an AI tab to the SEO dashboard and move the AI SEO Enhancer toggle (auto-generate SEO title, description, and image alt text for new posts) onto it.
31 changes: 31 additions & 0 deletions projects/packages/seo/src/class-initializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ public static function inject_script_data( $data ) {

$data[ self::SCRIPT_DATA_KEY ]['overview'] = self::get_overview_data();
$data[ self::SCRIPT_DATA_KEY ]['settings'] = self::get_settings_data();
$data[ self::SCRIPT_DATA_KEY ]['ai'] = self::get_ai_data();

return $data;
}
Expand Down Expand Up @@ -441,4 +442,34 @@ public static function get_settings_data() {
),
);
}

/**
* Build the AI tab's initial state.
*
* The AI SEO Enhancer auto-generates SEO titles/descriptions/alt-text in the
* editor (the generation itself is wpcom/AI-Assistant side); this exposes only
* its persisted on/off toggle and whether it's available. Availability mirrors
* the legacy Traffic page: the `ai_seo_enhancer_enabled` feature filter must be
* on (it still depends on AI being available) AND the site's plan must support
* the `ai-seo-enhancer` feature. The toggle writes through the existing
* `/jetpack/v4/settings` endpoint (`ai_seo_enhancer_enabled`).
*
* @return array
*/
public static function get_ai_data() {
$filter_on = (bool) apply_filters( 'ai_seo_enhancer_enabled', true );

// Current_Plan is provided by the host Jetpack plugin, not a package
// dependency — guard like the Jetpack_SEO_* helpers above.
$plan_supports = class_exists( 'Automattic\\Jetpack\\Current_Plan' )
// @phan-suppress-next-line PhanUndeclaredClassMethod -- guarded by class_exists; host plugin provides the class.
&& \Automattic\Jetpack\Current_Plan::supports( 'ai-seo-enhancer' );

return array(
'enhancer' => array(
'available' => $filter_on && $plan_supports,
'enabled' => (bool) get_option( 'ai_seo_enhancer_enabled', false ),
),
);
}
}
18 changes: 18 additions & 0 deletions projects/packages/seo/tests/php/InitializerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,22 @@ public function test_package_version_constant_is_defined() {
public function test_feature_filter_constant_is_defined() {
$this->assertSame( 'rsm_jetpack_seo', Initializer::FEATURE_FILTER );
}

/**
* The AI tab bootstrap exposes the enhancer shape the React app expects, with
* boolean availability/enabled. Without a plan-supporting environment the
* enhancer is unavailable.
*/
public function test_get_ai_data_shape() {
$ai = Initializer::get_ai_data();

$this->assertArrayHasKey( 'enhancer', $ai );
$this->assertArrayHasKey( 'available', $ai['enhancer'] );
$this->assertArrayHasKey( 'enabled', $ai['enhancer'] );
$this->assertIsBool( $ai['enhancer']['available'] );
$this->assertIsBool( $ai['enhancer']['enabled'] );
// Current_Plan isn't present in the package test context, so the enhancer
// can't be available regardless of the feature filter.
$this->assertFalse( $ai['enhancer']['available'] );
}
}
Loading