diff --git a/projects/packages/forms/changelog/update-forms-spam-akismet-cta b/projects/packages/forms/changelog/update-forms-spam-akismet-cta
new file mode 100644
index 0000000000000..ac0f365a0bca2
--- /dev/null
+++ b/projects/packages/forms/changelog/update-forms-spam-akismet-cta
@@ -0,0 +1,4 @@
+Significance: patch
+Type: added
+
+Forms: Add CTA to install/activate Akismet on empty spam dashboard
diff --git a/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/hooks/use-plugin-installation.ts b/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/hooks/use-plugin-installation.ts
deleted file mode 100644
index 2c3d1b6124e2e..0000000000000
--- a/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/hooks/use-plugin-installation.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * External dependencies
- */
-import { useAnalytics } from '@automattic/jetpack-shared-extension-utils';
-import { useState, useCallback } from '@wordpress/element';
-/**
- * Internal dependencies
- */
-import { installAndActivatePlugin, activatePlugin } from '../../../util/plugin-management.js';
-
-type PluginInstallation = {
- isInstalling: boolean;
- installPlugin: () => Promise< boolean >;
-};
-
-/**
- * Custom hook to handle plugin installation and activation flows.
- *
- * @param {string} slug - The plugin slug (e.g., 'akismet')
- * @param {string} pluginPath - The plugin path (e.g., 'akismet/akismet')
- * @param {boolean} isInstalled - Whether the plugin is installed
- * @param {string} tracksEventName - The name of the tracks event to record
- * @return {object} Plugin installation states and handlers
- */
-export const usePluginInstallation = (
- slug: string,
- pluginPath: string,
- isInstalled: boolean,
- tracksEventName: string
-): PluginInstallation => {
- const [ isInstalling, setIsInstalling ] = useState( false );
- const { tracks } = useAnalytics();
-
- const installPlugin = useCallback( async () => {
- setIsInstalling( true );
-
- if ( tracksEventName ) {
- tracks.recordEvent( tracksEventName, {
- screen: 'block-editor',
- intent: isInstalled ? 'activate-plugin' : 'install-plugin',
- } );
- }
-
- try {
- if ( isInstalled ) {
- await activatePlugin( pluginPath );
- } else {
- await installAndActivatePlugin( slug );
- }
- return true;
- } catch {
- // Let the component handle the error state
- return false;
- } finally {
- setIsInstalling( false );
- }
- }, [ slug, pluginPath, isInstalled, tracks, tracksEventName ] );
-
- return {
- isInstalling,
- installPlugin,
- };
-};
diff --git a/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/integration-card/plugin-action-button.tsx b/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/integration-card/plugin-action-button.tsx
index 362ee8bd317c7..bee3a55202200 100644
--- a/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/integration-card/plugin-action-button.tsx
+++ b/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/integration-card/plugin-action-button.tsx
@@ -7,8 +7,7 @@ import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
-import useConfigValue from '../../../../../hooks/use-config-value.ts';
-import { usePluginInstallation } from '../hooks/use-plugin-installation.ts';
+import { usePluginInstallation } from '../../../../../hooks/use-plugin-installation.ts';
type PluginActionButtonProps = {
slug: string;
@@ -27,18 +26,20 @@ const PluginActionButton = ( {
refreshStatus,
trackEventName,
}: PluginActionButtonProps ) => {
- const { isInstalling, installPlugin } = usePluginInstallation(
- slug,
- pluginFile,
- isInstalled,
- trackEventName
- );
+ const trackEventProps = {
+ screen: 'block-editor',
+ };
- // Permissions from consolidated Forms config (shared across editor and dashboard)
- const canUserInstallPlugins = useConfigValue( 'canInstallPlugins' );
- const canUserActivatePlugins = useConfigValue( 'canActivatePlugins' );
+ const { isInstalling, installPlugin, canInstallPlugins, canActivatePlugins } =
+ usePluginInstallation( {
+ slug,
+ pluginPath: pluginFile,
+ isInstalled,
+ trackEventName,
+ trackEventProps,
+ } );
- const canPerformAction = isInstalled ? canUserActivatePlugins : canUserInstallPlugins;
+ const canPerformAction = isInstalled ? canActivatePlugins : canInstallPlugins;
const [ isReconcilingStatus, setIsReconcilingStatus ] = useState( false );
const isDisabled = isInstalling || isReconcilingStatus || ! canPerformAction;
@@ -84,10 +85,10 @@ const PluginActionButton = ( {
);
const getTooltipText = (): string => {
- if ( isInstalled && ! canUserActivatePlugins ) {
+ if ( isInstalled && ! canActivatePlugins ) {
return tooltipTextNoActivatePerms;
}
- if ( ! isInstalled && ! canUserInstallPlugins ) {
+ if ( ! isInstalled && ! canInstallPlugins ) {
return tooltipTextNoInstallPerms;
}
return String( isInstalled ? tooltipTextActivate : tooltipTextInstall );
diff --git a/projects/packages/forms/src/dashboard/components/empty-responses/index.tsx b/projects/packages/forms/src/dashboard/components/empty-responses/index.tsx
index 7ca6578a8d50e..dafa76f4f67bd 100644
--- a/projects/packages/forms/src/dashboard/components/empty-responses/index.tsx
+++ b/projects/packages/forms/src/dashboard/components/empty-responses/index.tsx
@@ -1,12 +1,157 @@
+/**
+ * External dependencies
+ */
+import { isSimpleSite } from '@automattic/jetpack-script-data';
import {
+ Button,
+ ExternalLink,
__experimentalText as Text, // eslint-disable-line @wordpress/no-unsafe-wp-apis
__experimentalVStack as VStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis
} from '@wordpress/components';
+import { useSelect, useDispatch } from '@wordpress/data';
+import { createInterpolateElement, useCallback, useMemo } from '@wordpress/element';
import { __, _n, sprintf } from '@wordpress/i18n';
+/**
+ * Internal dependencies
+ */
import useConfigValue from '../../../hooks/use-config-value.ts';
+import { usePluginInstallation } from '../../../hooks/use-plugin-installation.ts';
+import { INTEGRATIONS_STORE } from '../../../store/integrations/index.ts';
import CreateFormButton from '../create-form-button/index.tsx';
+/**
+ * Types
+ */
+import type {
+ IntegrationsDispatch,
+ SelectIntegrations,
+} from '../../../store/integrations/index.ts';
+import type { Integration } from '../../../types/index.ts';
+import type { ReactNode } from 'react';
-const EmptyWrapper = ( { heading = '', body = '', actions = null } ) => (
+type UseInstallAkismetReturn = {
+ shouldShowAkismetCta: boolean;
+ wrapperBody: ReactNode;
+ isInstallingAkismet: boolean;
+ canPerformAkismetAction: boolean;
+ wrapperButtonText: string;
+ handleAkismetSetup: () => Promise< void >;
+};
+
+type EmptyResponsesProps = {
+ status: string;
+ isSearch: boolean;
+ readStatusFilter?: 'unread' | 'read';
+};
+
+type EmptyWrapperProps = {
+ heading?: string;
+ body?: string | ReactNode;
+ actions?: ReactNode;
+};
+
+/**
+ * Hook to handle Akismet installation and activation.
+ *
+ * @return {UseInstallAkismetReturn} An object containing the necessary data and functions to handle Akismet installation and activation.
+ */
+const useInstallAkismet = (): UseInstallAkismetReturn => {
+ const { akismetIntegration } = useSelect( ( select: SelectIntegrations ) => {
+ const store = select( INTEGRATIONS_STORE );
+ const integrations = store.getIntegrations() || [];
+
+ return {
+ akismetIntegration: integrations.find(
+ ( integration: Integration ) => integration.id === 'akismet'
+ ),
+ };
+ }, [] ) as { akismetIntegration?: Integration };
+
+ const { refreshIntegrations } = useDispatch( INTEGRATIONS_STORE ) as IntegrationsDispatch;
+
+ const akismetIntegrationReady = useMemo(
+ () => !! akismetIntegration && ! akismetIntegration.__isPartial,
+ [ akismetIntegration ]
+ );
+
+ const isInstalled = !! akismetIntegration?.isInstalled;
+
+ const isAkismetActive = akismetIntegrationReady && isInstalled && !! akismetIntegration?.isActive;
+
+ const shouldShowAkismetCta = akismetIntegrationReady && ! isAkismetActive && ! isSimpleSite();
+
+ const akismetPluginFile = useMemo(
+ () => akismetIntegration?.pluginFile ?? 'akismet/akismet',
+ [ akismetIntegration?.pluginFile ]
+ );
+
+ const wrapperBody: ReactNode = createInterpolateElement(
+ __(
+ 'Want automatic spam filtering? Akismet Anti-spam protects millions of sites. Learn more.',
+ 'jetpack-forms'
+ ),
+ {
+ moreInfoLink: ,
+ }
+ );
+
+ const activateButtonText = __( 'Activate Akismet Anti-spam', 'jetpack-forms' );
+ const installAndActivateButtonText = __( 'Install Akismet Anti-spam', 'jetpack-forms' );
+ const wrapperButtonText = isInstalled ? activateButtonText : installAndActivateButtonText;
+
+ const {
+ isInstalling: isInstallingAkismet,
+ installPlugin,
+ canInstallPlugins,
+ canActivatePlugins,
+ } = usePluginInstallation( {
+ slug: 'akismet',
+ pluginPath: akismetPluginFile,
+ isInstalled,
+ onSuccess: refreshIntegrations,
+ trackEventName: 'jetpack_forms_upsell_akismet_click',
+ trackEventProps: {
+ screen: 'dashboard',
+ },
+ successNotices: {
+ install: {
+ message: __( 'Akismet installed and activated.', 'jetpack-forms' ),
+ options: { type: 'snackbar', id: 'akismet-install-success' },
+ },
+ activate: {
+ message: __( 'Akismet activated.', 'jetpack-forms' ),
+ options: { type: 'snackbar', id: 'akismet-install-success' },
+ },
+ },
+ errorNotice: {
+ message: __( 'Could not set up Akismet. Please try again.', 'jetpack-forms' ),
+ options: { type: 'snackbar', id: 'akismet-install-error' },
+ },
+ } );
+
+ const canPerformAkismetAction =
+ isInstalled && akismetIntegrationReady
+ ? canActivatePlugins !== false
+ : canInstallPlugins !== false;
+
+ const handleAkismetSetup = useCallback( async () => {
+ if ( isInstallingAkismet || ! akismetIntegrationReady || ! canPerformAkismetAction ) {
+ return;
+ }
+
+ await installPlugin();
+ }, [ isInstallingAkismet, akismetIntegrationReady, canPerformAkismetAction, installPlugin ] );
+
+ return {
+ shouldShowAkismetCta,
+ wrapperBody,
+ isInstallingAkismet,
+ canPerformAkismetAction,
+ wrapperButtonText,
+ handleAkismetSetup,
+ };
+};
+
+const EmptyWrapper = ( { heading = '', body = '', actions = null }: EmptyWrapperProps ) => (
{ heading && (
@@ -18,14 +163,16 @@ const EmptyWrapper = ( { heading = '', body = '', actions = null } ) => (
);
-type EmptyResponsesProps = {
- status: string;
- isSearch: boolean;
- readStatusFilter?: 'unread' | 'read';
-};
-
const EmptyResponses = ( { status, isSearch, readStatusFilter }: EmptyResponsesProps ) => {
const emptyTrashDays = useConfigValue( 'emptyTrashDays' ) ?? 0;
+ const {
+ shouldShowAkismetCta,
+ wrapperBody,
+ isInstallingAkismet,
+ canPerformAkismetAction,
+ wrapperButtonText,
+ handleAkismetSetup,
+ } = useInstallAkismet();
// Handle search and filter states first
const hasReadStatusFilter = !! readStatusFilter;
@@ -60,7 +207,28 @@ const EmptyResponses = ( { status, isSearch, readStatusFilter }: EmptyResponsesP
'Spam responses are permanently deleted after 15 days.',
'jetpack-forms'
);
+
if ( status === 'spam' ) {
+ if ( shouldShowAkismetCta ) {
+ return (
+
+ { wrapperButtonText }
+
+ }
+ />
+ );
+ }
+
return ;
}
diff --git a/projects/packages/forms/src/dashboard/inbox/stage/index.js b/projects/packages/forms/src/dashboard/inbox/stage/index.js
index 05248db7bc111..044617c6d45c6 100644
--- a/projects/packages/forms/src/dashboard/inbox/stage/index.js
+++ b/projects/packages/forms/src/dashboard/inbox/stage/index.js
@@ -3,9 +3,11 @@
*/
import jetpackAnalytics from '@automattic/jetpack-analytics';
import { JetpackLogo } from '@automattic/jetpack-components';
+import { isSimpleSite } from '@automattic/jetpack-script-data';
import { Badge } from '@automattic/ui';
import { ExternalLink, Modal } from '@wordpress/components';
import { useResizeObserver, useViewportMatch } from '@wordpress/compose';
+import { useSelect } from '@wordpress/data';
import { DataViews } from '@wordpress/dataviews/wp';
import { dateI18n, getSettings as getDateSettings } from '@wordpress/date';
import { useCallback, useMemo, useState } from '@wordpress/element';
@@ -19,6 +21,7 @@ import { useSearchParams } from 'react-router';
* Internal dependencies
*/
import useConfigValue from '../../../hooks/use-config-value.ts';
+import { INTEGRATIONS_STORE } from '../../../store/integrations/index.ts';
import CreateFormButton from '../../components/create-form-button/index.tsx';
import EmptyResponses from '../../components/empty-responses/index.tsx';
import EmptySpamButton from '../../components/empty-spam-button/index.tsx';
@@ -137,6 +140,23 @@ export default function InboxView() {
totalItems,
totalPages,
} = useInboxData();
+ const isAkismetStatusPending = useSelect(
+ select => {
+ const store = select( INTEGRATIONS_STORE );
+ const integrations = store.getIntegrations() || [];
+ const isIntegrationsLoading = store.isIntegrationsLoading();
+ const akismetIntegration = integrations.find( integration => integration.id === 'akismet' );
+
+ return (
+ statusFilter === 'spam' &&
+ ! isSimpleSite() &&
+ ( isIntegrationsLoading || ! akismetIntegration || akismetIntegration.__isPartial )
+ );
+ },
+ [ statusFilter ]
+ );
+
+ const isInboxLoading = isLoadingData || isAkismetStatusPending;
useEffect( () => {
const _filters = view.filters?.reduce( ( accumulator, { field, value } ) => {
@@ -497,7 +517,7 @@ export default function InboxView() {
fields={ fields }
actions={ actions }
data={ records || EMPTY_ARRAY }
- isLoading={ isLoadingData }
+ isLoading={ isInboxLoading }
view={ view }
onChangeView={ setView }
selection={ selection }
diff --git a/projects/packages/forms/src/hooks/use-plugin-installation.ts b/projects/packages/forms/src/hooks/use-plugin-installation.ts
new file mode 100644
index 0000000000000..97dfaade677b5
--- /dev/null
+++ b/projects/packages/forms/src/hooks/use-plugin-installation.ts
@@ -0,0 +1,142 @@
+/**
+ * External dependencies
+ */
+import { useAnalytics } from '@automattic/jetpack-shared-extension-utils';
+import { useDispatch } from '@wordpress/data';
+import { useState, useCallback } from '@wordpress/element';
+import { store as noticesStore } from '@wordpress/notices';
+/**
+ * Internal dependencies
+ */
+import {
+ installAndActivatePlugin,
+ activatePlugin,
+} from '../blocks/contact-form/util/plugin-management.js';
+import useConfigValue from './use-config-value.ts';
+
+type NoticeOptions = Record< string, unknown >;
+
+type NoticeConfig = {
+ message?: string;
+ options?: NoticeOptions;
+};
+
+type SuccessNotices = {
+ install?: NoticeConfig;
+ activate?: NoticeConfig;
+};
+
+type UsePluginInstallationArgs = {
+ slug: string;
+ pluginPath: string;
+ isInstalled: boolean;
+ trackEventName?: string;
+ trackEventProps?: Record< string, unknown >;
+ onSuccess?: () => void | Promise< void >;
+ successNotices?: SuccessNotices;
+ errorNotice?: NoticeConfig;
+};
+
+type PluginInstallation = {
+ isInstalling: boolean;
+ installPlugin: () => Promise< boolean >;
+ canInstallPlugins: boolean;
+ canActivatePlugins: boolean;
+};
+
+/**
+ * Custom hook to handle plugin installation and activation flows.
+ *
+ * @param {UsePluginInstallationArgs} args - Hook arguments.
+ * @return {PluginInstallation} Plugin installation states and handlers.
+ */
+export const usePluginInstallation = ( {
+ slug,
+ pluginPath,
+ isInstalled,
+ trackEventName,
+ trackEventProps = {},
+ onSuccess,
+ successNotices,
+ errorNotice,
+}: UsePluginInstallationArgs ): PluginInstallation => {
+ const [ isInstalling, setIsInstalling ] = useState( false );
+ const { tracks } = useAnalytics();
+ const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore );
+ const canInstallPlugins = useConfigValue( 'canInstallPlugins' );
+ const canActivatePlugins = useConfigValue( 'canActivatePlugins' );
+
+ const installPlugin = useCallback( async () => {
+ setIsInstalling( true );
+
+ if ( trackEventName ) {
+ tracks.recordEvent( trackEventName, {
+ intent: isInstalled ? 'activate-plugin' : 'install-plugin',
+ ...( trackEventProps ?? {} ),
+ } );
+ }
+
+ try {
+ if ( isInstalled ) {
+ if ( ! canActivatePlugins ) {
+ return false;
+ }
+
+ await activatePlugin( pluginPath );
+ } else {
+ if ( ! canInstallPlugins ) {
+ return false;
+ }
+
+ await installAndActivatePlugin( slug );
+ }
+
+ const successNoticeConfig = isInstalled ? successNotices?.activate : successNotices?.install;
+
+ if ( successNoticeConfig?.message ) {
+ createSuccessNotice( successNoticeConfig.message, successNoticeConfig.options );
+ }
+
+ if ( onSuccess ) {
+ await onSuccess();
+ }
+
+ return true;
+ } catch ( error ) {
+ if ( errorNotice ) {
+ const noticeMessage =
+ errorNotice.message || ( error instanceof Error ? error.message : undefined );
+
+ if ( noticeMessage ) {
+ createErrorNotice( noticeMessage, errorNotice.options );
+ }
+ }
+
+ return false;
+ } finally {
+ setIsInstalling( false );
+ }
+ }, [
+ trackEventName,
+ tracks,
+ isInstalled,
+ trackEventProps,
+ successNotices?.activate,
+ successNotices?.install,
+ onSuccess,
+ canActivatePlugins,
+ pluginPath,
+ canInstallPlugins,
+ slug,
+ createSuccessNotice,
+ errorNotice,
+ createErrorNotice,
+ ] );
+
+ return {
+ isInstalling,
+ installPlugin,
+ canInstallPlugins,
+ canActivatePlugins,
+ };
+};