From 71ad18031530d6ee862a0aa469c33ff4afeeb835 Mon Sep 17 00:00:00 2001 From: Angela Blake Date: Thu, 4 Jun 2026 13:30:43 -0500 Subject: [PATCH] SEO: add AI tab and move the AI SEO Enhancer setting onto it (JETPACK-1683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stand up an AI tab in the SEO dashboard and relocate the AI SEO Enhancer toggle (auto-generate SEO title/description/alt-text for new posts) from the legacy Traffic page onto it. Additive only — the legacy toggle stays until PR #5b removes the old page. - New 'ai' tab in app.tsx (always visible — free AI settings will be added here later by separate llms.txt / AI-crawler work). - get_ai_data() bootstraps seo.ai script-data: the enhancer's enabled state plus availability, which mirrors the legacy gate (the ai_seo_enhancer_enabled feature filter AND Current_Plan::supports('ai-seo-enhancer'); Current_Plan guarded by class_exists as it's host-plugin-provided). - AiScreen renders the enhancer toggle only when available; useAiForm saves on change through the existing /jetpack/v4/settings endpoint (no new REST), with the same snackbar + revert-on-error as Settings. Lifted to the page root so the value survives tab switches. - Tests: get_ai_data() contract (shape + unavailable without a supporting plan). Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/packages/seo/_inc/app.tsx | 17 +++- projects/packages/seo/_inc/data/ai-types.ts | 13 +++ projects/packages/seo/_inc/data/use-ai.ts | 92 +++++++++++++++++++ .../packages/seo/_inc/screens/ai/index.tsx | 73 +++++++++++++++ projects/packages/seo/changelog/add-ai-tab | 4 + .../packages/seo/src/class-initializer.php | 31 +++++++ .../seo/tests/php/InitializerTest.php | 18 ++++ 7 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 projects/packages/seo/_inc/data/ai-types.ts create mode 100644 projects/packages/seo/_inc/data/use-ai.ts create mode 100644 projects/packages/seo/_inc/screens/ai/index.tsx create mode 100644 projects/packages/seo/changelog/add-ai-tab diff --git a/projects/packages/seo/_inc/app.tsx b/projects/packages/seo/_inc/app.tsx index 77fd9db88a83..3612e5bd0a57 100644 --- a/projects/packages/seo/_inc/app.tsx +++ b/projects/packages/seo/_inc/app.tsx @@ -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'; @@ -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 @@ -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 @@ -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( { @@ -85,6 +90,7 @@ const App: FC = () => { { __( 'Overview', 'jetpack-seo' ) } { __( 'Settings', 'jetpack-seo' ) } { __( 'Content', 'jetpack-seo' ) } + { __( 'AI', 'jetpack-seo' ) } @@ -102,6 +108,11 @@ const App: FC = () => { + +
+ +
+
diff --git a/projects/packages/seo/_inc/data/ai-types.ts b/projects/packages/seo/_inc/data/ai-types.ts new file mode 100644 index 000000000000..00befa27f81b --- /dev/null +++ b/projects/packages/seo/_inc/data/ai-types.ts @@ -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; + }; +} diff --git a/projects/packages/seo/_inc/data/use-ai.ts b/projects/packages/seo/_inc/data/use-ai.ts new file mode 100644 index 000000000000..b6f5ce9166f6 --- /dev/null +++ b/projects/packages/seo/_inc/data/use-ai.ts @@ -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 }; +} diff --git a/projects/packages/seo/_inc/screens/ai/index.tsx b/projects/packages/seo/_inc/screens/ai/index.tsx new file mode 100644 index 000000000000..8468809b0229 --- /dev/null +++ b/projects/packages/seo/_inc/screens/ai/index.tsx @@ -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 ( + + + { __( 'Unable to load AI settings.', 'jetpack-seo' ) } + + + ); + } + + // 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 ( + + + { __( 'More AI tools for your SEO are on the way.', 'jetpack-seo' ) } + + + ); + } + + return ( +
+ + + { __( 'SEO Enhancer', 'jetpack-seo' ) } + + + + + +
+ ); +}; + +export default AiScreen; diff --git a/projects/packages/seo/changelog/add-ai-tab b/projects/packages/seo/changelog/add-ai-tab new file mode 100644 index 000000000000..d351cdb5259e --- /dev/null +++ b/projects/packages/seo/changelog/add-ai-tab @@ -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. diff --git a/projects/packages/seo/src/class-initializer.php b/projects/packages/seo/src/class-initializer.php index 1ed694123fd5..efc75c7eeea3 100644 --- a/projects/packages/seo/src/class-initializer.php +++ b/projects/packages/seo/src/class-initializer.php @@ -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; } @@ -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 ), + ), + ); + } } diff --git a/projects/packages/seo/tests/php/InitializerTest.php b/projects/packages/seo/tests/php/InitializerTest.php index 457e1ed0bb37..231aa37828a4 100644 --- a/projects/packages/seo/tests/php/InitializerTest.php +++ b/projects/packages/seo/tests/php/InitializerTest.php @@ -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'] ); + } }