diff --git a/assets/apps/dashboard/src/Components/Content/Settings.js b/assets/apps/dashboard/src/Components/Content/Settings.js
index 075fafa611..dfe99f213d 100644
--- a/assets/apps/dashboard/src/Components/Content/Settings.js
+++ b/assets/apps/dashboard/src/Components/Content/Settings.js
@@ -139,28 +139,30 @@ const Settings = () => {
-
- {tab === 'general' && (
-
-
-
- )}
- {tab === 'performance' && (
-
-
-
- )}
- {tab === 'white-label' && (
-
-
-
- )}
- {tab === 'manage-modules' && (
-
-
-
- )}
-
+
+
+ {tab === 'general' && (
+
+
+
+ )}
+ {tab === 'performance' && (
+
+
+
+ )}
+ {tab === 'white-label' && (
+
+
+
+ )}
+ {tab === 'manage-modules' && (
+
+
+
+ )}
+
+
);
};
diff --git a/assets/apps/dashboard/src/Components/Content/Settings/PerformancePlugins.js b/assets/apps/dashboard/src/Components/Content/Settings/PerformancePlugins.js
new file mode 100644
index 0000000000..95456495d8
--- /dev/null
+++ b/assets/apps/dashboard/src/Components/Content/Settings/PerformancePlugins.js
@@ -0,0 +1,153 @@
+/* global neveDash */
+import { useSelect } from '@wordpress/data';
+import { useEffect, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import cn from 'classnames';
+import { LoaderCircle, LucidePuzzle, LucideRocket } from 'lucide-react';
+
+import Pill from '../../Common/Pill';
+import Toast from '../../Common/Toast';
+import TransitionInOut from '../../Common/TransitionInOut';
+import Card from '../../../Layout/Card';
+import usePluginActions from '../../../Hooks/usePluginActions';
+import {
+ NEVE_HIDE_PLUGINS,
+ NEVE_PLUGIN_ICON_MAP,
+ NEVE_STORE,
+} from '../../../utils/constants';
+
+const PROMOTED_SLUGS = ['optimole-wp', 'wp-cloudflare-page-cache'];
+
+const PluginCard = ({ slug }) => {
+ const ICON = NEVE_PLUGIN_ICON_MAP[slug] || LucidePuzzle;
+ // Titles and descriptions are already localized in inc/admin/dashboard/main.php.
+ const { title, description } = neveDash.plugins[slug];
+
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(false);
+
+ const { doPluginAction, loading, buttonText } = usePluginActions(
+ slug,
+ true
+ );
+
+ const isPluginActive = useSelect(
+ (select) => select(NEVE_STORE).getPlugins()[slug]?.cta === 'deactivate'
+ );
+
+ useEffect(() => {
+ if (!success) {
+ return;
+ }
+
+ const timeoutId = window.setTimeout(() => {
+ setSuccess(false);
+ }, 1500);
+
+ return () => window.clearTimeout(timeoutId);
+ }, [success]);
+
+ if (isPluginActive && !success) {
+ return null;
+ }
+
+ const handleClick = async () => {
+ setError(null);
+
+ window.tiTrk?.with('neve').set(slug, {
+ feature: 'performance-plugin-promo',
+ featureComponent: slug,
+ });
+
+ const result = await doPluginAction();
+
+ if (result.success) {
+ setSuccess(true);
+
+ return;
+ }
+
+ setError(result.error);
+ };
+
+ return (
+
+
+
+
{title}
+
+ {success && (
+
+
+ {__('Active', 'neve')}
+
+
+ )}
+
+
+ {/* grow keeps the CTAs aligned when descriptions wrap to different heights */}
+
+ {description}
+
+
+ {!success && (
+
+ )}
+
+ {error && (
+
+
+
+ )}
+
+ );
+};
+
+const PerformancePlugins = () => {
+ const plugins = useSelect((select) => select(NEVE_STORE).getPlugins(), []);
+
+ // A promoted plugin can be absent entirely, e.g. Super Page Cache is dropped when SPC Pro is installed.
+ const availableSlugs = PROMOTED_SLUGS.filter(
+ (slug) => plugins[slug] && plugins[slug].cta !== 'deactivate'
+ );
+
+ if (NEVE_HIDE_PLUGINS || availableSlugs.length < 1) {
+ return null;
+ }
+
+ return (
+ }
+ title={__('Recommended Plugins', 'neve')}
+ >
+ 1,
+ })}
+ >
+ {availableSlugs.map((slug) => (
+
+ ))}
+
+
+ );
+};
+
+export default PerformancePlugins;
diff --git a/assets/apps/dashboard/src/Components/Content/Settings/PerformanceTabContent.js b/assets/apps/dashboard/src/Components/Content/Settings/PerformanceTabContent.js
index 1efe58f04e..91219f1188 100644
--- a/assets/apps/dashboard/src/Components/Content/Settings/PerformanceTabContent.js
+++ b/assets/apps/dashboard/src/Components/Content/Settings/PerformanceTabContent.js
@@ -11,6 +11,7 @@ import ToggleControl from '../../Controls/ToggleControl';
import Button from '../../Common/Button';
import useLicenseData from '../../../Hooks/useLicenseData';
import OptionGroup from './OptionGroup';
+import PerformancePlugins from './PerformancePlugins';
const LOCAL_HOSTING_OPTION = 'enable_local_fonts';
@@ -115,6 +116,8 @@ export default () => {
{(isLicenseValid && ) || }
+
+
>
);
};
diff --git a/assets/apps/dashboard/src/Layout/Card.js b/assets/apps/dashboard/src/Layout/Card.js
index d00980209e..186b842050 100644
--- a/assets/apps/dashboard/src/Layout/Card.js
+++ b/assets/apps/dashboard/src/Layout/Card.js
@@ -7,12 +7,14 @@ export default ({
afterTitle,
className = '',
id = null,
+ flat = false,
}) => {
+ // `flat` keeps the header styling but drops the box, for cards nested inside another one.
const classes = cn(
[
- 'p-6 rounded-lg shadow-sm',
{
- 'bg-white': !className.includes('bg-'),
+ 'p-6 rounded-lg shadow-sm': !flat,
+ 'bg-white': !flat && !className.includes('bg-'),
},
],
className