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', +); +?> +