diff --git a/classes/controllers/FrmHooksController.php b/classes/controllers/FrmHooksController.php index d5383d9eb9..088d85c6ff 100644 --- a/classes/controllers/FrmHooksController.php +++ b/classes/controllers/FrmHooksController.php @@ -222,6 +222,10 @@ public static function load_admin_hooks() { FrmSMTPController::load_hooks(); FrmOnboardingWizardController::load_admin_hooks(); FrmAddonsController::load_admin_hooks(); + + // Plugin feedback (NPS survey). + FrmPluginFeedbackController::load_admin_hooks(); + new FrmPluginSearch(); } @@ -325,6 +329,10 @@ public static function load_ajax_hooks() { // Welcome Tour. add_action( 'wp_ajax_frm_mark_checklist_step_as_completed', 'FrmWelcomeTourController::ajax_mark_checklist_step_as_completed' ); add_action( 'wp_ajax_frm_dismiss_welcome_tour', 'FrmWelcomeTourController::ajax_dismiss_welcome_tour' ); + + // Plugin feedback. + add_action( 'wp_ajax_frm_submit_lite_plugin_feedback', 'FrmPluginFeedbackController::ajax_submit_plugin_feedback' ); + add_action( 'wp_ajax_frm_dismiss_lite_plugin_feedback', 'FrmPluginFeedbackController::ajax_dismiss_plugin_feedback' ); } /** diff --git a/classes/controllers/FrmPluginFeedbackController.php b/classes/controllers/FrmPluginFeedbackController.php new file mode 100644 index 0000000000..7fd7db30e3 --- /dev/null +++ b/classes/controllers/FrmPluginFeedbackController.php @@ -0,0 +1,469 @@ += $threshold_days * DAY_IN_SECONDS; + } + + /** + * @return array + */ + protected static function get_config() { + return array( + 'script' => array( + 'handle' => 'formidable-lite-plugin-feedback', + 'url' => FrmAppHelper::plugin_url() . '/js/plugin-feedback.js', + ), + 'style' => array( + 'handle' => 'formidable-lite-plugin-feedback', + 'url' => FrmAppHelper::plugin_url() . '/css/components/plugin-feedback.css', + ), + 'ajax' => array( + 'submit' => 'submit_lite_plugin_feedback', + 'dismiss' => 'dismiss_lite_plugin_feedback', + ), + 'remote' => 'https://formidableforms.com/wp-admin/admin-ajax.php?action=frm_forms_preview&form=plugin-feedback-lite', + 'remote_form_key' => 'plugin-feedback-lite', + ); + } + + /** + * @return void + */ + public static function enqueue_assets() { + $config = static::get_config(); + $version = FrmAppHelper::plugin_version(); + + wp_enqueue_script( $config['script']['handle'], $config['script']['url'], array( 'formidable_dom' ), $version, true ); + wp_enqueue_style( $config['style']['handle'], $config['style']['url'], array(), $version ); + } + + /** + * @return void + */ + public static function show_plugin_feedback() { + $current = static::get_current_year_feedback(); + $step = isset( $current['nps-score'] ) ? 'reasons' : 'nps'; + $reasons = static::get_reasons(); + $config = static::get_config(); + + include FrmAppHelper::plugin_path() . '/classes/views/shared/plugin-feedback.php'; + } + + /** + * @return void + */ + public static function ajax_submit_plugin_feedback() { + check_ajax_referer( 'frm_ajax', 'nonce' ); + FrmAppHelper::permission_check( 'frm_change_settings' ); + + if ( static::pro_is_blocking() ) { + wp_send_json_error( array( 'type' => 'pro-active' ) ); + } + + static::$user_id = get_current_user_id(); + + static::maybe_save_nps_and_send_response(); + static::submit_feedback_to_remote(); + } + + /** + * @return void + */ + public static function ajax_dismiss_plugin_feedback() { + check_ajax_referer( 'frm_ajax', 'nonce' ); + FrmAppHelper::permission_check( 'frm_change_settings' ); + + if ( static::pro_is_blocking() ) { + wp_send_json_error( array( 'type' => 'pro-active' ) ); + } + + static::$user_id = get_current_user_id(); + + static::submit_feedback_to_remote(); + } + + /** + * @return void + */ + protected static function maybe_save_nps_and_send_response() { + if ( ! isset( $_POST['nps-score'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + return; + } + + $nps_score = (int) FrmAppHelper::get_post_param( 'nps-score', 10, 'absint' ); + + if ( $nps_score < 0 || $nps_score > 10 ) { + wp_send_json_error( array( 'type' => 'invalid-nps' ) ); + } + + static::set_current_year_feedback( 'nps-score', $nps_score ); + wp_send_json_success( array( 'message' => __( 'Feedback score saved successfully.', 'formidable' ) ) ); + } + + /** + * @return void + */ + protected static function submit_feedback_to_remote() { + $current = static::get_current_year_feedback(); + + if ( ! isset( $current['nps-score'] ) ) { + static::set_current_year_feedback( 'submitted', true ); + static::after_submission_commit(); + wp_send_json_success( array( 'message' => __( 'Feedback dismissed successfully.', 'formidable' ) ) ); + } + + $remote_response = wp_remote_post( + static::get_config()['remote'], + array( + 'timeout' => 30, + 'body' => http_build_query( static::build_remote_body() ), + ) + ); + + if ( is_wp_error( $remote_response ) ) { + wp_send_json_error( + array( + 'type' => 'server-error', + 'message' => __( 'Failed to submit feedback to remote service.', 'formidable' ), + ) + ); + } + + $response_code = wp_remote_retrieve_response_code( $remote_response ); + + if ( WP_Http::OK !== $response_code ) { + wp_send_json_error( + array( + 'type' => 'server-error', + 'message' => __( 'Remote service returned an error.', 'formidable' ), + ) + ); + } + + static::set_current_year_feedback( 'submitted', true ); + static::after_submission_commit(); + wp_send_json_success( array( 'message' => __( 'Feedback submitted successfully.', 'formidable' ) ) ); + } + + /** + * @return array + */ + protected static function field_map() { + return array( + 'nps' => 'NPS', + 'reasons' => 'RSN', + 'details' => 'DTL', + 'url' => 'URL', + 'source' => 'SRC', + 'version' => 'VER', + ); + } + + /** + * @return array + */ + protected static function build_remote_body() { + $map = static::field_map(); + $config = static::get_config(); + $feedback = static::get_current_year_feedback(); + + $body = array( + 'l' => base64_encode( (string) static::get_remote_identifier() ), + 'form_key' => isset( $config['remote_form_key'] ) ? $config['remote_form_key'] : '', + 'frm_action' => 'create', + 'form_id' => static::get_remote_form_id(), + 'item_key' => '', + 'item_meta[0]' => '', + ); + + $values = array( + 'nps' => isset( $feedback['nps-score'] ) ? $feedback['nps-score'] : '', + 'reasons' => static::format_reasons_list( static::get_posted_reasons() ), + 'details' => FrmAppHelper::get_post_param( 'details', '' ), + 'url' => site_url(), + 'source' => static::SOURCE, + 'version' => FrmAppHelper::plugin_version(), + ); + + foreach ( $values as $key => $value ) { + if ( ! isset( $map[ $key ] ) ) { + continue; + } + + $body[ 'item_meta[' . $map[ $key ] . ']' ] = $value; + } + + return $body; + } + + /** + * @return string + */ + protected static function get_remote_identifier() { + return (string) get_option( 'frm-usage-uuid' ); + } + + /** + * @return int + */ + protected static function get_remote_form_id() { + return 0; + } + + /** + * @return void + */ + protected static function after_submission_commit() { + } + + /** + * @return array + */ + protected static function get_posted_reasons() { + $reasons = json_decode( FrmAppHelper::get_post_param( 'reasons', '[]' ), true ); + $reasons = rest_sanitize_value_from_schema( + $reasons, + array( + 'type' => 'array', + 'items' => array( + 'enum' => array_keys( static::get_reasons() ), + 'type' => 'string', + ), + ) + ); + + if ( ! $reasons && ! FrmAppHelper::get_post_param( 'dismissed', false, 'rest_sanitize_boolean' ) ) { + wp_send_json_error( array( 'type' => 'invalid-reasons' ) ); + } + + return $reasons; + } + + /** + * @param array $reason_keys + * @return string + */ + protected static function format_reasons_list( $reason_keys ) { + if ( ! $reason_keys ) { + return ''; + } + + $reasons = static::get_reasons(); + $formatted_reasons = array_map( + static function ( $key ) use ( $reasons ) { + return '- ' . $reasons[ $key ]; + }, + $reason_keys + ); + + return implode( "\n", $formatted_reasons ); + } + + /** + * @return array + */ + protected static function get_plugin_feedback() { + if ( static::$plugin_feedback ) { + return static::$plugin_feedback; + } + + static::$plugin_feedback = get_user_meta( static::$user_id, static::PLUGIN_FEEDBACK_META_KEY, true ); + + if ( ! is_array( static::$plugin_feedback ) ) { + static::$plugin_feedback = array( + static::get_current_year() => array( + 'submitted' => false, + 'source' => static::SOURCE, + ), + ); + } elseif ( ! isset( static::$plugin_feedback[ static::get_current_year() ] ) ) { + static::$plugin_feedback[ static::get_current_year() ] = array( + 'submitted' => false, + 'source' => static::SOURCE, + ); + } + + return static::$plugin_feedback; + } + + /** + * @return array + */ + protected static function get_current_year_feedback() { + return static::get_plugin_feedback()[ static::get_current_year() ]; + } + + /** + * @param string $key + * @param mixed $value + * @return void + */ + protected static function set_current_year_feedback( $key, $value ) { + static::get_plugin_feedback(); + static::$plugin_feedback[ static::get_current_year() ][ $key ] = $value; + static::$plugin_feedback[ static::get_current_year() ]['source'] = static::SOURCE; + update_user_meta( static::$user_id, static::PLUGIN_FEEDBACK_META_KEY, static::$plugin_feedback ); + } + + /** + * @return int + */ + protected static function get_current_year() { + if ( static::$current_year ) { + return static::$current_year; + } + + static::$current_year = (int) wp_date( 'Y' ); + return static::$current_year; + } + + /** + * Not translatable: sent to a remote service. + * + * @return array + */ + protected static function get_reasons() { + return array( + 'pricing' => 'Pricing and plans', + 'form-builder' => 'Form builder flexibility', + 'customization' => 'Customization options', + 'integrations' => 'Integrations', + 'advanced-fields' => 'Advanced fields', + 'customer-support' => 'Customer support', + 'templates' => 'Template selection', + 'performance' => 'Performance/Speed', + 'calculations' => 'Calculations & formulas', + 'documentation' => 'Documentation / tutorials', + ); + } +} diff --git a/classes/helpers/FrmHtmlHelper.php b/classes/helpers/FrmHtmlHelper.php index f47d42fcff..f7b2cde684 100644 --- a/classes/helpers/FrmHtmlHelper.php +++ b/classes/helpers/FrmHtmlHelper.php @@ -131,4 +131,30 @@ public static function echo_unit_input( $args = array() ) { '', + 'class' => '', + 'name' => 'nps_score', + 'value' => '0', + 'negative_statement' => __( 'Not satisfied', 'formidable' ), + 'positive_statement' => __( 'Very satisfied', 'formidable' ), + ); + $args = wp_parse_args( $args, $defaults ); + + include FrmAppHelper::plugin_path() . '/classes/views/shared/nps.php'; + } } diff --git a/classes/views/shared/nps.php b/classes/views/shared/nps.php new file mode 100644 index 0000000000..f495ea35d5 --- /dev/null +++ b/classes/views/shared/nps.php @@ -0,0 +1,65 @@ + 'frm-nps frm-flex-col frm-gap-xs', +); + +if ( ! empty( $args['id'] ) ) { + $nps_attrs['id'] = $args['id']; +} + +if ( ! empty( $args['class'] ) ) { + $nps_attrs['class'] .= ' ' . $args['class']; +} + +$input_attrs = array( + 'type' => 'radio', + 'name' => $args['name'], + 'class' => 'frm_hidden', +); +?> +
> +
+ + /> + + +
+ +
+
+ ' . esc_html( $args['negative_statement'] ) . '' + ); + ?> +
+ +
+ ' . esc_html( $args['positive_statement'] ) . '' + ); + ?> +
+
+
diff --git a/classes/views/shared/plugin-feedback.php b/classes/views/shared/plugin-feedback.php new file mode 100644 index 0000000000..11a681272b --- /dev/null +++ b/classes/views/shared/plugin-feedback.php @@ -0,0 +1,74 @@ + +
+ + + + +
+
+

+

+ 'frm-plugin-feedback-nps', + 'class' => 'frm-mt-xs', + 'name' => 'plugin-feedback-nps-score', + 'value' => '10', + ) + ); + ?> +
+ +
+

+
+ $label ) { ?> + + +
+
+ + +
+
+ + 'frm-plugin-feedback-error', + 'errors' => array( + 'invalid-nps' => __( 'NPS score is invalid.', 'formidable' ), + 'invalid-reasons' => __( 'Please select at least one reason.', 'formidable' ), + 'server-error' => __( 'Failed to submit feedback, try again later.', 'formidable' ), + ), + 'class' => 'frm-items-center', + ) + ); + ?> + + +
+ +
+

+

+
+
diff --git a/css/components/nps.css b/css/components/nps.css new file mode 100644 index 0000000000..32cb4a3d6a --- /dev/null +++ b/css/components/nps.css @@ -0,0 +1,33 @@ +.frm-nps__buttons { + flex-wrap: wrap; + gap: 6px; +} + +.frm-nps__button { + flex: 0 0 auto; + width: 44px; + height: 44px; + font-size: var(--text-sm); + color: var(--grey-900); + border: 1px solid var(--grey-300); + border-radius: var(--small-radius); + cursor: pointer; + transition: background-color 150ms ease-out, + border-color 150ms ease-out, + color 150ms ease-out; +} + +.frm-nps__button:hover, +.frm-nps__button:focus, +.frm-nps__buttons input[type="radio"]:focus + .frm-nps__button, +.frm-nps__buttons input[type="radio"]:checked + .frm-nps__button, +.frm-nps__button.frm-nps__button--active { + background-color: var(--primary-500); + border-color: var(--primary-500); + color: #fff; +} + +.frm-nps__statements { + font-size: var(--text-xs); + color: var(--grey-500); +} diff --git a/css/components/plugin-feedback.css b/css/components/plugin-feedback.css new file mode 100644 index 0000000000..00e9879550 --- /dev/null +++ b/css/components/plugin-feedback.css @@ -0,0 +1,71 @@ +#frm-plugin-feedback, +#frm-plugin-feedback * { + box-sizing: border-box; +} + +#frm-plugin-feedback { + position: fixed; + bottom: var(--gap-md); + right: var(--gap-md); + width: 100%; + max-width: 548px; + background-color: #fff; + border: 1px solid var(--grey-100); + border-radius: var(--medium-radius); + padding: var(--gap-xl) var(--gap-md) var(--gap-md); + box-shadow: var(--box-shadow-xl); + z-index: 9999; +} + +#frm-plugin-feedback[data-step="nps"] { + max-width: 594px; +} + +#frm-plugin-feedback:not([data-step="thank-you"]) p { + color: var(--grey-900) !important; + line-height: 1 !important; +} + +#frm-plugin-feedback .dismiss { + top: var(--gap-sm); + right: var(--gap-sm); +} + +#frm-plugin-feedback .frm-validation-error { + margin: 0; +} + +#frm-plugin-feedback .frm-validation-error[frm-error="invalid-nps"] span[frm-error="invalid-nps"], +#frm-plugin-feedback .frm-validation-error[frm-error="invalid-reasons"] span[frm-error="invalid-reasons"], +#frm-plugin-feedback .frm-validation-error[frm-error="server-error"] span[frm-error="server-error"] { + display: inline-block; +} + +/* Steps */ +#frm-plugin-feedback-reasons-step .frm_grid_container { + gap: var(--gap-xs); +} + +#frm-plugin-feedback-reasons-step .frm-option-box { + justify-content: flex-start; + gap: var(--gap-xs); + color: var(--grey-900); + padding: var(--gap-sm) 12px; + background-color: var(--grey-100); + border-color: var(--grey-100); +} + +#frm-plugin-feedback-reasons-step .frm-option-box:hover { + background-color: var(--grey-200); + border-color: var(--grey-200); +} + +#frm-plugin-feedback-reasons-step .frm-option-box.frm-checked { + background-color: var(--primary-50); + border-color: var(--primary-500); +} + +#frm-plugin-feedback-details { + resize: none; + height: 90px; +} diff --git a/js/src/plugin-feedback/index.js b/js/src/plugin-feedback/index.js new file mode 100644 index 0000000000..bf8809cf3e --- /dev/null +++ b/js/src/plugin-feedback/index.js @@ -0,0 +1,15 @@ +/** + * WordPress dependencies + */ +import domReady from '@wordpress/dom-ready'; + +/** + * Internal dependencies + */ +import { addOptionBoxEvents } from 'core/events'; +import addSubmitFeedbackEventListeners from './submitFeedbackEvents'; + +domReady( () => { + addSubmitFeedbackEventListeners(); + addOptionBoxEvents(); +} ); diff --git a/js/src/plugin-feedback/submitFeedbackEvents.js b/js/src/plugin-feedback/submitFeedbackEvents.js new file mode 100644 index 0000000000..15c5c68e84 --- /dev/null +++ b/js/src/plugin-feedback/submitFeedbackEvents.js @@ -0,0 +1,125 @@ +/** + * Internal dependencies + */ +import { hideError, showError } from './utils'; + +const { doJsonPost } = frmDom.ajax; + +const HIDDEN_CLASS = 'frm_hidden'; +const CLASS_PREFIX = 'frm-plugin-feedback'; +const LOADING_CLASS = 'frm_loading_button'; + +const pluginFeedback = document.getElementById( CLASS_PREFIX ); +const form = document.getElementById( `${ CLASS_PREFIX }-form` ); +const submitButton = form?.querySelector( 'button[type="submit"]' ); +const npsStep = document.getElementById( `${ CLASS_PREFIX }-nps-step` ); +const reasonsStep = document.getElementById( `${ CLASS_PREFIX }-reasons-step` ); +const thankYouStep = document.getElementById( `${ CLASS_PREFIX }-thank-you-step` ); + +const submitAction = pluginFeedback?.dataset.submitAction; +const dismissAction = pluginFeedback?.dataset.dismissAction; + +/** + * Adds event listeners for submitting plugin feedback. + * + * @private + * @return {void} + */ +function addSubmitFeedbackEventListeners() { + if ( ! pluginFeedback || ! form ) { + return; + } + + form.addEventListener( 'submit', onSubmitFeedback ); + pluginFeedback.querySelector( '.dismiss' )?.addEventListener( 'click', onDismissFeedback ); +} + +/** + * Handles form submission for plugin feedback. + * + * @private + * @param {Event} event The form submit event. + * @return {void} + */ +async function onSubmitFeedback( event ) { + event.preventDefault(); + + submitButton.classList.add( LOADING_CLASS ); + + const step = pluginFeedback.dataset.step; + const formData = new FormData(); + + if ( 'nps' === step ) { + const npsScore = form.querySelector( 'input[name="plugin-feedback-nps-score"]:checked' ); + formData.append( 'nps-score', npsScore?.value ); + } else { + const reasons = form.querySelectorAll( 'input[name="plugin-feedback-reasons"]:checked' ); + formData.append( 'reasons', JSON.stringify( Array.from( reasons ).map( ( reason ) => reason.value ) ) ); + formData.append( 'details', form.querySelector( 'textarea[name="plugin-feedback-details"]' )?.value ); + } + + try { + await doJsonPost( submitAction, formData ); + } catch ( error ) { + showError( error.type ); + if ( error.message ) { + console.error( 'Feedback submission error:', error.message ); + } + return; + } finally { + submitButton.classList.remove( LOADING_CLASS ); + } + + hideError(); + updateFeedbackStep( step ); +} + +/** + * Updates the feedback step and shows/hides appropriate step elements. + * + * @private + * @param {string} step The current step ('nps' or 'reasons'). + * @return {void} + */ +function updateFeedbackStep( step ) { + if ( 'nps' === step ) { + pluginFeedback.dataset.step = 'reasons'; + npsStep.classList.add( HIDDEN_CLASS ); + reasonsStep.classList.remove( HIDDEN_CLASS ); + } else { + pluginFeedback.dataset.step = 'thank-you'; + reasonsStep.classList.add( HIDDEN_CLASS ); + form.classList.add( HIDDEN_CLASS ); + thankYouStep.classList.remove( HIDDEN_CLASS ); + } +} + +/** + * Handles dismiss button click. + * + * @private + * @param {Event} event The click event. + * @return {void} + */ +async function onDismissFeedback( event ) { + event.preventDefault(); + + pluginFeedback.remove(); + + if ( 'thank-you' === pluginFeedback.dataset.step ) { + return; + } + + const formData = new FormData(); + formData.append( 'dismissed', true ); + + try { + await doJsonPost( dismissAction, formData ); + } catch ( error ) { + if ( error.message ) { + console.error( 'Feedback submission error:', error.message ); + } + } +} + +export default addSubmitFeedbackEventListeners; diff --git a/js/src/plugin-feedback/utils.js b/js/src/plugin-feedback/utils.js new file mode 100644 index 0000000000..bee35d70ef --- /dev/null +++ b/js/src/plugin-feedback/utils.js @@ -0,0 +1,21 @@ +const error = document.getElementById( 'frm-plugin-feedback-error' ); + +/** + * Shows an error message for a form field. + * + * @param {string} type The categorization of the error (e.g., "invalid", "empty"). + * @return {void} + */ +export function showError( type ) { + error.setAttribute( 'frm-error', type ); + error.classList.remove( 'frm_hidden' ); +} + +/** + * Hides the error message. + * + * @return {void} + */ +export function hideError() { + error.classList.add( 'frm_hidden' ); +} diff --git a/webpack.config.js b/webpack.config.js index 0ada33e408..29beda4a5e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -49,6 +49,7 @@ const entries = { 'formidable-settings-components': './js/src/settings-components/index.js', 'formidable-web-components': './js/src/web-components/index.js', 'welcome-tour': './js/src/welcome-tour', + 'plugin-feedback': './js/src/plugin-feedback/index.js', }, // SCSS entries scss: {