setCimoDownloadNotice( null ) } /> }
+ >
)
} )
diff --git a/src/init.php b/src/init.php
index 2caf788dae..4903527e43 100644
--- a/src/init.php
+++ b/src/init.php
@@ -349,6 +349,7 @@ public function register_block_editor_assets() {
'version' => array_shift( $version_parts ),
'wpVersion' => ! empty( $wp_version ) ? preg_replace( '/-.*/', '', $wp_version ) : $wp_version, // Ensure semver, strip out after dash
'adminUrl' => admin_url(),
+ 'ajaxUrl' => admin_url('admin-ajax.php'),
// Fonts.
'locale' => get_locale(),
diff --git a/src/lazy-components/cimo/index.js b/src/lazy-components/cimo/index.js
new file mode 100644
index 0000000000..563610a7f2
--- /dev/null
+++ b/src/lazy-components/cimo/index.js
@@ -0,0 +1,198 @@
+import {
+ cimo, i18n, ajaxUrl,
+} from 'stackable'
+import { createRoot } from '~stackable/util'
+
+import { __ } from '@wordpress/i18n'
+import { Dashicon } from '@wordpress/components'
+import domReady from '@wordpress/dom-ready'
+import {
+ useState, useRef, useEffect,
+} from '@wordpress/element'
+import { models } from '@wordpress/api'
+
+const CimoDownloadNotice = props => {
+ const [ data, setData ] = useState( { status: cimo?.status, action: cimo?.action } )
+ const pollCountRef = useRef( 0 )
+
+ const onDismiss = () => {
+ const settings = new models.Settings( { stackable_hide_cimo_notice: true } ) // eslint-disable-line camelcase
+ settings.save()
+
+ if ( cimo ) {
+ cimo.hideNotice = true
+ }
+
+ // Update the global stackable.cimo hideNotice variable
+ if ( typeof window !== 'undefined' && window.stackable?.cimo ) {
+ window.stackable.cimo.hideNotice = true
+ }
+
+ props?.onDismiss?.()
+ }
+
+ // Polls the Cimo plugin status to detect installation or activation state changes
+ const pollStatus = ( action, link, pollOnce = false ) => {
+ fetch( ajaxUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams( {
+ action: 'stackable_check_cimo_status',
+ // eslint-disable-next-line camelcase
+ user_action: action,
+ nonce: cimo.nonce,
+ } ),
+ credentials: 'same-origin',
+ } ).then( res => res.json() ).then( res => {
+ if ( ! res.success ) {
+ setData( { status: 'error', action: '' } )
+
+ const errorMessage = res?.data?.message ? res.data.message : 'Server error'
+
+ throw new Error( 'Stackable: ' + errorMessage )
+ }
+
+ if ( pollCountRef.current === 0 && link ) {
+ window.open( link, '_blank' )
+ }
+
+ pollCountRef.current += 1
+
+ const _data = res.data
+
+ if ( data.status !== _data.status ) {
+ setData( _data )
+
+ // Update the global stackable.cimo status/action variables
+ // so new image block selections reflect the latest Cimo installation state
+ if ( typeof window !== 'undefined' && window.stackable?.cimo ) {
+ window.stackable.cimo.status = _data.status
+ window.stackable.cimo.action = _data.action
+ }
+ }
+
+ // Stop polling if it has reached 3 attempts, or plugin status indicates installation/activation is complete
+ if ( pollOnce || pollCountRef.current >= 3 ||
+ ( action === 'install' && ( _data.status === 'installed' || _data.status === 'activated' ) ) ||
+ ( action === 'activate' && _data.status === 'activated' )
+ ) {
+ return
+ }
+
+ setTimeout( () => {
+ pollStatus( action )
+ }, 3000 * pollCountRef.current )
+ } ).catch( e => {
+ // eslint-disable-next-line no-console
+ console.error( e.message )
+ } )
+ }
+
+ useEffect( () => {
+ const _media = wp.media
+ const old = _media.view.MediaFrame.Select
+
+ // When the media library closes, check and update the Cimo plugin status
+ // to ensure the UI reflects the latest installation or activation state.
+ _media.view.MediaFrame.Select = old.extend( {
+ initialize() {
+ old.prototype.initialize.apply( this, arguments )
+
+ this.on( 'close', () => {
+ pollCountRef.current = 0
+ if ( data.status === 'activated' ) {
+ return
+ }
+
+ if ( data.status === 'not_installed' ) {
+ pollStatus( 'install', null, true )
+ return
+ }
+
+ pollStatus( 'activate', null, true )
+ } )
+ },
+ } )
+ }, [] )
+
+ const onActionClick = e => {
+ e.preventDefault()
+ pollCountRef.current = 0
+
+ if ( data.status === 'not_installed' ) {
+ setData( { status: 'installing', action: '' } )
+ pollStatus( 'install', e.currentTarget.href )
+ return
+ }
+
+ setData( { status: 'activating', action: '' } )
+ pollStatus( 'activate', e.currentTarget.href )
+ }
+
+ return ( <>
+
+ { data.status === 'activated'
+ ?
+ { __( 'Cimo Image Optimizer has been activated. Please refresh this page to begin optimizing your images automatically.', i18n ) }
+
+ : { __( 'Instantly optimize images as you upload them with Cimo Image Optimizer.', i18n ) }
+
+ { data.status === 'installing'
+ ? { __( 'Installing', i18n ) }
+ : ( data.status === 'activating'
+ ? { __( 'Activating', i18n ) }
+ :
+ { data.status === 'installed' ? __( 'Activate now', i18n ) : __( 'Install now', i18n ) }
+
+ )
+ }
+
+ }
+ > )
+}
+
+const CimoDownloadNoticeWrapper = props => {
+ return
+}
+
+export default CimoDownloadNoticeWrapper
+
+domReady( () => {
+ if ( ! cimo || cimo.status === 'activated' || cimo.hideNotice ||
+ typeof wp === 'undefined' || ! wp?.media?.view?.Attachment?.Details
+ ) {
+ return
+ }
+
+ const CurrentDetailsView = wp.media.view.Attachment.Details
+
+ // Display the Cimo download notice in the media library
+ const CustomDetailsView = CurrentDetailsView.extend( {
+ render() {
+ const result = CurrentDetailsView.prototype.render.apply( this, arguments )
+
+ if ( cimo?.hideNotice ) {
+ return result
+ }
+
+ const details = this.el.querySelector( '.attachment-info .details' )
+ if ( details && ! this.el.querySelector( '.stk-cimo-notice' ) ) {
+ const noticeDiv = document.createElement( 'div' )
+ noticeDiv.className = 'stk-cimo-notice'
+
+ const onDismiss = () => {
+ if ( noticeDiv && noticeDiv.parentNode ) {
+ noticeDiv.parentNode.removeChild( noticeDiv )
+ }
+ }
+
+ createRoot( noticeDiv ).render( )
+ details.insertAdjacentElement( 'afterend', noticeDiv )
+ }
+
+ return result
+ },
+ } )
+
+ wp.media.view.Attachment.Details = CustomDetailsView
+} )
diff --git a/src/lazy-components/cimo/style.scss b/src/lazy-components/cimo/style.scss
new file mode 100644
index 0000000000..185b3106b8
--- /dev/null
+++ b/src/lazy-components/cimo/style.scss
@@ -0,0 +1,47 @@
+.stk-cimo-notice {
+ clear: both;
+ padding: 16px;
+ border: 2px solid #16a249;
+ background: #fff;
+ margin: 12px 0;
+ border-radius: 4px;
+ box-shadow: 0 1px 4px #33533f70;
+ position: relative;
+
+ button {
+ background: none;
+ border: none;
+ height: 14px;
+ width: 14px;
+ position: absolute;
+ right: 4px;
+ top: 4px;
+ cursor: pointer;
+ }
+
+ .dashicon {
+ font-size: 14px;
+ height: 14px;
+ width: 14px;
+ &:hover {
+ color: var(--stk-skin-error, #f15449);
+ }
+ }
+
+ p {
+ margin: 0;
+ font-size: 12px;
+
+ a {
+ color: inherit;
+ font-weight: 700;
+
+ &:hover {
+ color: #16a249;
+ }
+ }
+ span {
+ font-weight: 700;
+ }
+ }
+}
diff --git a/src/welcome/admin.js b/src/welcome/admin.js
index 92dd69ee18..3d83ea5eef 100644
--- a/src/welcome/admin.js
+++ b/src/welcome/admin.js
@@ -42,6 +42,7 @@ import { GettingStarted } from './getting-started'
import { BLOCK_STATE } from '~stackable/util/blocks'
import { BlockToggler, OptimizationSettings } from '~stackable/deprecated/v2/welcome/admin'
import blockData from '~stackable/deprecated/v2/welcome/blocks'
+import { UsefulPlugins } from './useful-plugins'
const [ FREE_BLOCKS, BLOCK_DEPENDENCIES ] = importBlocks( require.context( '../block', true, /block\.json$/ ) )
@@ -1658,4 +1659,12 @@ domReady( () => {
)
}
+
+ if ( document.getElementById( 's-useful-plugins' ) ) {
+ createRoot(
+ document.getElementById( 's-useful-plugins' )
+ ).render(
+
+ )
+ }
} )
diff --git a/src/welcome/admin.scss b/src/welcome/admin.scss
index 1fc2f84946..30537f977d 100644
--- a/src/welcome/admin.scss
+++ b/src/welcome/admin.scss
@@ -8,6 +8,15 @@
--stk-welcome-light-border: #d0d5dd;
}
+@mixin s-shadow {
+ box-shadow: rgba(0, 0, 0, 0.05) 0px 6px 24px 0px, rgba(0, 0, 0, 0.08) 0px 0px 0px 1px;
+ transition: all 0.3s ease-in-out;
+ &:hover {
+ box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px, rgba(0, 0, 0, 0.08) 0px 0px 0px 1px;
+ }
+}
+
+
// Make scroll smooth with inline navigation.
html {
scroll-behavior: smooth;
@@ -17,6 +26,9 @@ html {
body[class*="page_stackable"],
body[class*="page_stk-"] {
+ --wp-components-color-accent: var(--stk-welcome-primary);
+ --wp-components-color-accent-darker-20: var(--stk-welcome-secondary);
+ --wp-components-color-accent-inverted: var(--stk-welcome-secondary);
#wpcontent {
padding-left: 0;
}
@@ -154,6 +166,54 @@ body[class*="page_stk-"] {
align-items: center;
}
+
+.s-card {
+ padding: 1.5em;
+ display: flex;
+ flex-direction: column;
+ border-radius: 16px;
+ @include s-shadow;
+ background: #fff;
+ overflow: hidden;
+
+ .s-card-title,
+ h3 {
+ margin: 0 0 0.5em;
+ }
+ .s-card-subtitle,
+ p {
+ margin: 0;
+ }
+ > *:last-child {
+ margin-bottom: 0;
+ }
+
+ .s-video-wrapper {
+ align-items: center;
+ }
+
+ .s-icon-wrapper {
+ border: 1px solid #eaecf0;
+ border-radius: 8px;
+ padding: 10px;
+ display: flex;
+ align-items: center;
+ max-width: fit-content;
+ margin-bottom: 20px;
+ }
+
+ .s-bottom-icon-wrapper {
+ display: flex;
+ justify-content: flex-end;
+ }
+
+ svg {
+ height: 24px;
+ width: 24px;
+ }
+
+}
+
.s-admin-notice-marker {
display: none !important;
}
@@ -369,10 +429,8 @@ body.toplevel_page_stackable {
padding-bottom: 0;
}
}
-body.stackable_page_stackable-settings,
body.toplevel_page_stackable,
-body.stackable_page_stk-custom-fields,
-body.stackable_page_stackable-go-premium {
+body[class*="page_stackable"] {
img {
max-width: 100%;
}
@@ -939,3 +997,4 @@ body.stackable_page_stackable-go-premium {
@import "news";
@import "getting-started";
@import "freemius";
+@import "useful-plugins";
diff --git a/src/welcome/getting-started.scss b/src/welcome/getting-started.scss
index 1545a4a5a7..f955d5e539 100644
--- a/src/welcome/getting-started.scss
+++ b/src/welcome/getting-started.scss
@@ -2,14 +2,6 @@
* Styles used by the Getting Started section.
*/
-@mixin s-shadow {
- box-shadow: rgba(0, 0, 0, 0.05) 0px 6px 24px 0px, rgba(0, 0, 0, 0.08) 0px 0px 0px 1px;
- transition: all 0.3s ease-in-out;
- &:hover {
- box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px, rgba(0, 0, 0, 0.08) 0px 0px 0px 1px;
- }
-}
-
.toplevel_page_stackable {
.s-body {
max-width: 1200px;
@@ -154,53 +146,6 @@
}
}
- .s-card {
- padding: 1.5em;
- display: flex;
- flex-direction: column;
- border-radius: 16px;
- @include s-shadow;
- background: #fff;
- overflow: hidden;
-
- .s-card-title,
- h3 {
- margin: 0 0 0.5em;
- }
- .s-card-subtitle,
- p {
- margin: 0;
- }
- > *:last-child {
- margin-bottom: 0;
- }
-
- .s-video-wrapper {
- align-items: center;
- }
-
- .s-icon-wrapper {
- border: 1px solid #eaecf0;
- border-radius: 8px;
- padding: 10px;
- display: flex;
- align-items: center;
- max-width: fit-content;
- margin-bottom: 20px;
- }
-
- .s-bottom-icon-wrapper {
- display: flex;
- justify-content: flex-end;
- }
-
- svg {
- height: 24px;
- width: 24px;
- }
-
- }
-
.s-divider {
width: 150px;
margin: 64px auto;
diff --git a/src/welcome/index.php b/src/welcome/index.php
index 94fcd6d5d4..a3fed63e39 100644
--- a/src/welcome/index.php
+++ b/src/welcome/index.php
@@ -80,6 +80,16 @@ public function add_dashboard_page() {
'__return_null',
);
}
+
+ // Our settings page.
+ add_submenu_page(
+ 'stackable', // Parent slug.
+ __( 'Useful Plugins', STACKABLE_I18N ), // Page title.
+ __( 'Useful Plugins', STACKABLE_I18N ), // Menu title.
+ 'manage_options', // Capability.
+ 'stackable-useful-plugins', // Menu slug.
+ function() { $this->stackable_content('s-useful-plugins'); }
+ );
}
public function enqueue_dashboard_script( $hook ) {
@@ -91,7 +101,7 @@ public function enqueue_dashboard_script( $hook ) {
}
// For the options page, load our options script.
- if ( 'settings_page_stackable' === $hook || stripos( $hook, 'page_stackable-settings' ) !== false || 'toplevel_page_stackable' === $hook ) {
+ if ( 'settings_page_stackable' === $hook || stripos( $hook, 'page_stackable' ) !== false || 'toplevel_page_stackable' === $hook ) {
wp_enqueue_script( 'wp-i18n' );
wp_enqueue_script( 'wp-element' );
@@ -195,6 +205,12 @@ public static function print_tabs() {
+
+
+
+
+
+
+ {
+ const pluginData = usefulPlugins?.[ plugin.id ] ?? null
+ const [ status, setStatus ] = useState( pluginData?.status ?? PLUGIN_STATUS.ACTIVATED )
+
+ if ( ! pluginData ) {
+ return null
+ }
+ const onClickAction = () => {
+ if ( status === PLUGIN_STATUS.ACTIVATED ||
+ status === PLUGIN_STATUS.INSTALLING ||
+ status === PLUGIN_STATUS.ACTIVATING
+ ) {
+ return
+ }
+
+ const prevStatus = status // Remember previous status to revert on error
+ let successStatus = status // Will be set for next success state
+ const formData = new window.FormData()
+ setStatus( prev => {
+ let newStatus = prev
+ if ( prev === PLUGIN_STATUS.NOT_INSTALLED ) {
+ formData.append( 'action', 'stackable_useful_plugins_install' )
+ formData.append( '_ajax_nonce', installerNonce )
+ formData.append( 'slug', plugin.id )
+ newStatus = PLUGIN_STATUS.INSTALLING
+ successStatus = PLUGIN_STATUS.INSTALLED
+ } else if ( prev === PLUGIN_STATUS.INSTALLED ) {
+ formData.append( 'action', 'stackable_useful_plugins_activate' )
+ formData.append( 'nonce', activateNonce )
+ formData.append( 'slug', plugin.id )
+ formData.append( 'full_slug', pluginData.fullSlug )
+ newStatus = PLUGIN_STATUS.ACTIVATING
+ successStatus = PLUGIN_STATUS.ACTIVATED
+ }
+ return newStatus
+ } )
+
+ // formData is empty
+ if ( formData.entries().next().done ) {
+ setStatus( prevStatus )
+ return
+ }
+
+ // Perform Ajax request to install or activate plugin
+ apiFetch( {
+ url: ajaxUrl,
+ method: 'POST',
+ body: formData,
+ } ).then( response => {
+ setTimeout( () => {
+ // Mark as succeeded if operation successful or folder already exists after install
+ if ( response.success || response.data?.errorCode === 'folder_exists' ) {
+ pluginData.status = successStatus
+ setStatus( successStatus )
+ } else {
+ pluginData.status = prevStatus
+ setStatus( prevStatus )
+ }
+ }, 1000 ) // Add small delay to avoid race conditions with plugin activation/installation
+ } ).catch( e => {
+ // eslint-disable-next-line no-console
+ console.error( 'Stackable: ', e )
+ pluginData.status = prevStatus
+ setStatus( prevStatus )
+ } )
+ }
+
+ return
+
+

+
{ plugin.title }
+
+
{ plugin.description }
+
+
+}
+
+export const UsefulPlugins = () => {
+ return
+ { PLUGINS.map( ( plugin, i ) => {
+ return
+ } ) }
+
+}
diff --git a/src/welcome/useful-plugins.php b/src/welcome/useful-plugins.php
new file mode 100644
index 0000000000..984923896b
--- /dev/null
+++ b/src/welcome/useful-plugins.php
@@ -0,0 +1,310 @@
+ array(
+ 'slug' => 'interactions',
+ 'full_slug' => 'interactions/interactions.php',
+ ),
+ 'cimo-image-optimizer' => array(
+ 'slug' => 'cimo-image-optimizer',
+ 'full_slug' => 'cimo-image-optimizer/cimo.php',
+ ),
+ );
+
+ function __construct() {
+ add_action( 'admin_init', array( $this, 'register_settings' ) );
+
+ // Register action on 'admin_menu' to ensure filters for the editor and admin settings
+ // are added early, before those scripts are enqueued and filters are applied.
+ add_action( 'admin_menu', array( $this, 'get_useful_plugins_info' ) );
+
+ // use WordPress ajax installer
+ // see Docs: https://developer.wordpress.org/reference/functions/wp_ajax_install_plugin/
+ add_action('wp_ajax_stackable_useful_plugins_activate', array( $this, 'do_plugin_activate' ) );
+ add_action('wp_ajax_stackable_useful_plugins_install', 'wp_ajax_install_plugin' );
+
+ // handler for polling the Cimo plugin's installation or activation status from the block editor
+ add_action('wp_ajax_stackable_check_cimo_status', array( $this, 'check_cimo_status' ) );
+
+ if ( is_admin() ) {
+ add_filter( 'stackable_localize_script', array( $this, 'localize_hide_cimo_notice' ) );
+ }
+ }
+
+ public function register_settings() {
+ register_setting(
+ 'stackable_editor_settings',
+ 'stackable_hide_cimo_notice',
+ array(
+ 'type' => 'boolean',
+ 'description' => __( 'Hides the Cimo download notice.', STACKABLE_I18N ),
+ 'sanitize_callback' => 'rest_sanitize_boolean',
+ 'show_in_rest' => true,
+ 'default' => false,
+ )
+ );
+ }
+
+ public static function is_plugin_installed( $plugin_slug ) {
+ if ( ! function_exists( 'get_plugins' ) ) {
+ include_once( ABSPATH . 'wp-admin/includes/plugin.php' );
+ }
+
+ $all_plugins = get_plugins();
+ if ( isset( $all_plugins[ $plugin_slug ] ) ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ public static function is_plugin_activated( $plugin_slug ) {
+ if ( ! function_exists( 'is_plugin_active' ) ) {
+ include_once( ABSPATH . 'wp-admin/includes/plugin.php' );
+ }
+
+ if ( is_plugin_active( $plugin_slug ) ) {
+ return true;
+ }
+
+ return false;
+ }
+
+
+ public function get_useful_plugins_info() {
+ $current_user_cap = current_user_can( 'install_plugins' ) ? 2 : (
+ current_user_can( 'activate_plugins') ? 1 : 0
+ );
+
+ if ( ! $current_user_cap ) {
+ return;
+ }
+
+ if ( ! function_exists( 'plugins_api' ) ) {
+ include_once( ABSPATH . 'wp-admin/includes/plugin-install.php' );
+ }
+ if ( ! function_exists( 'get_plugins' ) || ! function_exists( 'is_plugin_active' ) ) {
+ include_once( ABSPATH . 'wp-admin/includes/plugin.php' );
+ }
+
+ $all_plugins = get_plugins();
+ $data_to_localize = array();
+
+ foreach ( self::$PLUGINS as $key => $plugin ) {
+ $status = 'not_installed';
+
+ if ( isset( $all_plugins[ $plugin['full_slug'] ] ) ) {
+ $status = 'installed';
+ }
+
+ if ( is_plugin_active( $plugin['full_slug'] ) ) {
+ $status = 'activated';
+ }
+
+ $plugin_info = plugins_api( 'plugin_information', [
+ 'slug' => $plugin['slug'],
+ 'fields' =>[ 'icons' => true, 'sections' => false ],
+ ] );
+
+ $icon_url = '';
+ if ( ! is_wp_error( $plugin_info ) && isset( $plugin_info->icons )
+ && is_array( $plugin_info->icons ) && ! empty( $plugin_info->icons )
+ ) {
+ $icon_url = array_values( $plugin_info->icons )[0];
+ }
+
+ $data_to_localize[ $key ] = array(
+ 'status' => $status,
+ 'icon' => $icon_url,
+ 'fullSlug' => $plugin[ 'full_slug' ],
+ );
+ }
+
+ // Make Cimo available in the block editor
+ $this->add_cimo_args_to_localize_editor( $data_to_localize, $current_user_cap );
+ // Make all plugin data and the ajax url available in the admin settings
+ $this->add_args_to_localize_admin( $data_to_localize );
+ }
+
+ public function add_cimo_args_to_localize_editor( $data_to_localize, $current_user_cap ) {
+ $slug = 'cimo-image-optimizer';
+ $full_slug = self::$PLUGINS[ $slug ][ 'full_slug' ];
+
+ $cimo_data = $data_to_localize[ $slug ];
+ $cimo_data['nonce'] = wp_create_nonce( 'stackable_cimo_status' );
+ $action_link = '';
+
+ if ( $current_user_cap === 2 && $cimo_data[ 'status' ] === 'not_installed' ) {
+ $action_link = wp_nonce_url(
+ add_query_arg(
+ [
+ 'action' => 'install-plugin',
+ 'plugin' => $slug,
+ ],
+ admin_url( 'update.php' )
+ ),
+ 'install-plugin_' . $slug
+ );
+ } else if ( $current_user_cap >= 1 && $cimo_data[ 'status' ] === 'installed' ) {
+ $action_link = wp_nonce_url(
+ add_query_arg( [
+ 'action' => 'activate',
+ 'plugin' => $full_slug,
+ ], admin_url( 'plugins.php' ) ),
+ 'activate-plugin_' . $full_slug
+ );
+ }
+
+ $cimo_data[ 'action' ] = html_entity_decode( $action_link );
+
+ add_filter( 'stackable_localize_script', function ( $args ) use( $cimo_data ) {
+ return $this->add_localize_script( $args, 'cimo', $cimo_data );
+ }, 1 );
+
+ }
+
+ public function add_args_to_localize_admin( $data_to_localize ) {
+ $argsToAdd = array(
+ 'usefulPlugins' => $data_to_localize,
+ 'installerNonce' => wp_create_nonce( "updates" ),
+ 'activateNonce' => wp_create_nonce( "stk_activate_useful_plugin" ),
+ 'ajaxUrl' => admin_url('admin-ajax.php')
+ );
+
+ add_filter( 'stackable_localize_settings_script', function ( $args ) use( $argsToAdd ) {
+ return $this->add_localize_script( $args, '', $argsToAdd );
+ } );
+ }
+
+ public function add_localize_script( $args, $arg_key, $data ) {
+ // If an argument key is provided, save data under that key and return
+ if ( $arg_key ) {
+ $args[ $arg_key ] = $data;
+ return $args;
+ }
+
+ // Otherwise, add each key/value from $data to merge with $args
+ foreach ( $data as $key => $value ) {
+ $args[$key] = $value;
+ }
+
+ return $args;
+ }
+
+ // Adds the hide notice option for the Cimo plugin to the localized script arguments.
+ public function localize_hide_cimo_notice( $args ) {
+ $hide_cimo = get_option( 'stackable_hide_cimo_notice', false );
+ if ( isset( $args['cimo'] ) ) {
+ $args['cimo']['hideNotice'] = $hide_cimo;
+ return $args;
+ }
+
+ $args[ 'cimo' ] = array( 'hideNotice' => $hide_cimo );
+ return $args;
+ }
+
+ function do_plugin_activate() {
+ $slug = isset( $_POST['slug'] ) ? sanitize_text_field( $_POST['slug'] ) : '';
+ $full_slug = isset( $_POST['full_slug'] ) ? sanitize_text_field( $_POST['full_slug'] ) : '';
+ if ( ! $slug || ! $full_slug ) {
+ wp_send_json_error( array( 'status' => 'error', 'message' => __( 'Invalid slug.', STACKABLE_I18N ) ), 400 );
+ }
+
+ if ( ! check_ajax_referer( 'stk_activate_useful_plugin', 'nonce', false ) ) {
+ wp_send_json_error( array( 'status' => 'error', 'message' => __( 'Security check failed.', STACKABLE_I18N ) ), 403 );
+ return;
+ }
+
+ if ( ! current_user_can( 'activate_plugins' ) ) {
+ wp_send_json_error( array( 'status' => 'error', 'message' => __( 'Insufficient permissions.', STACKABLE_I18N ) ), 403 );
+ return;
+ }
+
+ // Clear the plugins cache to ensure newly installed plugins are recognized (avoids activation errors due to outdated plugin cache)
+ wp_clean_plugins_cache();
+
+ if ( ! function_exists( 'activate_plugin' ) ) {
+ include_once( ABSPATH . 'wp-admin/includes/plugin.php' );
+ }
+
+ $result = activate_plugin( $full_slug, '', false, true );
+
+ if ( is_wp_error( $result ) ) {
+ wp_send_json_error( array( 'status' => 'error', 'message' => __( 'Failed to activate plugin.', STACKABLE_I18N ) ), 500 );
+ return;
+ }
+
+ wp_send_json_success( array( 'status' => 'success', 'message' => __( 'Successfully activated plugin.', STACKABLE_I18N ) ), 200 );
+ }
+
+
+ /**
+ * Checks the status of the Cimo plugin installation or activation.
+ * Returns JSON indicating if Cimo is installed, installing, activated, or activating,
+ * and provides the respective action URL if activation is needed.
+ *
+ * Used for polling Cimo plugin status changes via AJAX in the admin UI.
+ */
+ function check_cimo_status() {
+ $slug = 'cimo-image-optimizer';
+ // Verify nonce
+ if ( ! check_ajax_referer( 'stackable_cimo_status', 'nonce', false ) ) {
+ wp_send_json_error( array( 'status' => 'error', 'message' => __( 'Security check failed.', STACKABLE_I18N ) ), 403 );
+ return;
+ }
+
+ $action = isset( $_POST['user_action'] ) ? sanitize_text_field( $_POST['user_action'] ) : '';
+ $response = array(
+ 'status' => 'activated',
+ 'action' => ''
+ );
+
+ if ( ! $action || ( $action !== 'install' && $action !== 'activate' ) ) {
+ wp_send_json_error( array( 'status' => 'error', 'message' => __( 'Invalid request action.', STACKABLE_I18N ) ), 400 );
+ return;
+ }
+
+ if ( ( $action === 'install' && ! current_user_can( 'install_plugins' ) ) ||
+ ( $action === 'activate' && ! current_user_can( 'activate_plugins' ) ) ) {
+ wp_send_json_error( array( 'status' => 'error', 'message' => __( 'Insufficient permissions.', STACKABLE_I18N ) ), 403 );
+ return;
+ }
+
+ $full_slug = self::$PLUGINS[ $slug ][ 'full_slug' ];
+
+ // Clear plugin cache to ensure we get the most current status
+ wp_clean_plugins_cache();
+
+ if ( $action === 'install' && ! self::is_plugin_installed( $full_slug ) ) {
+ $response[ 'status' ] = 'installing';
+ } else if ( ! self::is_plugin_activated( $full_slug ) ) {
+ $response[ 'status' ] = $action === 'install' ? 'installed' : 'activating';
+ $response[ 'action' ] = $action === 'install' ? html_entity_decode( wp_nonce_url(
+ add_query_arg(
+ [
+ 'action' => 'activate',
+ 'plugin' => $full_slug,
+ ],
+ admin_url( 'plugins.php' )
+ ),
+ 'activate-plugin_' . $full_slug
+ ) ) : '';
+ }
+
+ wp_send_json_success( $response );
+ }
+ }
+
+ new Stackable_Useful_Plugins();
+}
diff --git a/src/welcome/useful-plugins.scss b/src/welcome/useful-plugins.scss
new file mode 100644
index 0000000000..020e4cfe17
--- /dev/null
+++ b/src/welcome/useful-plugins.scss
@@ -0,0 +1,58 @@
+#s-useful-plugins {
+ max-width: 1200px;
+ margin: 60px auto;
+}
+
+.s-useful-plugin-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, 350px);
+ gap: 32px;
+ justify-content: center;
+
+ .s-card {
+ .s-plugin-title {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 12px;
+ .s-card-title {
+ margin: 0;
+ }
+ .s-plugin-icon {
+ width: 48px;
+ height: 48px;
+ }
+ }
+
+ .s-button.s-button--ghost {
+ margin: 24px 0 0;
+ padding: 0 20px;
+ color: var(--stk-welcome-primary);
+ box-shadow: none;
+ border: 1px solid var(--stk-welcome-primary);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ text-transform: none;
+ align-self: flex-start;
+
+ &:hover:not(:disabled) {
+ border-color: #b300be;
+ color: #b300be;
+ }
+ &:disabled {
+ cursor: not-allowed;
+ }
+ &.pending:disabled {
+ cursor: progress;
+ }
+ }
+
+ .s-spinner svg {
+ height: 12px;
+ width: 12px;
+ margin-top: 0;
+ }
+ }
+
+}