diff --git a/plugin.php b/plugin.php index c97bb04c42..e3a2fc038d 100644 --- a/plugin.php +++ b/plugin.php @@ -288,6 +288,7 @@ function is_frontend() { require_once( plugin_dir_path( __FILE__ ) . 'src/plugins/global-settings/block-styles/index.php' ); require_once( plugin_dir_path( __FILE__ ) . 'src/css-optimize.php' ); require_once( plugin_dir_path( __FILE__ ) . 'src/compatibility/index.php' ); + if ( ! is_admin() ) { require_once( plugin_dir_path( __FILE__ ) . 'src/lightbox/index.php' ); require_once( plugin_dir_path( __FILE__ ) . 'src/block/accordion/index.php' ); @@ -314,6 +315,7 @@ function is_frontend() { */ require_once( plugin_dir_path( __FILE__ ) . 'src/welcome/getting-started.php' ); if ( is_admin() ) { + require_once( plugin_dir_path( __FILE__ ) . 'src/welcome/useful-plugins.php' ); // For cross-marketing require_once( plugin_dir_path( __FILE__ ) . 'src/welcome/index.php' ); require_once( plugin_dir_path( __FILE__ ) . 'src/welcome/news.php' ); require_once( plugin_dir_path( __FILE__ ) . 'src/welcome/freemius.php' ); diff --git a/src/components/image-control2/index.js b/src/components/image-control2/index.js index 1203522c3a..b2866c9c21 100644 --- a/src/components/image-control2/index.js +++ b/src/components/image-control2/index.js @@ -11,7 +11,7 @@ import Button from '../button' * External dependencies */ import classnames from 'classnames' -import { i18n } from 'stackable' +import { i18n, cimo } from 'stackable' import { useAttributeName, useBlockAttributesContext, useBlockSetAttributesContext, } from '~stackable/hooks' @@ -20,8 +20,11 @@ import { * WordPress dependencies */ import { __ } from '@wordpress/i18n' -import { Fragment, memo } from '@wordpress/element' +import { + Fragment, memo, useEffect, useState, +} from '@wordpress/element' import { MediaUpload } from '@wordpress/block-editor' +import { currentUserHasCapability } from '~stackable/util' const ImageControl = memo( props => { const attrNameId = useAttributeName( `${ props.attribute }Id`, props.responsive, props.hover ) @@ -81,7 +84,37 @@ const ImageControl = memo( props => { } ) } - return ( + const [ CimoDownloadNotice, setCimoDownloadNotice ] = useState( null ) + + useEffect( () => { + // Skip displaying the Cimo notice if the plugin is already activated or the user has chosen to hide the notice + if ( ! cimo || cimo.hideNotice || cimo.status === 'activated' ) { + return + } + + const userCanInstall = currentUserHasCapability( 'install_plugins' ) + const userCanActivate = currentUserHasCapability( 'activate_plugins' ) + // Show the Cimo notice only if the user has permissions to install or activate plugins + if ( ( cimo.status === 'not_installed' && userCanInstall ) || ( cimo.status === 'installed' && userCanActivate ) ) { + const loadNotice = async () => { + try { + // Import the Cimo notice component with explicit chunk naming + const { default: CimoNoticeComponent } = await import( + /* webpackChunkName: "cimo-download-notice" */ + /* webpackMode: "lazy" */ + '../../lazy-components/cimo' + ) + setCimoDownloadNotice( () => CimoNoticeComponent ) + } catch ( err ) { + // eslint-disable-next-line no-console + console.error( 'Failed to load Cimo download notice component:', err ) + } + } + loadNotice() + } + }, [] ) + + return ( <> { /> ) } { type === 'image' && ( - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions { hasPanelModifiedIndicator={ props.hasPanelModifiedIndicator } /> + { CimoDownloadNotice && 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() { + + + + + +
+
+ print_header() ?> + print_premium_button() ?> + 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; + } + } + +}