| @@ -8,9 +8,12 @@ | ||
| 8 | 8 | namespace SRFM\Admin; |
| 9 | 9 | |
| 10 | 10 | use Astra_Notices; |
| 11 | 11 | use SRFM\Inc\AI_Form_Builder\AI_Helper; |
| 12 | +use SRFM\Inc\Client_Logger; | |
| 13 | +use SRFM\Inc\Database\Register; | |
| 12 | 14 | use SRFM\Inc\Database\Tables\Entries; |
| 15 | +use SRFM\Inc\Generate_Form_Markup; | |
| 13 | 16 | use SRFM\Inc\Global_Settings\Global_Settings; |
| 14 | 17 | use SRFM\Inc\Helper; |
| 15 | 18 | use SRFM\Inc\Onboarding; |
| 16 | 19 | use SRFM\Inc\Payments\Payment_Helper; |
| @@ -74,8 +77,42 @@ | ||
| 74 | 77 | */ |
| 75 | 78 | public const QUILL_1X_INLINE_CSS = '.ql-editor ul,.ql-editor ol{padding-left:1.5em}.ql-editor ul>li,.ql-editor ol>li{list-style-type:none}.ql-editor ol li:not(.ql-direction-rtl),.ql-editor ul li:not(.ql-direction-rtl){padding-left:1.5em}.ql-editor ol li.ql-direction-rtl,.ql-editor ul li.ql-direction-rtl{padding-right:1.5em}.ql-editor ul>li::before{content:"\2022"}.ql-editor li::before{display:inline-block;white-space:nowrap;width:1.2em}.ql-editor li:not(.ql-direction-rtl)::before{margin-left:-1.5em;margin-right:.3em;text-align:right}.ql-editor li.ql-direction-rtl::before{margin-left:.3em;margin-right:-1.5em}.ql-editor ol li{counter-reset:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;counter-increment:list-0}.ql-editor ol li::before{content:counter(list-0,decimal) ". "}.ql-editor ol li.ql-indent-1{counter-increment:list-1;counter-reset:list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-1::before{content:counter(list-1,lower-alpha) ". "}.ql-editor ol li.ql-indent-2{counter-increment:list-2;counter-reset:list-3 list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-2::before{content:counter(list-2,lower-roman) ". "}.ql-editor ol li.ql-indent-3{counter-increment:list-3;counter-reset:list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-3::before{content:counter(list-3,decimal) ". "}.ql-editor ol li.ql-indent-4{counter-increment:list-4;counter-reset:list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-4::before{content:counter(list-4,lower-alpha) ". "}.ql-editor ol li.ql-indent-5{counter-increment:list-5;counter-reset:list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-5::before{content:counter(list-5,lower-roman) ". "}.ql-editor ol li.ql-indent-6{counter-increment:list-6;counter-reset:list-7 list-8 list-9}.ql-editor ol li.ql-indent-6::before{content:counter(list-6,decimal) ". "}.ql-editor ol li.ql-indent-7{counter-increment:list-7;counter-reset:list-8 list-9}.ql-editor ol li.ql-indent-7::before{content:counter(list-7,lower-alpha) ". "}.ql-editor ol li.ql-indent-8{counter-increment:list-8;counter-reset:list-9}.ql-editor ol li.ql-indent-8::before{content:counter(list-8,lower-roman) ". "}.ql-editor ol li.ql-indent-9{counter-increment:list-9}.ql-editor ol li.ql-indent-9::before{content:counter(list-9,decimal) ". "}'; |
| 76 | 79 | |
| 77 | 80 | /** |
| 81 | + * Notice id for the "Finish setting up" Thank You prompt (#3030). | |
| 82 | + * | |
| 83 | + * A single stable id (not per-form): keeps both the autoloaded | |
| 84 | + * `allowed_astra_notices` option and the per-user dismissal meta bounded to one | |
| 85 | + * row, and lets a dismissed user short-circuit before the query runs. | |
| 86 | + * | |
| 87 | + * @since 2.12.6 | |
| 88 | + */ | |
| 89 | + public const THANKYOU_PROMPT_NOTICE_ID = 'srfm-thankyou-prompt'; | |
| 90 | + | |
| 91 | + /** | |
| 92 | + * Where the Contact Support button writes to. | |
| 93 | + * | |
| 94 | + * An inbox rather than a form, restoring the 2.12.6 behaviour. A mailto: opens | |
| 95 | + * the composer the person already has open with the subject and the whole | |
| 96 | + * report in the body, so reporting a fault is one click and a send. The | |
| 97 | + * troubleshooting form could carry neither, which is why 2.12.7 had to gate the | |
| 98 | + * button behind copying the diagnostics by hand first. | |
| 99 | + * | |
| 100 | + * @since 2.12.8 | |
| 101 | + */ | |
| 102 | + private const SUPPORT_EMAIL = '[email protected]'; | |
| 103 | + | |
| 104 | + /** | |
| 105 | + * Longest Contact Support mailto: URL we hand to a mail client. | |
| 106 | + * | |
| 107 | + * Below the roughly 2000-character limit the strictest common clients and | |
| 108 | + * browsers apply to a link, with room to spare. | |
| 109 | + * | |
| 110 | + * @since 2.12.8 | |
| 111 | + */ | |
| 112 | + private const SUPPORT_MAILTO_MAX_LENGTH = 1800; | |
| 113 | + | |
| 114 | + /** | |
| 78 | 115 | * Dashboard widget entries data. |
| 79 | 116 | * |
| 80 | 117 | * @var array |
| 81 | 118 | * @since 1.9.1 |
| @@ -124,8 +161,25 @@ | ||
| 124 | 161 | */ |
| 125 | 162 | private static $setup_card_cache = []; |
| 126 | 163 | |
| 127 | 164 | /** |
| 165 | + * Action items for this request, or null before the first build. | |
| 166 | + * | |
| 167 | + * Built twice on every admin page without this -- once for the localisation | |
| 168 | + * payload, once in the classic renderer -- and each open failure category reads | |
| 169 | + * a log excerpt. get_action_items() also records an impression, which running | |
| 170 | + * twice counted twice. | |
| 171 | + * | |
| 172 | + * Reset with reset_action_items_cache(). Admin is a singleton, so without that | |
| 173 | + * the first build pins the answer for the whole process and any test that | |
| 174 | + * records a failure and then asks again is testing the memo. | |
| 175 | + * | |
| 176 | + * @var array<int,array<string,mixed>>|null | |
| 177 | + * @since 2.12.7 | |
| 178 | + */ | |
| 179 | + private static $action_items_cache = null; | |
| 180 | + | |
| 181 | + /** | |
| 128 | 182 | * Class constructor. |
| 129 | 183 | * |
| 130 | 184 | * @return void |
| 131 | 185 | * @since 0.0.1 |
| @@ -135,9 +189,11 @@ | ||
| 135 | 189 | add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] ); |
| 136 | 190 | add_action( 'admin_menu', [ $this, 'settings_page' ] ); |
| 137 | 191 | add_action( 'admin_menu', [ $this, 'add_learn_page' ] ); |
| 138 | 192 | add_action( 'admin_menu', [ $this, 'add_new_form' ] ); |
| 139 | - add_action( 'admin_menu', [ $this, 'add_suremail_page' ] ); | |
| 193 | + if ( ! Helper::hide_promotions() ) { | |
| 194 | + add_action( 'admin_menu', [ $this, 'add_suremail_page' ] ); | |
| 195 | + } | |
| 140 | 196 | if ( ! Helper::has_pro() ) { |
| 141 | 197 | add_action( 'admin_menu', [ $this, 'add_quiz_page' ] ); |
| 142 | 198 | add_action( 'admin_menu', [ $this, 'add_survey_reports_page' ] ); |
| 143 | 199 | add_action( 'admin_menu', [ $this, 'add_partial_entries_page' ] ); |
| @@ -155,8 +211,19 @@ | ||
| 155 | 211 | |
| 156 | 212 | add_action( 'current_screen', [ $this, 'enable_gutenberg_for_sureforms' ], 100 ); |
| 157 | 213 | // Register notices early for React pages (before admin_enqueue_scripts). |
| 158 | 214 | add_action( 'admin_init', [ $this, 'register_pro_compatibility_notices' ], 5 ); |
| 215 | + | |
| 216 | + // Database maintenance notice: the entries table is missing, so submissions | |
| 217 | + // cannot be saved. Registered at admin_init priority 5 so the React notice is | |
| 218 | + // in place before admin_enqueue_scripts localizes it. | |
| 219 | + add_action( 'admin_init', [ $this, 'register_database_repair_notice' ], 5 ); | |
| 220 | + add_action( 'admin_notices', [ $this, 'render_action_item_notices' ] ); | |
| 221 | + // Late priority so the items are built after anything hooking | |
| 222 | + // srfm_action_items has had a chance to register. | |
| 223 | + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_action_item_styles' ], 20 ); | |
| 224 | + add_action( 'admin_notices', [ $this, 'render_database_repair_notice' ] ); | |
| 225 | + add_action( 'admin_post_srfm_repair_entries_table', [ $this, 'handle_database_repair' ] ); | |
| 159 | 226 | // Display notices on traditional WordPress admin pages. |
| 160 | 227 | add_action( 'admin_notices', [ $this, 'srfm_pro_version_compatibility' ] ); |
| 161 | 228 | |
| 162 | 229 | // Enfold theme compatibility to enable block editor for SureForms post type. |
| @@ -179,9 +246,13 @@ | ||
| 179 | 246 | add_action( 'wp_ajax_should_show_pointer', [ $this, 'pointer_should_show' ] ); |
| 180 | 247 | add_action( 'wp_ajax_sureforms_dismiss_pointer', [ $this, 'pointer_dismissed' ] ); |
| 181 | 248 | add_action( 'wp_ajax_sureforms_accept_cta', [ $this, 'pointer_accepted_cta' ] ); |
| 182 | 249 | add_action( 'wp_ajax_srfm_notice_response', [ $this, 'handle_notice_response' ] ); |
| 250 | + add_action( 'wp_ajax_srfm_dismiss_action_item', [ $this, 'handle_dismiss_action_item' ] ); | |
| 251 | + add_action( 'admin_post_srfm_dismiss_action_item_link', [ $this, 'handle_dismiss_action_item_link' ] ); | |
| 183 | 252 | add_action( 'wp_ajax_srfm_ai_widget_usage', [ $this, 'track_ai_widget_usage' ] ); |
| 253 | + add_action( 'load-post.php', [ $this, 'maybe_track_edit_form_button_click' ] ); | |
| 254 | + add_filter( 'removable_query_args', [ $this, 'add_removable_query_args' ] ); | |
| 184 | 255 | |
| 185 | 256 | // Register dashboard widget only if there are recent entries. |
| 186 | 257 | add_action( 'admin_init', [ $this, 'maybe_register_dashboard_widget' ] ); |
| 187 | 258 | |
| @@ -421,8 +492,22 @@ | ||
| 421 | 492 | return self::$thankyou_prompt_cache; |
| 422 | 493 | } |
| 423 | 494 | |
| 424 | 495 | /** |
| 496 | + * Clear the request memo for the action items. | |
| 497 | + * | |
| 498 | + * Admin is a singleton, so the memo outlives a request in a test process. | |
| 499 | + * Anything that records or clears a failure inside one process has to call | |
| 500 | + * this, or it reads the answer from before the change. | |
| 501 | + * | |
| 502 | + * @since 2.12.7 | |
| 503 | + * @return void | |
| 504 | + */ | |
| 505 | + public static function reset_action_items_cache() { | |
| 506 | + self::$action_items_cache = null; | |
| 507 | + } | |
| 508 | + | |
| 509 | + /** | |
| 425 | 510 | * Clear the request memo for the Thank You prompt (#3030). |
| 426 | 511 | * |
| 427 | 512 | * Lets tests exercise the memoized public path, and is a safe hook for anything |
| 428 | 513 | * that changes which form qualifies (e.g. a form save). |
| @@ -555,9 +640,9 @@ | ||
| 555 | 640 | * @since 2.12.4 |
| 556 | 641 | * @return void |
| 557 | 642 | */ |
| 558 | 643 | public function register_form_setup_widget() { |
| 559 | - if ( ! Helper::current_user_can() ) { | |
| 644 | + if ( ! Helper::current_user_can() || Helper::hide_promotions() ) { | |
| 560 | 645 | return; |
| 561 | 646 | } |
| 562 | 647 | |
| 563 | 648 | if ( null === self::get_form_setup_card() ) { |
| @@ -662,9 +747,9 @@ | ||
| 662 | 747 | * @since 2.12.4 |
| 663 | 748 | * @return void |
| 664 | 749 | */ |
| 665 | 750 | public function enqueue_form_setup_widget_assets( $hook_suffix ) { |
| 666 | - if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) { | |
| 751 | + if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() || Helper::hide_promotions() ) { | |
| 667 | 752 | return; |
| 668 | 753 | } |
| 669 | 754 | |
| 670 | 755 | $card = self::get_form_setup_card(); |
| @@ -753,14 +838,19 @@ | ||
| 753 | 838 | * step (default Thank You message, or no reply destination). Uses a single |
| 754 | 839 | * stable notice id so the library's built-in ✕ dismissal is one persistent |
| 755 | 840 | * choice ("stop nudging me"), not a per-form row. |
| 756 | 841 | * |
| 757 | - * @since 2.12.4 | |
| 758 | - * @return void | |
| 842 | + * Split out from the renderer so the decision has exactly one home. The Getting | |
| 843 | + * Started notice suppresses itself when this returns a form, and duplicating the | |
| 844 | + * conditions there would have meant two copies drifting apart. Reading it costs | |
| 845 | + * nothing extra — get_thankyou_prompt_forms() memoizes its query per request. | |
| 846 | + * | |
| 847 | + * @since 2.12.6 | |
| 848 | + * @return array<string,mixed>|null The form to prompt for, or null when no prompt should render. | |
| 759 | 849 | */ |
| 760 | - public function render_thankyou_prompt_notice() { | |
| 850 | + public function get_displayable_thankyou_prompt() { | |
| 761 | 851 | if ( ! Helper::current_user_can() || ! class_exists( 'Astra_Notices' ) ) { |
| 762 | - return; | |
| 852 | + return null; | |
| 763 | 853 | } |
| 764 | 854 | |
| 765 | 855 | /** |
| 766 | 856 | * Short-circuit the "Finish setting up" Thank You notice. |
| @@ -769,9 +859,9 @@ | ||
| 769 | 859 | * |
| 770 | 860 | * @since 2.12.4 |
| 771 | 861 | */ |
| 772 | 862 | if ( ! apply_filters( 'srfm_show_thankyou_prompt', true ) ) { |
| 773 | - return; | |
| 863 | + return null; | |
| 774 | 864 | } |
| 775 | 865 | |
| 776 | 866 | // Everywhere in wp-admin except the main dashboard. A null screen fails |
| 777 | 867 | // closed (return) rather than registering the notice on an unknown screen. |
| @@ -777,21 +867,16 @@ | ||
| 777 | 867 | // closed (return) rather than registering the notice on an unknown screen. |
| 778 | 868 | $screen = get_current_screen(); |
| 779 | 869 | |
| 780 | 870 | if ( ! $screen || 'dashboard' === $screen->id ) { |
| 781 | - return; | |
| 871 | + return null; | |
| 782 | 872 | } |
| 783 | 873 | |
| 784 | - // A single stable notice id (not per-form): keeps both the autoloaded | |
| 785 | - // `allowed_astra_notices` option and the per-user dismissal meta bounded to | |
| 786 | - // one row, and lets a dismissed user short-circuit before the query runs. | |
| 787 | - $notice_id = 'srfm-thankyou-prompt'; | |
| 788 | - | |
| 789 | 874 | // The library only checks dismissal at render (priority 30, after this |
| 790 | 875 | // query would already have run). Check it up front so a user who dismissed |
| 791 | 876 | // the prompt never pays for the WP_Query on subsequent admin page views. |
| 792 | - if ( 'notice-dismissed' === get_user_meta( get_current_user_id(), $notice_id, true ) ) { | |
| 793 | - return; | |
| 877 | + if ( 'notice-dismissed' === get_user_meta( get_current_user_id(), self::THANKYOU_PROMPT_NOTICE_ID, true ) ) { | |
| 878 | + return null; | |
| 794 | 879 | } |
| 795 | 880 | |
| 796 | 881 | // array_values so a filter returning a key-preserving array (e.g. the |
| 797 | 882 | // result of array_filter()) still exposes the newest prompt at index 0. |
| @@ -805,12 +890,34 @@ | ||
| 805 | 890 | || empty( $prompts[0]['id'] ) || empty( $prompts[0]['edit_url'] ) |
| 806 | 891 | || empty( $prompts[0]['thankyou_url'] ) || empty( $prompts[0]['replies_url'] ) |
| 807 | 892 | || ! isset( $prompts[0]['title'] ) |
| 808 | 893 | ) { |
| 894 | + return null; | |
| 895 | + } | |
| 896 | + | |
| 897 | + return $prompts[0]; | |
| 898 | + } | |
| 899 | + | |
| 900 | + /** | |
| 901 | + * Render the "Finish setting up" Thank You notice (#3030). | |
| 902 | + * | |
| 903 | + * @since 2.12.4 | |
| 904 | + * @return void | |
| 905 | + */ | |
| 906 | + public function render_thankyou_prompt_notice() { | |
| 907 | + $notice_id = self::THANKYOU_PROMPT_NOTICE_ID; | |
| 908 | + $form = $this->get_displayable_thankyou_prompt(); | |
| 909 | + | |
| 910 | + if ( null === $form ) { | |
| 809 | 911 | return; |
| 810 | 912 | } |
| 811 | 913 | |
| 812 | - $form = $prompts[0]; | |
| 914 | + // A broken form outranks a setup prompt. This is the top of the existing | |
| 915 | + // precedence chain, so the action-item check goes here rather than the | |
| 916 | + // action items standing down for an engagement notice. | |
| 917 | + if ( $this->has_action_item_warnings() ) { | |
| 918 | + return; | |
| 919 | + } | |
| 813 | 920 | |
| 814 | 921 | \Astra_Notices::add_notice( |
| 815 | 922 | [ |
| 816 | 923 | 'id' => $notice_id, |
| @@ -815,9 +922,9 @@ | ||
| 815 | 922 | [ |
| 816 | 923 | 'id' => $notice_id, |
| 817 | 924 | 'type' => 'info', |
| 818 | 925 | 'message' => self::build_thankyou_notice_markup( $form ), |
| 819 | - 'class' => 'srfm-thankyou-notice', | |
| 926 | + 'class' => 'srfm-notice srfm-thankyou-notice', | |
| 820 | 927 | 'is_dismissible' => true, |
| 821 | 928 | 'display-with-other-notices' => true, |
| 822 | 929 | // Render late so this nudge never pre-empts higher-priority notices |
| 823 | 930 | // (e.g. Astra's minimum-version warnings, which are display-with- |
| @@ -827,9 +934,9 @@ | ||
| 827 | 934 | ); |
| 828 | 935 | |
| 829 | 936 | // The message is wp_kses_post'd by the library, so the brand-orange styling |
| 830 | 937 | // is printed through the notice's pre-markup hook instead of inline. |
| 831 | - add_action( 'astra_notice_before_markup_' . $notice_id, [ $this, 'print_thankyou_notice_styles' ] ); | |
| 938 | + add_action( 'astra_notice_before_markup_' . $notice_id, [ $this, 'print_srfm_notice_styles' ] ); | |
| 832 | 939 | |
| 833 | 940 | // Track clicks on the CTAs and the dismiss ✕ via the shared notice-response |
| 834 | 941 | // endpoint, enqueued only when the notice actually renders. |
| 835 | 942 | add_action( 'astra_notice_after_markup_' . $notice_id, [ $this, 'enqueue_thankyou_notice_tracking' ] ); |
| @@ -919,9 +1026,9 @@ | ||
| 919 | 1026 | * |
| 920 | 1027 | * @since 2.12.4 |
| 921 | 1028 | * @return void |
| 922 | 1029 | */ |
| 923 | - public function print_thankyou_notice_styles() { | |
| 1030 | + public function print_srfm_notice_styles() { | |
| 924 | 1031 | // The library wp_kses_post()'s the message, which strips <svg> and data: |
| 925 | 1032 | // image srcs, so the SureForms mark is painted as a CSS background here |
| 926 | 1033 | // (this hook fires outside that kses call). URL-encoded, not base64, so the |
| 927 | 1034 | // value is fully percent-encoded and safe to pass through esc_url. |
| @@ -928,20 +1035,20 @@ | ||
| 928 | 1035 | $icon = 'data:image/svg+xml,' . rawurlencode( |
| 929 | 1036 | '<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 32 32"><path fill="#D54407" fill-rule="evenodd" clip-rule="evenodd" d="M32 0H0V32H32V0ZM22.8573 6.85728H9.14304V11.4287V13.7144L11.4288 11.4287H22.8573V6.85728ZM20.5717 13.7146H9.14314V18.286V20.5714V20.5718V25.1428H16.0003V20.5714H9.14351L11.4289 18.286H20.5717V13.7146Z"/></svg>' |
| 930 | 1037 | ); |
| 931 | 1038 | ?> |
| 932 | - <style id="srfm-thankyou-notice-styles"> | |
| 933 | - .srfm-thankyou-notice.notice { border-left-color: #D54407; } | |
| 1039 | + <style id="srfm-notice-styles"> | |
| 1040 | + .srfm-notice.notice { border-left-color: #D54407; } | |
| 934 | 1041 | /* Stack our blocks (the library lays the container out as a flex row) and reserve room on the left for the SureForms mark. */ |
| 935 | - .srfm-thankyou-notice .astra-notice-container { display: block; padding: 4px 0 4px 52px; background: url('<?php echo esc_url( $icon, [ 'data' ] ); ?>') no-repeat 4px 6px; background-size: 32px 32px; } | |
| 936 | - .srfm-thankyou-notice .srfm-thankyou-notice__title { margin: 0 0 4px; font-size: 14px; font-weight: 600; color: #1d2327; } | |
| 937 | - .srfm-thankyou-notice .srfm-thankyou-notice__text { margin: 0 0 10px; color: #50575e; } | |
| 938 | - .srfm-thankyou-notice .srfm-thankyou-notice__actions { margin: 12px 0 2px; display: flex; flex-wrap: wrap; gap: 10px 20px; align-items: center; } | |
| 939 | - .srfm-thankyou-notice .button-primary { background: #D54407; border-color: #D54407; color: #fff; box-shadow: none; text-shadow: none; } | |
| 940 | - .srfm-thankyou-notice .button-primary:hover, .srfm-thankyou-notice .button-primary:focus { background: #C83B00; border-color: #C83B00; color: #fff; box-shadow: none; } | |
| 941 | - .srfm-thankyou-notice .button:not(.button-primary) { background: transparent; border-color: transparent; color: #D54407; box-shadow: none; padding: 0; } | |
| 942 | - .srfm-thankyou-notice .button:not(.button-primary):hover, .srfm-thankyou-notice .button:not(.button-primary):focus { background: transparent; border-color: transparent; color: #C83B00; box-shadow: none; } | |
| 943 | - .srfm-thankyou-notice .button-primary:focus { outline: 2px solid #D54407; outline-offset: 1px; } | |
| 1042 | + .srfm-notice .astra-notice-container { display: block; padding: 4px 0 4px 52px; background: url('<?php echo esc_url( $icon, [ 'data' ] ); ?>') no-repeat 4px 6px; background-size: 32px 32px; } | |
| 1043 | + .srfm-notice .srfm-notice__title { margin: 0 0 4px; font-size: 14px; font-weight: 600; color: #1d2327; } | |
| 1044 | + .srfm-notice .srfm-notice__text { margin: 0 0 10px; color: #50575e; } | |
| 1045 | + .srfm-notice .srfm-notice__actions { margin: 12px 0 2px; display: flex; flex-wrap: wrap; gap: 10px 20px; align-items: center; } | |
| 1046 | + .srfm-notice .button-primary { background: #D54407; border-color: #D54407; color: #fff; box-shadow: none; text-shadow: none; } | |
| 1047 | + .srfm-notice .button-primary:hover, .srfm-notice .button-primary:focus { background: #C83B00; border-color: #C83B00; color: #fff; box-shadow: none; } | |
| 1048 | + .srfm-notice .button:not(.button-primary) { background: transparent; border-color: transparent; color: #D54407; box-shadow: none; padding: 0; } | |
| 1049 | + .srfm-notice .button:not(.button-primary):hover, .srfm-notice .button:not(.button-primary):focus { background: transparent; border-color: transparent; color: #C83B00; box-shadow: none; } | |
| 1050 | + .srfm-notice .button-primary:focus { outline: 2px solid #D54407; outline-offset: 1px; } | |
| 944 | 1051 | </style> |
| 945 | 1052 | <?php |
| 946 | 1053 | } |
| 947 | 1054 | |
| @@ -1201,12 +1308,9 @@ | ||
| 1201 | 1308 | public function add_quiz_page() { |
| 1202 | 1309 | add_submenu_page( |
| 1203 | 1310 | 'sureforms_menu', |
| 1204 | 1311 | __( 'Quiz Entries', 'sureforms' ), |
| 1205 | - __( 'Quizzes', 'sureforms' ) . | |
| 1206 | - ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' . | |
| 1207 | - esc_html__( 'New', 'sureforms' ) . | |
| 1208 | - '</span>', | |
| 1312 | + __( 'Quizzes', 'sureforms' ), | |
| 1209 | 1313 | self::$sureforms_page_default_capability, |
| 1210 | 1314 | 'sureforms_quiz_entries', |
| 1211 | 1315 | [ $this, 'render_quiz_empty_state' ], |
| 1212 | 1316 | 5 |
| @@ -1234,12 +1338,9 @@ | ||
| 1234 | 1338 | public function add_survey_reports_page() { |
| 1235 | 1339 | add_submenu_page( |
| 1236 | 1340 | 'sureforms_menu', |
| 1237 | 1341 | __( 'Survey Reports', 'sureforms' ), |
| 1238 | - __( 'Survey Reports', 'sureforms' ) . | |
| 1239 | - ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' . | |
| 1240 | - esc_html__( 'New', 'sureforms' ) . | |
| 1241 | - '</span>', | |
| 1342 | + __( 'Survey Reports', 'sureforms' ), | |
| 1242 | 1343 | self::$sureforms_page_default_capability, |
| 1243 | 1344 | 'sureforms_survey_reports', |
| 1244 | 1345 | [ $this, 'render_survey_empty_state' ], |
| 1245 | 1346 | 6 |
| @@ -1267,12 +1368,9 @@ | ||
| 1267 | 1368 | public function add_partial_entries_page() { |
| 1268 | 1369 | add_submenu_page( |
| 1269 | 1370 | 'sureforms_menu', |
| 1270 | 1371 | __( 'Partial Entries', 'sureforms' ), |
| 1271 | - __( 'Partial Entries', 'sureforms' ) . | |
| 1272 | - ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' . | |
| 1273 | - esc_html__( 'New', 'sureforms' ) . | |
| 1274 | - '</span>', | |
| 1372 | + __( 'Partial Entries', 'sureforms' ), | |
| 1275 | 1373 | self::$sureforms_page_default_capability, |
| 1276 | 1374 | 'sureforms_partial_entries', |
| 1277 | 1375 | [ $this, 'render_partial_entries_empty_state' ], |
| 1278 | 1376 | 7 |
| @@ -1720,10 +1818,16 @@ | ||
| 1720 | 1818 | 'sureforms_pricing_page' => Helper::get_sureforms_website_url( 'pricing' ), |
| 1721 | 1819 | 'field_spacing_vars' => Helper::get_css_vars(), |
| 1722 | 1820 | 'is_ver_lower_than_6_7' => version_compare( $wp_version, '6.6.2', '<=' ), |
| 1723 | 1821 | 'integrations' => Helper::sureforms_get_integration(), |
| 1724 | - 'rotating_plugin_banner' => Helper::get_rotating_plugin_banner(), | |
| 1822 | + 'hide_promotions' => Helper::hide_promotions(), | |
| 1823 | + // Null makes the dashboard's ExtendTab render nothing. | |
| 1824 | + 'rotating_plugin_banner' => Helper::hide_promotions() ? null : Helper::get_rotating_plugin_banner(), | |
| 1725 | 1825 | 'ajax_url' => admin_url( 'admin-ajax.php' ), |
| 1826 | + 'client_logs_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_client_logs' ) : '', | |
| 1827 | + 'action_items' => $this->get_action_items(), | |
| 1828 | + 'notice_response_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_notice_response' ) : '', | |
| 1829 | + 'dismiss_action_item_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_dismiss_action_item' ) : '', | |
| 1726 | 1830 | 'sf_plugin_manager_nonce' => wp_create_nonce( 'sf_plugin_manager_nonce' ), |
| 1727 | 1831 | 'plugin_installer_nonce' => wp_create_nonce( 'updates' ), |
| 1728 | 1832 | 'plugin_activating_text' => __( 'Activating...', 'sureforms' ), |
| 1729 | 1833 | 'plugin_activated_text' => __( 'Activated', 'sureforms' ), |
| @@ -1732,8 +1836,12 @@ | ||
| 1732 | 1836 | 'plugin_installed_text' => __( 'Installed', 'sureforms' ), |
| 1733 | 1837 | 'privacy_policy_url' => Helper::get_sureforms_website_url( 'privacy-policy/' ), |
| 1734 | 1838 | 'is_rtl' => $is_rtl, |
| 1735 | 1839 | 'onboarding_completed' => method_exists( $onboarding_instance, 'get_onboarding_status' ) ? $onboarding_instance->get_onboarding_status() : false, |
| 1840 | + // Read by the onboarding cache-conflict step: the name decides whether the | |
| 1841 | + // step renders, the URL is where "View full guide" points. | |
| 1842 | + 'caching_plugin' => Helper::get_active_caching_plugin(), | |
| 1843 | + 'caching_plugin_doc_url' => Helper::get_caching_plugin_doc_url( 'onboarding' ), | |
| 1736 | 1844 | 'migration_banner_dismissed' => method_exists( $onboarding_instance, 'is_migration_banner_dismissed' ) ? $onboarding_instance->is_migration_banner_dismissed() : false, |
| 1737 | 1845 | 'migration_settings_url' => admin_url( 'admin.php?page=sureforms_form_settings&tab=migration-settings' ), |
| 1738 | 1846 | 'onboarding_redirect' => isset( $_GET['srfm-activation-redirect'] ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is not required for the activation redirection. |
| 1739 | 1847 | 'pointer_nonce' => wp_create_nonce( 'sureforms_pointer_action' ), |
| @@ -2250,8 +2358,234 @@ | ||
| 2250 | 2358 | } |
| 2251 | 2359 | } |
| 2252 | 2360 | |
| 2253 | 2361 | /** |
| 2362 | + * Register the React notice when the entries table is missing. | |
| 2363 | + * | |
| 2364 | + * Hooked - admin_init, priority 5. | |
| 2365 | + * | |
| 2366 | + * Priority 5 is load-bearing: Notice_Manager hands notices to the front end | |
| 2367 | + * through the `srfm_admin_filter` applied during admin_enqueue_scripts, so | |
| 2368 | + * anything registering later never reaches the page. | |
| 2369 | + * | |
| 2370 | + * @since 2.12.6 | |
| 2371 | + * @return void | |
| 2372 | + */ | |
| 2373 | + public function register_database_repair_notice() { | |
| 2374 | + // admin_init also fires on admin-ajax.php. Nothing there renders a notice, so | |
| 2375 | + // skip the work rather than reading a transient on every AJAX request. | |
| 2376 | + if ( wp_doing_ajax() ) { | |
| 2377 | + return; | |
| 2378 | + } | |
| 2379 | + | |
| 2380 | + if ( ! Helper::current_user_can() ) { | |
| 2381 | + return; | |
| 2382 | + } | |
| 2383 | + | |
| 2384 | + if ( ! class_exists( 'SRFM\Admin\Notice_Manager' ) ) { | |
| 2385 | + return; | |
| 2386 | + } | |
| 2387 | + | |
| 2388 | + // A just-completed repair reports its outcome instead of the warning. | |
| 2389 | + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display flag; the repair itself is nonce-checked in handle_database_repair(). | |
| 2390 | + $result = isset( $_GET['srfm_db_repair'] ) ? sanitize_key( wp_unslash( $_GET['srfm_db_repair'] ) ) : ''; | |
| 2391 | + | |
| 2392 | + if ( 'done' === $result ) { | |
| 2393 | + Notice_Manager::register_notice( | |
| 2394 | + [ | |
| 2395 | + 'id' => 'srfm-database-repaired', | |
| 2396 | + 'variant' => 'success', | |
| 2397 | + 'message' => __( 'Your SureForms database is up to date. New form entries will be saved as usual.', 'sureforms' ), | |
| 2398 | + 'pages' => [ 'all' ], | |
| 2399 | + ] | |
| 2400 | + ); | |
| 2401 | + return; | |
| 2402 | + } | |
| 2403 | + | |
| 2404 | + if ( 'failed' === $result ) { | |
| 2405 | + Notice_Manager::register_notice( | |
| 2406 | + [ | |
| 2407 | + 'id' => 'srfm-database-repair-failed', | |
| 2408 | + // Still a warning, not an error: a host that does not allow | |
| 2409 | + // SureForms to create tables is not the user's mistake. | |
| 2410 | + 'variant' => 'warning', | |
| 2411 | + 'message' => __( 'SureForms could not finish updating the database. Your hosting may not allow SureForms to create database tables — please contact your hosting provider or SureForms support.', 'sureforms' ), | |
| 2412 | + 'actions' => [ | |
| 2413 | + [ | |
| 2414 | + 'label' => __( 'Contact support', 'sureforms' ), | |
| 2415 | + 'url' => 'https://sureforms.com/contact/', | |
| 2416 | + ], | |
| 2417 | + ], | |
| 2418 | + 'pages' => [ 'all' ], | |
| 2419 | + ] | |
| 2420 | + ); | |
| 2421 | + return; | |
| 2422 | + } | |
| 2423 | + | |
| 2424 | + if ( ! Register::is_entries_table_missing() ) { | |
| 2425 | + return; | |
| 2426 | + } | |
| 2427 | + | |
| 2428 | + $this->track_database_notice_impression(); | |
| 2429 | + | |
| 2430 | + Notice_Manager::register_notice( | |
| 2431 | + [ | |
| 2432 | + 'id' => 'srfm-database-maintenance', | |
| 2433 | + 'variant' => 'warning', | |
| 2434 | + 'title' => __( 'Database update needed', 'sureforms' ), | |
| 2435 | + // Plain text only. AdminNotice.js renders this as a React child, so | |
| 2436 | + // any markup here would show up as literal characters. | |
| 2437 | + 'message' => $this->get_database_notice_message(), | |
| 2438 | + 'actions' => [ | |
| 2439 | + [ | |
| 2440 | + 'label' => __( 'Fix now', 'sureforms' ), | |
| 2441 | + // Opaque identifier, resolved to a handler in AdminNotice.js. | |
| 2442 | + // Deliberately not a URL or endpoint: the server never tells | |
| 2443 | + // the browser which address to call. | |
| 2444 | + 'action' => 'repair-entries-table', | |
| 2445 | + 'url' => $this->get_database_repair_url(), | |
| 2446 | + ], | |
| 2447 | + ], | |
| 2448 | + 'pages' => [ 'all' ], | |
| 2449 | + ] | |
| 2450 | + ); | |
| 2451 | + } | |
| 2452 | + | |
| 2453 | + /** | |
| 2454 | + * Render the classic warning on the WordPress dashboard. | |
| 2455 | + * | |
| 2456 | + * Hooked - admin_notices. | |
| 2457 | + * | |
| 2458 | + * Scoped to index.php on purpose. The React notice already covers the SureForms | |
| 2459 | + * screens, so leaving this one admin-wide would stack two warnings on the same | |
| 2460 | + * page and nag on every screen in wp-admin. | |
| 2461 | + * | |
| 2462 | + * Registered as [ $this, 'method' ] rather than a closure because | |
| 2463 | + * suppress_foreign_admin_notices() strips any callback it cannot attribute to a | |
| 2464 | + * SureForms class — a closure here would be silently removed. | |
| 2465 | + * | |
| 2466 | + * @since 2.12.6 | |
| 2467 | + * @return void | |
| 2468 | + */ | |
| 2469 | + public function render_database_repair_notice() { | |
| 2470 | + if ( ! Helper::current_user_can() ) { | |
| 2471 | + return; | |
| 2472 | + } | |
| 2473 | + | |
| 2474 | + $screen = get_current_screen(); | |
| 2475 | + | |
| 2476 | + if ( ! $screen || 'dashboard' !== $screen->base ) { | |
| 2477 | + return; | |
| 2478 | + } | |
| 2479 | + | |
| 2480 | + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display flag; the repair itself is nonce-checked in handle_database_repair(). | |
| 2481 | + $result = isset( $_GET['srfm_db_repair'] ) ? sanitize_key( wp_unslash( $_GET['srfm_db_repair'] ) ) : ''; | |
| 2482 | + | |
| 2483 | + if ( 'done' === $result ) { | |
| 2484 | + ?> | |
| 2485 | + <div class="notice notice-success is-dismissible"> | |
| 2486 | + <p><?php esc_html_e( 'Your SureForms database is up to date. New form entries will be saved as usual.', 'sureforms' ); ?></p> | |
| 2487 | + </div> | |
| 2488 | + <?php | |
| 2489 | + return; | |
| 2490 | + } | |
| 2491 | + | |
| 2492 | + if ( 'failed' === $result ) { | |
| 2493 | + ?> | |
| 2494 | + <div class="notice notice-warning is-dismissible"> | |
| 2495 | + <p><?php esc_html_e( 'SureForms could not finish updating the database. Your hosting may not allow SureForms to create database tables — please contact your hosting provider or SureForms support.', 'sureforms' ); ?></p> | |
| 2496 | + </div> | |
| 2497 | + <?php | |
| 2498 | + return; | |
| 2499 | + } | |
| 2500 | + | |
| 2501 | + if ( ! Register::is_entries_table_missing() ) { | |
| 2502 | + return; | |
| 2503 | + } | |
| 2504 | + | |
| 2505 | + $this->track_database_notice_impression(); | |
| 2506 | + ?> | |
| 2507 | + <div class="notice notice-warning"> | |
| 2508 | + <p> | |
| 2509 | + <strong><?php esc_html_e( 'SureForms — database update needed', 'sureforms' ); ?></strong> | |
| 2510 | + </p> | |
| 2511 | + <p><?php echo esc_html( $this->get_database_notice_message() ); ?></p> | |
| 2512 | + <p> | |
| 2513 | + <a href="<?php echo esc_url( $this->get_database_repair_url() ); ?>" class="button button-primary"> | |
| 2514 | + <?php esc_html_e( 'Update database', 'sureforms' ); ?> | |
| 2515 | + </a> | |
| 2516 | + </p> | |
| 2517 | + </div> | |
| 2518 | + <?php | |
| 2519 | + } | |
| 2520 | + | |
| 2521 | + /** | |
| 2522 | + * Repair the entries table, then redirect back with the outcome. | |
| 2523 | + * | |
| 2524 | + * Hooked - admin_post_srfm_repair_entries_table. | |
| 2525 | + * | |
| 2526 | + * A nonce-protected GET that changes state matches how core's own plugin | |
| 2527 | + * activate / deactivate / delete links work. | |
| 2528 | + * | |
| 2529 | + * @since 2.12.6 | |
| 2530 | + * @return void | |
| 2531 | + */ | |
| 2532 | + public function handle_database_repair() { | |
| 2533 | + if ( ! Helper::current_user_can() ) { | |
| 2534 | + wp_die( esc_html__( 'You do not have permission to update the database.', 'sureforms' ), 403 ); | |
| 2535 | + } | |
| 2536 | + | |
| 2537 | + check_admin_referer( 'srfm_repair_entries_table' ); | |
| 2538 | + | |
| 2539 | + $repaired = $this->do_database_repair(); | |
| 2540 | + $referer = wp_get_referer(); | |
| 2541 | + | |
| 2542 | + wp_safe_redirect( | |
| 2543 | + add_query_arg( | |
| 2544 | + 'srfm_db_repair', | |
| 2545 | + $repaired ? 'done' : 'failed', | |
| 2546 | + $referer ? $referer : admin_url() | |
| 2547 | + ) | |
| 2548 | + ); | |
| 2549 | + exit; | |
| 2550 | + } | |
| 2551 | + | |
| 2552 | + /** | |
| 2553 | + * Repair the entries table and record what happened. | |
| 2554 | + * | |
| 2555 | + * The single place the repair is performed and counted, shared by the | |
| 2556 | + * admin-post handler and the REST endpoint. One user action reaches exactly one | |
| 2557 | + * of those, so the click counter cannot double-count across the two surfaces. | |
| 2558 | + * | |
| 2559 | + * @since 2.12.6 | |
| 2560 | + * @return bool True when the table exists afterwards. | |
| 2561 | + */ | |
| 2562 | + public function do_database_repair() { | |
| 2563 | + // Cumulative counter, so $force = true: each new count is a new value and is | |
| 2564 | + // re-sent, while an identical repeat short-circuits inside track(). | |
| 2565 | + $attempts = Helper::get_integer_value( Helper::get_srfm_option( 'db_repair_attempts', 0 ) ) + 1; | |
| 2566 | + Helper::update_srfm_option( 'db_repair_attempts', $attempts ); | |
| 2567 | + | |
| 2568 | + // Event name is the `database_error` => `fix_now` entry in the $valid | |
| 2569 | + // allowlist in handle_notice_response(). Kept in sync by hand; that array is | |
| 2570 | + // where the team looks notice event names up. | |
| 2571 | + Analytics::events()->track( 'database_error_notice_cta', (string) $attempts, [], true ); | |
| 2572 | + | |
| 2573 | + $repaired = Register::repair_entries_table(); | |
| 2574 | + | |
| 2575 | + // The failure case is the more valuable signal: it means the host refuses to | |
| 2576 | + // let SureForms create tables, which no amount of retrying will fix. | |
| 2577 | + Analytics::events()->track( | |
| 2578 | + 'database_repair_result', | |
| 2579 | + $repaired ? 'success' : 'failed', | |
| 2580 | + [], | |
| 2581 | + true | |
| 2582 | + ); | |
| 2583 | + | |
| 2584 | + return $repaired; | |
| 2585 | + } | |
| 2586 | + | |
| 2587 | + /** | |
| 2254 | 2588 | * Admin Notice Callback if sureforms pro is out of date. |
| 2255 | 2589 | * |
| 2256 | 2590 | * Hooked - admin_notices |
| 2257 | 2591 | * |
| @@ -2341,34 +2675,57 @@ | ||
| 2341 | 2675 | if ( ! Helper::current_user_can() ) { |
| 2342 | 2676 | return; |
| 2343 | 2677 | } |
| 2344 | 2678 | |
| 2345 | - // Allow the notice to be disabled. | |
| 2346 | - if ( ! apply_filters( 'srfm_show_rating_notice', true ) ) { | |
| 2679 | + // Allow the notice to be disabled; never shown while promotions are hidden. | |
| 2680 | + if ( Helper::hide_promotions() || ! apply_filters( 'srfm_show_rating_notice', true ) ) { | |
| 2347 | 2681 | return; |
| 2348 | 2682 | } |
| 2349 | 2683 | |
| 2684 | + $notice_id = 'srfm-plugin-review-notice'; | |
| 2685 | + | |
| 2350 | 2686 | Astra_Notices::add_notice( |
| 2351 | 2687 | [ |
| 2352 | - 'id' => 'srfm-plugin-review-notice', | |
| 2688 | + 'id' => $notice_id, | |
| 2353 | 2689 | 'type' => '', |
| 2354 | - 'message' => $this->build_notice_markup( | |
| 2355 | - esc_html__( 'Amazing! SureForms is powering your forms and submissions - let\'s keep growing together!', 'sureforms' ), | |
| 2356 | - esc_html__( 'If SureForms has been helpful, would you mind taking a moment to leave a 5-star review on WordPress.org?', 'sureforms' ), | |
| 2357 | - esc_url( 'https://wordpress.org/support/plugin/sureforms/reviews/' ), | |
| 2358 | - esc_html__( 'Rate SureForms', 'sureforms' ), | |
| 2359 | - esc_html__( 'Maybe later', 'sureforms' ), | |
| 2360 | - esc_html__( 'I already did', 'sureforms' ), | |
| 2361 | - WEEK_IN_SECONDS, | |
| 2362 | - true | |
| 2690 | + 'message' => self::build_srfm_notice_markup( | |
| 2691 | + __( 'Amazing! SureForms is powering your forms and submissions - let\'s keep growing together!', 'sureforms' ), | |
| 2692 | + __( 'If SureForms has been helpful, would you mind taking a moment to leave a 5-star review on WordPress.org?', 'sureforms' ), | |
| 2693 | + [ | |
| 2694 | + [ | |
| 2695 | + 'text' => __( 'Rate SureForms', 'sureforms' ), | |
| 2696 | + 'url' => esc_url( 'https://wordpress.org/support/plugin/sureforms/reviews/' ), | |
| 2697 | + 'primary' => true, | |
| 2698 | + // Leaves wp-admin, so it also dismisses on the way out. | |
| 2699 | + 'dismiss' => true, | |
| 2700 | + 'external' => true, | |
| 2701 | + ], | |
| 2702 | + [ | |
| 2703 | + 'text' => __( 'Maybe later', 'sureforms' ), | |
| 2704 | + 'url' => '#', | |
| 2705 | + 'dismiss' => true, | |
| 2706 | + 'snooze' => WEEK_IN_SECONDS, | |
| 2707 | + ], | |
| 2708 | + [ | |
| 2709 | + 'text' => __( 'I already did', 'sureforms' ), | |
| 2710 | + 'url' => '#', | |
| 2711 | + 'dismiss' => true, | |
| 2712 | + ], | |
| 2713 | + ] | |
| 2363 | 2714 | ), |
| 2715 | + 'class' => 'srfm-notice srfm-rating-notice', | |
| 2364 | 2716 | 'repeat-notice-after' => WEEK_IN_SECONDS, |
| 2365 | - 'show_if' => $this->maybe_display_rating_notice(), | |
| 2717 | + // Yields to the Thank You prompt for the same reason the Getting Started | |
| 2718 | + // notice does: a specific form to finish beats a recurring review ask, | |
| 2719 | + // and a user with three forms who then imports a template would | |
| 2720 | + // otherwise see both at once. | |
| 2721 | + 'show_if' => $this->maybe_display_rating_notice() && null === $this->get_displayable_thankyou_prompt() && ! $this->has_action_item_warnings(), | |
| 2366 | 2722 | 'display-with-other-notices' => true, |
| 2367 | 2723 | ] |
| 2368 | 2724 | ); |
| 2369 | 2725 | |
| 2370 | - add_action( 'astra_notice_after_markup_srfm-plugin-review-notice', [ $this, 'enqueue_notice_response_script' ] ); | |
| 2726 | + add_action( 'astra_notice_before_markup_' . $notice_id, [ $this, 'print_srfm_notice_styles' ] ); | |
| 2727 | + add_action( 'astra_notice_after_markup_' . $notice_id, [ $this, 'enqueue_notice_response_script' ] ); | |
| 2371 | 2728 | } |
| 2372 | 2729 | |
| 2373 | 2730 | /** |
| 2374 | 2731 | * Display a "Getting Started" admin notice for new users who haven't yet |
| @@ -2390,29 +2747,52 @@ | ||
| 2390 | 2747 | if ( ! apply_filters( 'srfm_show_getting_started_notice', true ) ) { |
| 2391 | 2748 | return; |
| 2392 | 2749 | } |
| 2393 | 2750 | |
| 2751 | + $notice_id = 'srfm-getting-started-notice'; | |
| 2752 | + | |
| 2394 | 2753 | Astra_Notices::add_notice( |
| 2395 | 2754 | [ |
| 2396 | - 'id' => 'srfm-getting-started-notice', | |
| 2755 | + 'id' => $notice_id, | |
| 2397 | 2756 | 'type' => '', |
| 2398 | - 'message' => $this->build_notice_markup( | |
| 2399 | - esc_html__( 'SureForms is ready to power your forms — explore what\'s possible!', 'sureforms' ), | |
| 2400 | - esc_html__( 'Manage your forms, track submissions, and discover features like AI Form Builder, payment integrations, and more from the SureForms dashboard.', 'sureforms' ), | |
| 2401 | - esc_url( admin_url( 'admin.php?page=sureforms_menu' ) ), | |
| 2402 | - esc_html__( 'Go to Dashboard', 'sureforms' ), | |
| 2403 | - esc_html__( 'Maybe later', 'sureforms' ), | |
| 2404 | - esc_html__( 'I already know', 'sureforms' ), | |
| 2405 | - WEEK_IN_SECONDS | |
| 2757 | + 'message' => self::build_srfm_notice_markup( | |
| 2758 | + __( 'SureForms is ready to power your forms — explore what\'s possible!', 'sureforms' ), | |
| 2759 | + __( 'Manage your forms, track submissions, and discover features like AI Form Builder, payment integrations, and more from the SureForms dashboard.', 'sureforms' ), | |
| 2760 | + [ | |
| 2761 | + [ | |
| 2762 | + 'text' => __( 'Go to Dashboard', 'sureforms' ), | |
| 2763 | + 'url' => esc_url( admin_url( 'admin.php?page=sureforms_menu' ) ), | |
| 2764 | + 'primary' => true, | |
| 2765 | + ], | |
| 2766 | + [ | |
| 2767 | + 'text' => __( 'Maybe later', 'sureforms' ), | |
| 2768 | + 'url' => '#', | |
| 2769 | + 'dismiss' => true, | |
| 2770 | + 'snooze' => WEEK_IN_SECONDS, | |
| 2771 | + ], | |
| 2772 | + [ | |
| 2773 | + 'text' => __( 'I already know', 'sureforms' ), | |
| 2774 | + 'url' => '#', | |
| 2775 | + 'dismiss' => true, | |
| 2776 | + ], | |
| 2777 | + ] | |
| 2406 | 2778 | ), |
| 2779 | + 'class' => 'srfm-notice srfm-getting-started-notice', | |
| 2407 | 2780 | 'repeat-notice-after' => WEEK_IN_SECONDS, |
| 2408 | - 'show_if' => ! $this->maybe_display_rating_notice(), | |
| 2781 | + // Yields to both of the other SureForms notices, so only one of ours is | |
| 2782 | + // ever on screen. The rating notice supersedes it once the user has real | |
| 2783 | + // usage; the Thank You prompt supersedes it because "finish this specific | |
| 2784 | + // form" is a concrete next step and this is a generic tour invitation. | |
| 2785 | + 'show_if' => ! $this->maybe_display_rating_notice() && null === $this->get_displayable_thankyou_prompt() && ! $this->has_action_item_warnings(), | |
| 2409 | 2786 | 'display-notice-after' => WEEK_IN_SECONDS, |
| 2410 | 2787 | 'display-with-other-notices' => true, |
| 2411 | 2788 | ] |
| 2412 | 2789 | ); |
| 2413 | 2790 | |
| 2414 | - add_action( 'astra_notice_after_markup_srfm-getting-started-notice', [ $this, 'enqueue_notice_response_script' ] ); | |
| 2791 | + // Same pre-markup hook the Thank You prompt uses, so both notices are painted | |
| 2792 | + // by one stylesheet instead of two that drift apart. | |
| 2793 | + add_action( 'astra_notice_before_markup_' . $notice_id, [ $this, 'print_srfm_notice_styles' ] ); | |
| 2794 | + add_action( 'astra_notice_after_markup_' . $notice_id, [ $this, 'enqueue_notice_response_script' ] ); | |
| 2415 | 2795 | } |
| 2416 | 2796 | |
| 2417 | 2797 | /** |
| 2418 | 2798 | * Enqueue the notice response analytics script. |
| @@ -2439,10 +2819,20 @@ | ||
| 2439 | 2819 | wp_localize_script( |
| 2440 | 2820 | 'srfm-notice-response', |
| 2441 | 2821 | 'srfmNoticeResponse', |
| 2442 | 2822 | [ |
| 2443 | - 'ajaxurl' => admin_url( 'admin-ajax.php' ), | |
| 2444 | - 'nonce' => wp_create_nonce( 'srfm_notice_response' ), | |
| 2823 | + 'ajaxurl' => admin_url( 'admin-ajax.php' ), | |
| 2824 | + 'nonce' => wp_create_nonce( 'srfm_notice_response' ), | |
| 2825 | + // Carousel chrome. Built in the browser rather than printed here so | |
| 2826 | + // that with JavaScript off every notice simply stays visible, which | |
| 2827 | + // is the behaviour this replaced -- controls that cannot work must | |
| 2828 | + // not be what hides a warning. | |
| 2829 | + 'carousel' => [ | |
| 2830 | + 'previous' => __( 'Previous notice', 'sureforms' ), | |
| 2831 | + 'next' => __( 'Next notice', 'sureforms' ), | |
| 2832 | + /* translators: 1: current position, 2: total notices. */ | |
| 2833 | + 'counter' => __( '%1$d of %2$d', 'sureforms' ), | |
| 2834 | + ], | |
| 2445 | 2835 | ] |
| 2446 | 2836 | ); |
| 2447 | 2837 | } |
| 2448 | 2838 | |
| @@ -2457,12 +2847,14 @@ | ||
| 2457 | 2847 | */ |
| 2458 | 2848 | public function handle_notice_response() { |
| 2459 | 2849 | if ( ! check_ajax_referer( 'srfm_notice_response', 'nonce', false ) ) { |
| 2460 | 2850 | wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 ); |
| 2851 | + return; | |
| 2461 | 2852 | } |
| 2462 | 2853 | |
| 2463 | 2854 | if ( ! Helper::current_user_can() ) { |
| 2464 | 2855 | wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 ); |
| 2856 | + return; | |
| 2465 | 2857 | } |
| 2466 | 2858 | |
| 2467 | 2859 | $notice_id = isset( $_POST['notice_id'] ) ? sanitize_text_field( wp_unslash( $_POST['notice_id'] ) ) : ''; |
| 2468 | 2860 | $button = isset( $_POST['button'] ) ? sanitize_text_field( wp_unslash( $_POST['button'] ) ) : ''; |
| @@ -2477,9 +2869,34 @@ | ||
| 2477 | 2869 | 'rate_sureforms' => 'rating_notice_cta', |
| 2478 | 2870 | 'maybe_later' => 'rating_notice_snooze', |
| 2479 | 2871 | 'dismissed' => 'rating_notice_dismiss', |
| 2480 | 2872 | ], |
| 2873 | + // Database maintenance notice. Keyed `database_error` for the warehouse; | |
| 2874 | + // the user-facing copy deliberately reads as a routine update, not an | |
| 2875 | + // error. `dismissed` is registered but unreachable today — a missing | |
| 2876 | + // entries table is not something we let people dismiss. | |
| 2877 | + 'database_error' => [ | |
| 2878 | + 'fix_now' => 'database_error_notice_cta', | |
| 2879 | + 'dismissed' => 'database_error_notice_dismiss', | |
| 2880 | + ], | |
| 2481 | 2881 | // The "Finish setting up" prompt (#3030): three CTAs, plus the ✕. |
| 2882 | + 'form_submission_error' => [ | |
| 2883 | + 'contact_support' => 'submission_failure_notice_cta', | |
| 2884 | + 'dismissed' => 'submission_failure_notice_dismiss', | |
| 2885 | + ], | |
| 2886 | + 'notification_error' => [ | |
| 2887 | + 'contact_support' => 'notification_failure_notice_cta', | |
| 2888 | + 'help_me_fix' => 'notification_failure_notice_guide', | |
| 2889 | + 'dismissed' => 'notification_failure_notice_dismiss', | |
| 2890 | + ], | |
| 2891 | + 'integration_error' => [ | |
| 2892 | + 'contact_support' => 'integration_failure_notice_cta', | |
| 2893 | + 'dismissed' => 'integration_failure_notice_dismiss', | |
| 2894 | + ], | |
| 2895 | + 'caching_plugin' => [ | |
| 2896 | + 'help_me_fix' => 'caching_plugin_notice_cta', | |
| 2897 | + 'dismissed' => 'caching_plugin_notice_dismiss', | |
| 2898 | + ], | |
| 2482 | 2899 | 'srfm-thankyou-prompt' => [ |
| 2483 | 2900 | 'edit_form' => 'thankyou_notice_edit_form', |
| 2484 | 2901 | 'set_replies' => 'thankyou_notice_set_replies', |
| 2485 | 2902 | 'edit_thankyou' => 'thankyou_notice_edit_thankyou', |
| @@ -2488,13 +2905,35 @@ | ||
| 2488 | 2905 | ]; |
| 2489 | 2906 | |
| 2490 | 2907 | if ( ! isset( $valid[ $notice_id ][ $button ] ) ) { |
| 2491 | 2908 | wp_send_json_error( [ 'message' => __( 'Invalid parameters.', 'sureforms' ) ], 400 ); |
| 2909 | + // wp_send_json_error() ends the request in production. The explicit return | |
| 2910 | + // keeps the guard a guard rather than something that only works because of | |
| 2911 | + // a side effect in a function elsewhere. | |
| 2912 | + return; | |
| 2492 | 2913 | } |
| 2493 | 2914 | |
| 2494 | - $event_name = $valid[ $notice_id ][ $button ]; | |
| 2495 | - Analytics::events()->track( $event_name, $button ); | |
| 2915 | + $this->track_notice_event( $valid[ $notice_id ][ $button ] ); | |
| 2496 | 2916 | |
| 2917 | + // Reporting the failures retires the notice until something new fails. | |
| 2918 | + // Handled here rather than in the browser so both surfaces share it. The | |
| 2919 | + // click still reaches here only through JavaScript -- notice-response.js | |
| 2920 | + // on the classic notice, ActionItems.js on the dashboard -- so with | |
| 2921 | + // JavaScript off the link opens the email but the notice stays. | |
| 2922 | + $categories = [ | |
| 2923 | + 'form_submission_error' => 'submission', | |
| 2924 | + 'notification_error' => 'notification', | |
| 2925 | + 'integration_error' => 'integration', | |
| 2926 | + ]; | |
| 2927 | + | |
| 2928 | + if ( 'contact_support' === $button && isset( $categories[ $notice_id ] ) ) { | |
| 2929 | + Client_Logger::acknowledge_category( $categories[ $notice_id ] ); | |
| 2930 | + | |
| 2931 | + if ( 'form_submission_error' === $notice_id ) { | |
| 2932 | + Client_Logger::acknowledge_failures(); | |
| 2933 | + } | |
| 2934 | + } | |
| 2935 | + | |
| 2497 | 2936 | wp_send_json_success(); |
| 2498 | 2937 | } |
| 2499 | 2938 | |
| 2500 | 2939 | /** |
| @@ -2637,10 +3076,11 @@ | ||
| 2637 | 3076 | * @since 1.9.1 |
| 2638 | 3077 | */ |
| 2639 | 3078 | public function maybe_register_dashboard_widget() { |
| 2640 | 3079 | |
| 2641 | - // Only for users with manage_options capability. | |
| 2642 | - if ( ! Helper::current_user_can() ) { | |
| 3080 | + // Only for users with manage_options capability, and never while | |
| 3081 | + // promotions are hidden: no SureForms widget on the WordPress dashboard. | |
| 3082 | + if ( ! Helper::current_user_can() || Helper::hide_promotions() ) { | |
| 2643 | 3083 | return; |
| 2644 | 3084 | } |
| 2645 | 3085 | |
| 2646 | 3086 | // Register the AI quick draft widget for capable users (the capability gate above applies); unlike the recent-entries widget below, it is not conditional on having entries. |
| @@ -2743,9 +3183,9 @@ | ||
| 2743 | 3183 | * @since 2.12.1 |
| 2744 | 3184 | */ |
| 2745 | 3185 | public function enqueue_ai_dashboard_widget_assets( $hook_suffix ) { |
| 2746 | 3186 | // Only on the main dashboard, and only for capable users (matches the widget gate). |
| 2747 | - if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) { | |
| 3187 | + if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() || Helper::hide_promotions() ) { | |
| 2748 | 3188 | return; |
| 2749 | 3189 | } |
| 2750 | 3190 | |
| 2751 | 3191 | // Register an inline-only handle (empty src) — the WordPress-core pattern for attaching |
| @@ -2827,8 +3267,118 @@ | ||
| 2827 | 3267 | wp_add_inline_script( 'srfm-ai-dashboard-widget', $inline_script ); |
| 2828 | 3268 | } |
| 2829 | 3269 | |
| 2830 | 3270 | /** |
| 3271 | + * Count an editor visit that came from the front-end "Edit Form" pill. | |
| 3272 | + * | |
| 3273 | + * The pill is a plain link, so the click is attributed by the marker query arg | |
| 3274 | + * it carries rather than by a front-end click handler. That keeps the front end | |
| 3275 | + * script-free and adds no AJAX endpoint: the only thing on the page is still an | |
| 3276 | + * anchor. It also measures the outcome that matters — the editor actually | |
| 3277 | + * opening — instead of a click that may never land. | |
| 3278 | + * | |
| 3279 | + * Every decision here comes from server state. The query arg selects the code | |
| 3280 | + * path; what gets counted is derived from the resolved post and the current | |
| 3281 | + * user's capability on it. An absent, empty, misspelled or reused arg, a post | |
| 3282 | + * that is not a SureForms form, and a user without `edit_post` on that form all | |
| 3283 | + * fall through to no-op without an explicit branch. | |
| 3284 | + * | |
| 3285 | + * No nonce, deliberately: the pill is rendered into front-end HTML that may be | |
| 3286 | + * page-cached, so a nonce would either be baked into the cache or be stale on | |
| 3287 | + * arrival. The effect is a private usage counter for a user who can already edit | |
| 3288 | + * the form, and nothing attacker-controlled reaches the analytics payload — the | |
| 3289 | + * value sent is an integer read back from stored state. | |
| 3290 | + * | |
| 3291 | + * Because the marker is just a query arg, the invariant that bounds this is the | |
| 3292 | + * dedup transient below, not the arg: a given editor moves the counter at most | |
| 3293 | + * once per form per hour, no matter how many times the URL is requested. That is | |
| 3294 | + * also what keeps the metric honest — without it a refresh or a back-navigation | |
| 3295 | + * would count again, and each count is a read-modify-write of the whole | |
| 3296 | + * `srfm_options` row, which holds unrelated settings. | |
| 3297 | + * | |
| 3298 | + * @return void | |
| 3299 | + * @since 2.12.6 | |
| 3300 | + */ | |
| 3301 | + public function maybe_track_edit_form_button_click() { | |
| 3302 | + // is_string() before sanitize_key(): `?srfm_edit_src[]=x` satisfies isset(), | |
| 3303 | + // and wp_unslash() hands the array straight through. sanitize_key() only grew | |
| 3304 | + // its is_scalar() guard after this plugin's minimum WordPress, so on the older | |
| 3305 | + // supported versions that reaches strtolower( array ) — a TypeError on PHP 8, | |
| 3306 | + // i.e. the one input shape that ended in a fatal rather than in the no-op the | |
| 3307 | + // rest of this method guarantees. | |
| 3308 | + $arg = Generate_Form_Markup::EDIT_FORM_BUTTON_SOURCE_ARG; | |
| 3309 | + | |
| 3310 | + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only attribution marker; see docblock for why a nonce is neither possible nor needed. | |
| 3311 | + $source = isset( $_GET[ $arg ] ) && is_string( $_GET[ $arg ] ) ? sanitize_key( wp_unslash( $_GET[ $arg ] ) ) : ''; | |
| 3312 | + | |
| 3313 | + if ( 'embed' !== $source ) { | |
| 3314 | + return; | |
| 3315 | + } | |
| 3316 | + | |
| 3317 | + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Same read-only path as above. | |
| 3318 | + $post_id = isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0; | |
| 3319 | + | |
| 3320 | + // Resolve the post type from the stored post, never from the request. | |
| 3321 | + // | |
| 3322 | + // The capability below reads as per-post but is not: sureforms_form is | |
| 3323 | + // registered with an explicit capabilities map and no `map_meta_cap` | |
| 3324 | + // (inc/post-types.php), so core short-circuits `edit_post` to the post type's | |
| 3325 | + // `edit_post` capability — `manage_options` — without ever consulting $post_id. | |
| 3326 | + // The real gate is therefore "site administrator", which is stricter than a | |
| 3327 | + // per-form check, not weaker. Written down because a later `map_meta_cap` on | |
| 3328 | + // the CPT would silently change what this line means with no diff here. | |
| 3329 | + if ( 0 === $post_id || SRFM_FORMS_POST_TYPE !== get_post_type( $post_id ) ) { | |
| 3330 | + return; | |
| 3331 | + } | |
| 3332 | + | |
| 3333 | + if ( ! current_user_can( 'edit_post', $post_id ) ) { | |
| 3334 | + return; | |
| 3335 | + } | |
| 3336 | + | |
| 3337 | + // One count per editor per form per hour. Without this the metric measures | |
| 3338 | + // "editor loads carrying the marker" rather than pill clicks — a refresh or a | |
| 3339 | + // back-navigation re-counts — and a forged page could drive the counter, and | |
| 3340 | + // the writes behind it, without bound. | |
| 3341 | + $dedup_key = 'srfm_pill_click_' . get_current_user_id() . '_' . $post_id; | |
| 3342 | + | |
| 3343 | + if ( false !== get_transient( $dedup_key ) ) { | |
| 3344 | + return; | |
| 3345 | + } | |
| 3346 | + | |
| 3347 | + set_transient( $dedup_key, 1, HOUR_IN_SECONDS ); | |
| 3348 | + | |
| 3349 | + $count = Helper::get_integer_value( Helper::get_srfm_option( 'edit_form_button_clicks', 0 ) ) + 1; | |
| 3350 | + Helper::update_srfm_option( 'edit_form_button_clicks', $count ); | |
| 3351 | + | |
| 3352 | + // $force = true because this is a cumulative counter, not a one-time event — | |
| 3353 | + // it must re-send the latest count each cycle (bypasses one-time dedup). | |
| 3354 | + Analytics::events()->track( 'edit_form_button_clicked', (string) $count, [], true ); | |
| 3355 | + } | |
| 3356 | + | |
| 3357 | + /** | |
| 3358 | + * Let core strip the edit-attribution marker from the admin URL. | |
| 3359 | + * | |
| 3360 | + * Core's wp_admin_canonical_url() rewrites the address bar via replaceState() on | |
| 3361 | + * admin_head, which runs after load-post.php — so the marker has already been | |
| 3362 | + * counted by the time it is removed and no attribution is lost. Without this it | |
| 3363 | + * lingers in the address bar, in bookmarks, and in the Referer header sent to | |
| 3364 | + * every subresource the editor loads. | |
| 3365 | + * | |
| 3366 | + * @param array<string> $args Query args core already removes. | |
| 3367 | + * @since 2.12.6 | |
| 3368 | + * @return array<string> Args with the marker appended. | |
| 3369 | + */ | |
| 3370 | + public function add_removable_query_args( $args ) { | |
| 3371 | + if ( ! is_array( $args ) ) { | |
| 3372 | + return [ Generate_Form_Markup::EDIT_FORM_BUTTON_SOURCE_ARG ]; | |
| 3373 | + } | |
| 3374 | + | |
| 3375 | + $args[] = Generate_Form_Markup::EDIT_FORM_BUTTON_SOURCE_ARG; | |
| 3376 | + | |
| 3377 | + return $args; | |
| 3378 | + } | |
| 3379 | + | |
| 3380 | + /** | |
| 2831 | 3381 | * Track AI dashboard widget usage. |
| 2832 | 3382 | * |
| 2833 | 3383 | * @return void |
| 2834 | 3384 | * @since 2.12.1 |
| @@ -2903,8 +3453,634 @@ | ||
| 2903 | 3453 | <?php |
| 2904 | 3454 | } |
| 2905 | 3455 | |
| 2906 | 3456 | /** |
| 3457 | + * Classic dashboard notice when submissions keep failing. | |
| 3458 | + * | |
| 3459 | + * Hooked - admin_notices. | |
| 3460 | + * | |
| 3461 | + * Gated to the WP dashboard. The React notice already covers SureForms' own | |
| 3462 | + * screens, so leaving this admin-wide would stack two warnings on one page. | |
| 3463 | + * | |
| 3464 | + * Registered as [ $this, 'method' ] rather than a closure because | |
| 3465 | + * suppress_foreign_admin_notices() strips any callback it cannot attribute to | |
| 3466 | + * a SureForms class -- a closure here would be silently removed. | |
| 3467 | + * | |
| 3468 | + * @since 2.12.6 | |
| 3469 | + * @return void | |
| 3470 | + */ | |
| 3471 | + public function render_action_item_notices() { | |
| 3472 | + // Shown across wp-admin, because someone whose forms are silently failing | |
| 3473 | + // may not open the WP dashboard or SureForms for days. | |
| 3474 | + // | |
| 3475 | + // The one exclusion is SureForms' own dashboard: the Form Checks panel in | |
| 3476 | + // its sidebar already lists these, and a banner above it would say the same | |
| 3477 | + // thing twice on one screen. | |
| 3478 | + if ( Helper::validate_request_context( 'sureforms_menu', 'page' ) ) { | |
| 3479 | + return; | |
| 3480 | + } | |
| 3481 | + | |
| 3482 | + $items = $this->get_action_items(); | |
| 3483 | + | |
| 3484 | + // Only the faults reach this surface, so count those before deciding | |
| 3485 | + // whether the carousel stylesheet is worth printing. | |
| 3486 | + $rendered = 0; | |
| 3487 | + | |
| 3488 | + foreach ( $items as $item ) { | |
| 3489 | + $status = Helper::get_string_value( $item['status'] ?? '' ); | |
| 3490 | + | |
| 3491 | + if ( 'success' !== $status && '' !== $status ) { | |
| 3492 | + $rendered++; | |
| 3493 | + } | |
| 3494 | + } | |
| 3495 | + | |
| 3496 | + if ( 0 === $rendered ) { | |
| 3497 | + return; | |
| 3498 | + } | |
| 3499 | + | |
| 3500 | + $this->enqueue_notice_response_script(); | |
| 3501 | + | |
| 3502 | + foreach ( $items as $item ) { | |
| 3503 | + $status = Helper::get_string_value( $item['status'] ?? '' ); | |
| 3504 | + | |
| 3505 | + // Passing checks belong in the SureForms panel, not in wp-admin. A | |
| 3506 | + // notice that says nothing is wrong is noise on every page load. | |
| 3507 | + if ( 'success' === $status || '' === $status ) { | |
| 3508 | + continue; | |
| 3509 | + } | |
| 3510 | + | |
| 3511 | + // A fault reads as an error; advice reads as a warning. Both are shown, | |
| 3512 | + // but they are not the same kind of message and should not look alike. | |
| 3513 | + $class = 'error' === $status ? 'notice-error' : 'notice-warning'; | |
| 3514 | + ?> | |
| 3515 | + <div class="notice srfm-action-item-notice <?php echo esc_attr( $class ); ?>"> | |
| 3516 | + <?php | |
| 3517 | + /* | |
| 3518 | + * Guarded like every sibling key. A filter item carrying only | |
| 3519 | + * id/status/cta_* is a shape this surface designs for, and reading | |
| 3520 | + * these unguarded is two PHP 8 undefined-key warnings plus an | |
| 3521 | + * esc_html( null ) deprecation on 8.1+. React tolerates the absence, | |
| 3522 | + * so leaving it would keep the two renderers disagreeing. | |
| 3523 | + */ | |
| 3524 | + ?> | |
| 3525 | + <p><strong><?php echo esc_html( Helper::get_string_value( $item['title'] ?? '' ) ); ?></strong></p> | |
| 3526 | + <p><?php echo esc_html( Helper::get_string_value( $item['message'] ?? '' ) ); ?></p> | |
| 3527 | + <?php | |
| 3528 | + // Self-serve first, so the emphasis follows the order rather than the | |
| 3529 | + // identity: whichever action leads is the primary button, and an item | |
| 3530 | + // with no guide still leads with Contact Support. | |
| 3531 | + $has_guide = ! empty( $item['guide_label'] ) && ! empty( $item['guide_url'] ); | |
| 3532 | + | |
| 3533 | + // Both keys, not either. An item contributed through | |
| 3534 | + // srfm_action_items may carry only guide_* keys -- reading cta_url | |
| 3535 | + // unguarded emits two PHP 8 undefined-key warnings and renders | |
| 3536 | + // href="" -- and a label without a URL renders an anchor that is not | |
| 3537 | + // keyboard focusable. React gates on the same pair. | |
| 3538 | + $has_cta = ! empty( $item['cta_label'] ) && ! empty( $item['cta_url'] ); | |
| 3539 | + ?> | |
| 3540 | + <p> | |
| 3541 | + <?php if ( $has_guide ) { ?> | |
| 3542 | + <a | |
| 3543 | + href="<?php echo esc_url( Helper::get_string_value( $item['guide_url'] ) ); ?>" | |
| 3544 | + class="button button-primary" | |
| 3545 | + data-srfm-notice-id="<?php echo esc_attr( Helper::get_string_value( $item['id'] ) ); ?>" | |
| 3546 | + data-srfm-button="<?php echo esc_attr( Helper::get_string_value( $item['guide_action'] ?? '' ) ); ?>" | |
| 3547 | + target="_blank" | |
| 3548 | + rel="noopener noreferrer" | |
| 3549 | + > | |
| 3550 | + <?php echo esc_html( $item['guide_label'] ); ?> | |
| 3551 | + </a> | |
| 3552 | + <?php } ?> | |
| 3553 | + <?php if ( $has_cta ) { ?> | |
| 3554 | + <a | |
| 3555 | + href="<?php echo esc_url( Helper::get_string_value( $item['cta_url'] ) ); ?>" | |
| 3556 | + class="<?php echo $has_guide ? 'button' : 'button button-primary'; ?>" | |
| 3557 | + data-srfm-notice-id="<?php echo esc_attr( Helper::get_string_value( $item['id'] ) ); ?>" | |
| 3558 | + data-srfm-button="<?php echo esc_attr( Helper::get_string_value( $item['cta_action'] ?? '' ) ); ?>" | |
| 3559 | + <?php | |
| 3560 | + // A mailto: must reach the mail client, not a new tab -- | |
| 3561 | + // there is no document to open, so _blank leaves a blank | |
| 3562 | + // one behind. | |
| 3563 | + if ( 0 !== strpos( Helper::get_string_value( $item['cta_url'] ), 'mailto:' ) ) { | |
| 3564 | + echo 'target="_blank" rel="noopener noreferrer"'; | |
| 3565 | + } | |
| 3566 | + ?> | |
| 3567 | + > | |
| 3568 | + <?php echo esc_html( $item['cta_label'] ); ?> | |
| 3569 | + </a> | |
| 3570 | + <?php } ?> | |
| 3571 | + <?php if ( ! empty( $item['dismissible'] ) ) { ?> | |
| 3572 | + <a href="<?php echo esc_url( $this->get_dismiss_action_item_url( Helper::get_string_value( $item['id'] ) ) ); ?>" class="button"> | |
| 3573 | + <?php esc_html_e( 'Dismiss', 'sureforms' ); ?> | |
| 3574 | + </a> | |
| 3575 | + <?php } ?> | |
| 3576 | + </p> | |
| 3577 | + </div> | |
| 3578 | + <?php | |
| 3579 | + } | |
| 3580 | + } | |
| 3581 | + | |
| 3582 | + /** | |
| 3583 | + * Dismiss an action item from the classic notice's link. | |
| 3584 | + * | |
| 3585 | + * Hooked - admin_post_srfm_dismiss_action_item_link. | |
| 3586 | + * | |
| 3587 | + * @since 2.12.6 | |
| 3588 | + * @return void | |
| 3589 | + */ | |
| 3590 | + public function handle_dismiss_action_item_link() { | |
| 3591 | + if ( ! Helper::current_user_can() ) { | |
| 3592 | + wp_die( esc_html__( 'You do not have permission to do this.', 'sureforms' ), 403 ); | |
| 3593 | + } | |
| 3594 | + | |
| 3595 | + check_admin_referer( 'srfm_dismiss_action_item' ); | |
| 3596 | + | |
| 3597 | + $item_id = isset( $_GET['item'] ) ? sanitize_key( wp_unslash( $_GET['item'] ) ) : ''; | |
| 3598 | + | |
| 3599 | + $this->dismiss_action_item( $item_id ); | |
| 3600 | + | |
| 3601 | + $referer = wp_get_referer(); | |
| 3602 | + | |
| 3603 | + wp_safe_redirect( $referer ? $referer : admin_url() ); | |
| 3604 | + exit; | |
| 3605 | + } | |
| 3606 | + | |
| 3607 | + /** | |
| 3608 | + * Whether a first-party warning is currently on screen. | |
| 3609 | + * | |
| 3610 | + * Asked from the show_if of the rating, Getting Started and Thank You notices, | |
| 3611 | + * all of which are gated on nothing being wrong. "Wrong" has to mean the same | |
| 3612 | + * thing here as it does to the person looking at the screen. | |
| 3613 | + * | |
| 3614 | + * It used to re-state the conditions instead of reading them, and the | |
| 3615 | + * restatement was narrower than the display: has_persistent_failures() reads | |
| 3616 | + * the `submission` counter alone, while the notices and the Form Checks panel | |
| 3617 | + * warn on any open failure in any of the three categories. So an open | |
| 3618 | + * notification or integration failure left this false, and the review ask | |
| 3619 | + * appeared directly beneath "We noticed a notification failure on Contact | |
| 3620 | + * Form". Submission was covered only incidentally, by FAULT_THRESHOLD being 1 -- | |
| 3621 | + * raise that and it would have joined them. | |
| 3622 | + * | |
| 3623 | + * Derived from get_first_party_action_items() now, which is the thing that | |
| 3624 | + * builds those warnings, so the gate cannot drift from the display again. | |
| 3625 | + * | |
| 3626 | + * Two constraints kept from the previous version. It must not call | |
| 3627 | + * get_action_items(): that records an impression as a side effect and must | |
| 3628 | + * never run from a show_if. And it reads the first-party set specifically, so | |
| 3629 | + * an item contributed through `srfm_action_items` cannot suppress notices that | |
| 3630 | + * have nothing to do with it. | |
| 3631 | + * | |
| 3632 | + * Returns false with logging disabled, which is what makes those notices | |
| 3633 | + * eligible again on a site that has turned this surface off. Intended: with the | |
| 3634 | + * surface off there is nothing being reported. | |
| 3635 | + * | |
| 3636 | + * @since 2.12.6 | |
| 3637 | + * @return bool | |
| 3638 | + */ | |
| 3639 | + public function has_action_item_warnings() { | |
| 3640 | + if ( ! Client_Logger::is_enabled() ) { | |
| 3641 | + return false; | |
| 3642 | + } | |
| 3643 | + | |
| 3644 | + foreach ( $this->get_first_party_action_items() as $item ) { | |
| 3645 | + if ( ! is_array( $item ) ) { | |
| 3646 | + continue; | |
| 3647 | + } | |
| 3648 | + | |
| 3649 | + $status = Helper::get_string_value( $item['status'] ?? '' ); | |
| 3650 | + | |
| 3651 | + // Matches the renderers: 'success' is a passing check and an empty | |
| 3652 | + // status is not a warning either, so neither suppresses anything. | |
| 3653 | + if ( 'success' !== $status && '' !== $status ) { | |
| 3654 | + return true; | |
| 3655 | + } | |
| 3656 | + } | |
| 3657 | + | |
| 3658 | + return false; | |
| 3659 | + } | |
| 3660 | + | |
| 3661 | + /** | |
| 3662 | + * Things on this site that need the owner's attention, newest concern first. | |
| 3663 | + * | |
| 3664 | + * Fed to the dashboard sidebar carousel. Each entry is self-describing so the | |
| 3665 | + * front end has no rules of its own to keep in sync -- adding a new item here | |
| 3666 | + * makes it appear with no JavaScript change. | |
| 3667 | + * | |
| 3668 | + * `dismissible` separates a fault from advice. A run of failed submissions is | |
| 3669 | + * not something to wave away, and clears itself when a submission succeeds. A | |
| 3670 | + * caching plugin being present is information, so it can be dismissed. | |
| 3671 | + * | |
| 3672 | + * @since 2.12.6 | |
| 3673 | + * @return array<int,array<string,mixed>> | |
| 3674 | + */ | |
| 3675 | + public function get_action_items() { | |
| 3676 | + if ( ! Helper::current_user_can() ) { | |
| 3677 | + return []; | |
| 3678 | + } | |
| 3679 | + | |
| 3680 | + // Memoised for the request. This runs twice on every admin page -- once | |
| 3681 | + // building the localisation payload and once in the classic renderer -- and | |
| 3682 | + // each open category reads a log excerpt. It also records an impression, so | |
| 3683 | + // running twice counted twice. Matches the $thankyou_prompt_cache and | |
| 3684 | + // $setup_card_cache pattern already in this class. | |
| 3685 | + if ( null !== self::$action_items_cache ) { | |
| 3686 | + return self::$action_items_cache; | |
| 3687 | + } | |
| 3688 | + | |
| 3689 | + // Logging off is the opt-out for this surface. Not because the counters go | |
| 3690 | + // stale -- Client_Logger::record_failure() has no enabled check, and the | |
| 3691 | + // notification and integration categories are written by direct calls in | |
| 3692 | + // inc/form-submit.php that keep counting accurately with logging off. It is | |
| 3693 | + // simply the switch a site owner has to turn these notices off, and it | |
| 3694 | + // covers our own items only: the filter below still runs, because a third | |
| 3695 | + // party's advisory has nothing to do with SureForms' logging toggle. | |
| 3696 | + $warnings = []; | |
| 3697 | + | |
| 3698 | + if ( Client_Logger::is_enabled() ) { | |
| 3699 | + $warnings = $this->get_first_party_action_items(); | |
| 3700 | + } | |
| 3701 | + | |
| 3702 | + $this->track_action_item_impressions( $warnings ); | |
| 3703 | + | |
| 3704 | + /** | |
| 3705 | + * Filter the dashboard action items. | |
| 3706 | + * | |
| 3707 | + * Each entry needs id, status ('warning' or 'success'), title, message, | |
| 3708 | + * cta_label, cta_url and dismissible. Only ids in | |
| 3709 | + * handle_dismiss_action_item()'s allowlist can actually be dismissed, so | |
| 3710 | + * adding a dismissible item here also needs a line there. | |
| 3711 | + * | |
| 3712 | + * A third-party item's cta_url is followed as a plain link. The prefilled | |
| 3713 | + * support email is built only for SureForms' own failure items, from its own | |
| 3714 | + * client error log. | |
| 3715 | + * | |
| 3716 | + * @since 2.12.6 | |
| 3717 | + * | |
| 3718 | + * @param array<int,array<string,mixed>> $items Action items. | |
| 3719 | + */ | |
| 3720 | + $items = Helper::apply_filters_as_array( 'srfm_action_items', $warnings ); | |
| 3721 | + | |
| 3722 | + // Both URLs normalised once, here, rather than trusting each renderer to do | |
| 3723 | + // it. Two things are being fixed at once. | |
| 3724 | + // | |
| 3725 | + // The scheme: the classic notice runs esc_url() and drops anything outside | |
| 3726 | + // the allowlist, while React assigns href directly and react-dom 18 leaves | |
| 3727 | + // a javascript: URL intact -- its sanitizeURL() only warns, and the warning | |
| 3728 | + // is compiled out of the production build. esc_url_raw() with the same | |
| 3729 | + // allowlist closes both. | |
| 3730 | + // | |
| 3731 | + // The ampersands: Helper::get_sureforms_website_url() returns an esc_url()'d | |
| 3732 | + // string, so a URL with UTM parameters arrives with & in it. In an HTML | |
| 3733 | + // href the browser decodes that; React sets the property directly, so the | |
| 3734 | + // entity would be sent to the server verbatim. Decoded to one raw form here, | |
| 3735 | + // and each renderer escapes it for its own context. | |
| 3736 | + foreach ( $items as $index => $item ) { | |
| 3737 | + // A filter may hand back an object. isset() on it returns false, which | |
| 3738 | + // would slip the item past both the URL normalisation and the | |
| 3739 | + // sanitize_key() below without any sign that it had. | |
| 3740 | + if ( ! is_array( $item ) ) { | |
| 3741 | + continue; | |
| 3742 | + } | |
| 3743 | + | |
| 3744 | + foreach ( [ 'cta_url', 'guide_url' ] as $key ) { | |
| 3745 | + if ( ! isset( $item[ $key ] ) ) { | |
| 3746 | + continue; | |
| 3747 | + } | |
| 3748 | + | |
| 3749 | + $items[ $index ][ $key ] = esc_url_raw( | |
| 3750 | + wp_specialchars_decode( Helper::get_string_value( $item[ $key ] ), ENT_QUOTES ), | |
| 3751 | + [ 'http', 'https', 'mailto' ] | |
| 3752 | + ); | |
| 3753 | + } | |
| 3754 | + | |
| 3755 | + // The id ends up in the notice's data-srfm-notice-id attribute, which | |
| 3756 | + // notice-response.js matches on, and in the dismiss allowlist. | |
| 3757 | + // sanitize_key() is what both dismiss paths already apply, so applying | |
| 3758 | + // it once here means the value that renders is the value they compare | |
| 3759 | + // against -- and a filter-contributed id carrying a quote cannot break | |
| 3760 | + // the selector. | |
| 3761 | + if ( isset( $item['id'] ) ) { | |
| 3762 | + $items[ $index ]['id'] = sanitize_key( Helper::get_string_value( $item['id'] ) ); | |
| 3763 | + } | |
| 3764 | + } | |
| 3765 | + | |
| 3766 | + self::$action_items_cache = $items; | |
| 3767 | + | |
| 3768 | + return $items; | |
| 3769 | + } | |
| 3770 | + | |
| 3771 | + /** | |
| 3772 | + * Dismiss one action item. | |
| 3773 | + * | |
| 3774 | + * Hooked - wp_ajax_srfm_dismiss_action_item. | |
| 3775 | + * | |
| 3776 | + * Only items get_action_items() marks dismissible can be dismissed, so a | |
| 3777 | + * crafted request cannot silence a genuine fault. | |
| 3778 | + * | |
| 3779 | + * @since 2.12.6 | |
| 3780 | + * @return void | |
| 3781 | + */ | |
| 3782 | + public function handle_dismiss_action_item() { | |
| 3783 | + if ( ! Helper::current_user_can() ) { | |
| 3784 | + wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 ); | |
| 3785 | + return; | |
| 3786 | + } | |
| 3787 | + | |
| 3788 | + if ( ! check_ajax_referer( 'srfm_dismiss_action_item', 'nonce', false ) ) { | |
| 3789 | + wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 ); | |
| 3790 | + return; | |
| 3791 | + } | |
| 3792 | + | |
| 3793 | + $item_id = isset( $_POST['item_id'] ) ? sanitize_key( wp_unslash( $_POST['item_id'] ) ) : ''; | |
| 3794 | + | |
| 3795 | + if ( ! $this->dismiss_action_item( $item_id ) ) { | |
| 3796 | + wp_send_json_error( [ 'message' => __( 'Invalid parameters.', 'sureforms' ) ], 400 ); | |
| 3797 | + return; | |
| 3798 | + } | |
| 3799 | + | |
| 3800 | + wp_send_json_success(); | |
| 3801 | + } | |
| 3802 | + | |
| 3803 | + /** | |
| 3804 | + * The stylesheet for the notice carousel. | |
| 3805 | + * | |
| 3806 | + * In a stylesheet rather than inline style assignments in | |
| 3807 | + * notice-response.js, so the rules use logical properties and an RTL sheet can | |
| 3808 | + * override them. | |
| 3809 | + * | |
| 3810 | + * Only the classic wp-admin surface needs these. The SureForms dashboard is | |
| 3811 | + * styled by the Tailwind build, so nothing here reaches it. | |
| 3812 | + * | |
| 3813 | + * Attached to a registered handle with no file of its own, which is the WP way | |
| 3814 | + * to ship CSS tied to one script. | |
| 3815 | + * | |
| 3816 | + * Hooked to admin_enqueue_scripts rather than called from the renderer. | |
| 3817 | + * admin_notices fires from admin-header.php after admin_print_styles has | |
| 3818 | + * flushed the head, so enqueuing there reached the page only through core's | |
| 3819 | + * late-styles pass in the footer -- and until that parsed, every stacked notice | |
| 3820 | + * rendered expanded before collapsing to one, the carousel controls overlapped | |
| 3821 | + * the notice text, and the defensive `display: none` on the hidden payload was | |
| 3822 | + * inert, which is the exact window that rule exists for. | |
| 3823 | + * | |
| 3824 | + * @since 2.12.7 | |
| 3825 | + * @return void | |
| 3826 | + */ | |
| 3827 | + public function enqueue_action_item_styles() { | |
| 3828 | + if ( wp_style_is( 'srfm-action-items', 'enqueued' ) ) { | |
| 3829 | + return; | |
| 3830 | + } | |
| 3831 | + | |
| 3832 | + if ( ! Helper::current_user_can() ) { | |
| 3833 | + return; | |
| 3834 | + } | |
| 3835 | + | |
| 3836 | + // Nothing to style unless the carousel is actually going to build. Cheap to | |
| 3837 | + // ask: get_action_items() is memoised for the request. | |
| 3838 | + // | |
| 3839 | + // Two, not one: notice-response.js bails below two cards, so these rules | |
| 3840 | + // have no consumer on a site with a single open fault. | |
| 3841 | + $notices = 0; | |
| 3842 | + | |
| 3843 | + foreach ( $this->get_action_items() as $item ) { | |
| 3844 | + $status = Helper::get_string_value( is_array( $item ) ? $item['status'] ?? '' : '' ); | |
| 3845 | + | |
| 3846 | + if ( 'success' !== $status && '' !== $status ) { | |
| 3847 | + $notices++; | |
| 3848 | + } | |
| 3849 | + } | |
| 3850 | + | |
| 3851 | + if ( $notices < 2 ) { | |
| 3852 | + return; | |
| 3853 | + } | |
| 3854 | + | |
| 3855 | + wp_register_style( 'srfm-action-items', false, [], SRFM_VER ); | |
| 3856 | + wp_enqueue_style( 'srfm-action-items' ); | |
| 3857 | + | |
| 3858 | + $css = <<<'CSS' | |
| 3859 | +.srfm-action-item-carousel { position: relative; } | |
| 3860 | +.srfm-action-item-carousel .srfm-action-item-notice { padding-inline-end: var(--srfm-carousel-reserve, 130px); } | |
| 3861 | +/* [hidden] is only a UA rule, and WordPress sets display on .notice, so a | |
| 3862 | + third-party admin sheet can otherwise put a notice the carousel has hidden back | |
| 3863 | + on screen. */ | |
| 3864 | +.srfm-action-item-carousel .srfm-action-item-notice[hidden] { display: none; } | |
| 3865 | +.srfm-action-item-carousel-nav { | |
| 3866 | + position: absolute; | |
| 3867 | + top: 8px; | |
| 3868 | + inset-inline-end: 12px; | |
| 3869 | + margin: 0; | |
| 3870 | + display: flex; | |
| 3871 | + align-items: center; | |
| 3872 | + gap: 8px; | |
| 3873 | +} | |
| 3874 | +CSS; | |
| 3875 | + | |
| 3876 | + wp_add_inline_style( 'srfm-action-items', $css ); | |
| 3877 | + } | |
| 3878 | + | |
| 3879 | + /** | |
| 3880 | + * SureForms' own action items, before the filter. | |
| 3881 | + * | |
| 3882 | + * Split out so the Enable Logs gate in get_action_items() can sit above this | |
| 3883 | + * rather than above `srfm_action_items`. An item contributed through that | |
| 3884 | + * filter has nothing to do with SureForms' logging toggle, and was being | |
| 3885 | + * silenced by it. | |
| 3886 | + * | |
| 3887 | + * @since 2.12.7 | |
| 3888 | + * @return array<int,array<string,mixed>> | |
| 3889 | + */ | |
| 3890 | + private function get_first_party_action_items() { | |
| 3891 | + $warnings = []; | |
| 3892 | + $open = Client_Logger::get_open_failures(); | |
| 3893 | + | |
| 3894 | + // One item per category. They read differently to a site owner and must not | |
| 3895 | + // be collapsed: submissions failing means visitors cannot reach you, a | |
| 3896 | + // notification failing means you are not hearing about entries that did | |
| 3897 | + // save, an integration failing means a third party is not receiving them. | |
| 3898 | + $categories = [ | |
| 3899 | + 'submission' => [ | |
| 3900 | + 'id' => 'form_submission_error', | |
| 3901 | + /* translators: %s: form title. */ | |
| 3902 | + 'title' => __( 'We noticed a form submission failure on %s.', 'sureforms' ), | |
| 3903 | + 'generic' => __( 'We noticed a form submission failure.', 'sureforms' ), | |
| 3904 | + 'message' => __( 'Visitors may not be able to reach you, and their entries were not saved.', 'sureforms' ), | |
| 3905 | + ], | |
| 3906 | + 'notification' => [ | |
| 3907 | + 'id' => 'notification_error', | |
| 3908 | + /* translators: %s: form title. */ | |
| 3909 | + 'title' => __( 'We noticed a notification failure on %s.', 'sureforms' ), | |
| 3910 | + 'generic' => __( 'We noticed a notification failure.', 'sureforms' ), | |
| 3911 | + 'message' => __( 'The entry was saved, but we could not send the email about it. New entries may be coming in without you knowing.', 'sureforms' ), | |
| 3912 | + // Email is the one failure here a site owner can usually fix without | |
| 3913 | + // us: it is almost always SMTP not being configured. Offer the guide | |
| 3914 | + // alongside support rather than making them wait for a reply. | |
| 3915 | + 'guide' => Helper::get_sureforms_website_url( | |
| 3916 | + 'docs/troubleshooting-email-sending-in-sureforms/', | |
| 3917 | + [ | |
| 3918 | + 'utm_medium' => 'form_checks_notice', | |
| 3919 | + 'utm_content' => 'notification_error', | |
| 3920 | + ] | |
| 3921 | + ), | |
| 3922 | + ], | |
| 3923 | + 'integration' => [ | |
| 3924 | + 'id' => 'integration_error', | |
| 3925 | + /* translators: %s: form title. */ | |
| 3926 | + 'title' => __( 'We noticed an integration failure on %s.', 'sureforms' ), | |
| 3927 | + 'generic' => __( 'We noticed an integration failure.', 'sureforms' ), | |
| 3928 | + 'message' => __( 'The entry was saved, but we could not send it to a connected service.', 'sureforms' ), | |
| 3929 | + ], | |
| 3930 | + ]; | |
| 3931 | + | |
| 3932 | + foreach ( $categories as $category => $copy ) { | |
| 3933 | + if ( ! isset( $open[ $category ] ) ) { | |
| 3934 | + continue; | |
| 3935 | + } | |
| 3936 | + | |
| 3937 | + // Name the form. "A form is failing" is not actionable on a site with | |
| 3938 | + // twenty of them, and the title is the first thing anyone asks for. | |
| 3939 | + $form_title = Helper::get_string_value( $open[ $category ]['form_title'] ?? '' ); | |
| 3940 | + | |
| 3941 | + $warning = [ | |
| 3942 | + 'id' => $copy['id'], | |
| 3943 | + 'status' => 'error', | |
| 3944 | + 'title' => '' !== $form_title | |
| 3945 | + ? sprintf( $copy['title'], $form_title ) | |
| 3946 | + : $copy['generic'], | |
| 3947 | + 'message' => $copy['message'], | |
| 3948 | + // Straight to a composed email, as 2.12.6 did. The subject, the | |
| 3949 | + // diagnostics and the log tail are already in it, so reporting a | |
| 3950 | + // fault is one click and a send. | |
| 3951 | + // | |
| 3952 | + // Built when the page renders, so the report ships in the href of the | |
| 3953 | + // classic notice on every admin screen and in srfm_admin.action_items | |
| 3954 | + // on the dashboard, both for capable users only. Its log comes from | |
| 3955 | + // the client error log, which any visitor with a form's submit token | |
| 3956 | + // can write to, so treat it as untrusted text. It is inert here: | |
| 3957 | + // http_build_query() percent-encodes all of it, so it cannot break | |
| 3958 | + // out of the attribute or add &cc= / &bcc= to the mailto:, and the | |
| 3959 | + // URL is length-capped. Building it on click instead would bring back | |
| 3960 | + // an AJAX round trip and a nonce to open an email -- the 2.12.7 | |
| 3961 | + // dialog's machinery -- for text the person reads in the composer | |
| 3962 | + // before anything is sent. | |
| 3963 | + 'cta_label' => __( 'Contact Support', 'sureforms' ), | |
| 3964 | + 'cta_url' => $this->get_support_contact_url( $category, $form_title ), | |
| 3965 | + 'cta_action' => 'contact_support', | |
| 3966 | + 'dismissible' => false, | |
| 3967 | + ]; | |
| 3968 | + | |
| 3969 | + // A second, optional action. Absent keys render nothing, so a category | |
| 3970 | + // without a guide needs no branch in either renderer, and neither does | |
| 3971 | + // an item contributed through srfm_action_items. | |
| 3972 | + if ( ! empty( $copy['guide'] ) ) { | |
| 3973 | + $warning['guide_label'] = __( 'Help Me Fix', 'sureforms' ); | |
| 3974 | + $warning['guide_url'] = $copy['guide']; | |
| 3975 | + $warning['guide_action'] = 'help_me_fix'; | |
| 3976 | + } | |
| 3977 | + | |
| 3978 | + $warnings[] = $warning; | |
| 3979 | + } | |
| 3980 | + | |
| 3981 | + $caching_plugin = Helper::get_active_caching_plugin(); | |
| 3982 | + | |
| 3983 | + if ( '' === $caching_plugin ) { | |
| 3984 | + return $warnings; | |
| 3985 | + } | |
| 3986 | + | |
| 3987 | + // Read here rather than at the top: with no caching plugin active nothing | |
| 3988 | + // consults it, and this is the only dismissible item. | |
| 3989 | + $dismissed = Helper::get_array_value( Helper::get_srfm_option( 'dismissed_action_items', [] ) ); | |
| 3990 | + | |
| 3991 | + if ( ! in_array( 'caching_plugin', $dismissed, true ) ) { | |
| 3992 | + $warnings[] = [ | |
| 3993 | + 'id' => 'caching_plugin', | |
| 3994 | + 'status' => 'warning', | |
| 3995 | + 'title' => sprintf( | |
| 3996 | + /* translators: %s: caching plugin name. */ | |
| 3997 | + __( '%s may interfere with your forms.', 'sureforms' ), | |
| 3998 | + $caching_plugin | |
| 3999 | + ), | |
| 4000 | + 'message' => __( 'Caching can show visitors an old copy of your form, or load its scripts in the wrong order.', 'sureforms' ), | |
| 4001 | + 'cta_label' => __( 'Help Me Fix', 'sureforms' ), | |
| 4002 | + 'cta_url' => Helper::get_caching_plugin_doc_url(), | |
| 4003 | + 'cta_action' => 'help_me_fix', | |
| 4004 | + 'dismissible' => true, | |
| 4005 | + ]; | |
| 4006 | + } | |
| 4007 | + | |
| 4008 | + return $warnings; | |
| 4009 | + } | |
| 4010 | + | |
| 4011 | + /** | |
| 4012 | + * Nonce-protected URL that repairs the entries table. | |
| 4013 | + * | |
| 4014 | + * Shared by both notice surfaces so there is one repair route, one nonce and one | |
| 4015 | + * place that counts the click. Private, so it stays off the public API and out of | |
| 4016 | + * the test-coverage gate. | |
| 4017 | + * | |
| 4018 | + * @since 2.12.6 | |
| 4019 | + * @return string | |
| 4020 | + */ | |
| 4021 | + private function get_database_repair_url() { | |
| 4022 | + return wp_nonce_url( | |
| 4023 | + admin_url( 'admin-post.php?action=srfm_repair_entries_table' ), | |
| 4024 | + 'srfm_repair_entries_table' | |
| 4025 | + ); | |
| 4026 | + } | |
| 4027 | + | |
| 4028 | + /** | |
| 4029 | + * The database notice body, which differs by what the repair will actually do. | |
| 4030 | + * | |
| 4031 | + * Two outcomes are possible and they are not equivalent to the person clicking: | |
| 4032 | + * when the entries table exists under a different prefix — a changed | |
| 4033 | + * `$table_prefix`, a restored dump, a security plugin that renamed tables and | |
| 4034 | + * skipped ours — the repair renames it back and every stored entry comes with | |
| 4035 | + * it. When there is nothing to adopt, the repair creates an empty table and the | |
| 4036 | + * old submissions are not recoverable from here. | |
| 4037 | + * | |
| 4038 | + * Promising the wrong one is how a maintenance prompt turns into a complaint, so | |
| 4039 | + * the copy states which is about to happen. | |
| 4040 | + * | |
| 4041 | + * @since 2.12.6 | |
| 4042 | + * @return string | |
| 4043 | + */ | |
| 4044 | + private function get_database_notice_message() { | |
| 4045 | + if ( '' !== Register::get_adoptable_entries_table() ) { | |
| 4046 | + return __( 'SureForms found your form entries stored under a different database table prefix. Reconnecting them takes a moment, and your existing entries will be kept.', 'sureforms' ); | |
| 4047 | + } | |
| 4048 | + | |
| 4049 | + return __( 'SureForms needs to update your database before it can save new form entries. This only takes a moment and will not change your forms or existing content. Entries submitted before now cannot be recovered from here.', 'sureforms' ); | |
| 4050 | + } | |
| 4051 | + | |
| 4052 | + /** | |
| 4053 | + * Count one sighting of the database notice, at most once per user per day. | |
| 4054 | + * | |
| 4055 | + * While the table is missing the notice renders on every admin page load, on two | |
| 4056 | + * surfaces. Counting each render would rewrite the autoloaded `srfm_options` blob | |
| 4057 | + * on every pageview of a site that is already broken, and one site left unfixed | |
| 4058 | + * would dominate the aggregate. Throttling to a day per user answers the question | |
| 4059 | + * that matters — how many people are seeing this — for one write. | |
| 4060 | + * | |
| 4061 | + * @since 2.12.6 | |
| 4062 | + * @return void | |
| 4063 | + */ | |
| 4064 | + private function track_database_notice_impression() { | |
| 4065 | + $user_id = get_current_user_id(); | |
| 4066 | + | |
| 4067 | + if ( ! $user_id ) { | |
| 4068 | + return; | |
| 4069 | + } | |
| 4070 | + | |
| 4071 | + $key = 'srfm_db_notice_seen_' . $user_id; | |
| 4072 | + | |
| 4073 | + if ( get_transient( $key ) ) { | |
| 4074 | + return; | |
| 4075 | + } | |
| 4076 | + | |
| 4077 | + set_transient( $key, 1, DAY_IN_SECONDS ); | |
| 4078 | + | |
| 4079 | + Analytics::events()->track( 'database_error_notice_shown', 'entries' ); | |
| 4080 | + } | |
| 4081 | + | |
| 4082 | + /** | |
| 2907 | 4083 | * Build the setup-card payload (uncached). See get_form_setup_card(). |
| 2908 | 4084 | * |
| 2909 | 4085 | * @since 2.12.4 |
| 2910 | 4086 | * @return array<string,mixed>|null Card payload, or null when there is no candidate. |
| @@ -3114,27 +4290,93 @@ | ||
| 3114 | 4290 | // per-step claim — so it is always accurate whatever the user has since |
| 3115 | 4291 | // changed, while the action buttons point to the specific things to finish. |
| 3116 | 4292 | $sentence = __( 'We’ve already created this form for you. Finish customising it so it’s ready to collect real submissions.', 'sureforms' ); |
| 3117 | 4293 | |
| 4294 | + return self::build_srfm_notice_markup( | |
| 4295 | + sprintf( | |
| 4296 | + /* translators: %s: form name. */ | |
| 4297 | + __( 'Finish setting up “%s”', 'sureforms' ), | |
| 4298 | + $form['title'] | |
| 4299 | + ), | |
| 4300 | + $sentence, | |
| 4301 | + [ | |
| 4302 | + [ | |
| 4303 | + 'text' => __( 'Edit form', 'sureforms' ), | |
| 4304 | + 'url' => $form['edit_url'], | |
| 4305 | + 'primary' => true, | |
| 4306 | + 'class' => 'srfm-ty-edit-form', | |
| 4307 | + 'external' => true, | |
| 4308 | + ], | |
| 4309 | + [ | |
| 4310 | + 'text' => __( 'Edit the Thank You message', 'sureforms' ), | |
| 4311 | + 'url' => $form['thankyou_url'], | |
| 4312 | + 'class' => 'srfm-ty-edit-thankyou', | |
| 4313 | + 'external' => true, | |
| 4314 | + ], | |
| 4315 | + [ | |
| 4316 | + 'text' => __( 'Set where replies go', 'sureforms' ), | |
| 4317 | + 'url' => $form['replies_url'], | |
| 4318 | + 'class' => 'srfm-ty-set-replies', | |
| 4319 | + 'external' => true, | |
| 4320 | + ], | |
| 4321 | + ] | |
| 4322 | + ); | |
| 4323 | + } | |
| 4324 | + | |
| 4325 | + /** | |
| 4326 | + * Build the shared SureForms admin-notice body: title, sentence, action row. | |
| 4327 | + * | |
| 4328 | + * One builder for every SureForms notice so they cannot drift into looking like | |
| 4329 | + * two different plugins. Everything is escaped here rather than by the caller — | |
| 4330 | + * the notices library runs the result through wp_kses_post(), which would strip | |
| 4331 | + * anything richer anyway. | |
| 4332 | + * | |
| 4333 | + * @param string $title Notice heading. | |
| 4334 | + * @param string $text Supporting sentence. | |
| 4335 | + * @param array<int,array<string,mixed>> $actions Action links. Each accepts | |
| 4336 | + * text, url, and optionally | |
| 4337 | + * primary, class, external, | |
| 4338 | + * dismiss and snooze (seconds). | |
| 4339 | + * @since 2.12.6 | |
| 4340 | + * @return string | |
| 4341 | + */ | |
| 4342 | + private static function build_srfm_notice_markup( $title, $text, $actions ) { | |
| 3118 | 4343 | ob_start(); |
| 3119 | 4344 | ?> |
| 3120 | - <p class="srfm-thankyou-notice__title"> | |
| 4345 | + <p class="srfm-notice__title"><?php echo esc_html( $title ); ?></p> | |
| 4346 | + <p class="srfm-notice__text"><?php echo esc_html( $text ); ?></p> | |
| 4347 | + <p class="srfm-notice__actions"> | |
| 3121 | 4348 | <?php |
| 3122 | - echo esc_html( | |
| 3123 | - sprintf( | |
| 3124 | - /* translators: %s: form name. */ | |
| 3125 | - __( 'Finish setting up “%s”', 'sureforms' ), | |
| 3126 | - $form['title'] | |
| 3127 | - ) | |
| 3128 | - ); | |
| 4349 | + foreach ( $actions as $action ) { | |
| 4350 | + if ( empty( $action['text'] ) || ! isset( $action['url'] ) ) { | |
| 4351 | + continue; | |
| 4352 | + } | |
| 4353 | + | |
| 4354 | + $classes = [ 'button' ]; | |
| 4355 | + | |
| 4356 | + if ( ! empty( $action['primary'] ) ) { | |
| 4357 | + $classes[] = 'button-primary'; | |
| 4358 | + } | |
| 4359 | + | |
| 4360 | + // astra-notice-close is what the library binds its dismiss handler to. | |
| 4361 | + if ( ! empty( $action['dismiss'] ) ) { | |
| 4362 | + $classes[] = 'astra-notice-close'; | |
| 4363 | + } | |
| 4364 | + | |
| 4365 | + if ( ! empty( $action['class'] ) ) { | |
| 4366 | + $classes[] = $action['class']; | |
| 4367 | + } | |
| 4368 | + ?> | |
| 4369 | + <a | |
| 4370 | + class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>" | |
| 4371 | + href="<?php echo esc_url( $action['url'] ); ?>" | |
| 4372 | + <?php echo empty( $action['snooze'] ) ? '' : ' data-repeat-notice-after="' . esc_attr( (string) $action['snooze'] ) . '"'; ?> | |
| 4373 | + <?php echo empty( $action['external'] ) ? '' : ' target="_blank" rel="noopener noreferrer"'; ?> | |
| 4374 | + ><?php echo esc_html( $action['text'] ); ?></a> | |
| 4375 | + <?php | |
| 4376 | + } | |
| 3129 | 4377 | ?> |
| 3130 | 4378 | </p> |
| 3131 | - <p class="srfm-thankyou-notice__text"><?php echo esc_html( $sentence ); ?></p> | |
| 3132 | - <p class="srfm-thankyou-notice__actions"> | |
| 3133 | - <a class="button button-primary srfm-ty-edit-form" href="<?php echo esc_url( $form['edit_url'] ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Edit form', 'sureforms' ); ?></a> | |
| 3134 | - <a class="button srfm-ty-edit-thankyou" href="<?php echo esc_url( $form['thankyou_url'] ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Edit the Thank You message', 'sureforms' ); ?></a> | |
| 3135 | - <a class="button srfm-ty-set-replies" href="<?php echo esc_url( $form['replies_url'] ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Set where replies go', 'sureforms' ); ?></a> | |
| 3136 | - </p> | |
| 3137 | 4379 | <?php |
| 3138 | 4380 | return (string) ob_get_clean(); |
| 3139 | 4381 | } |
| 3140 | 4382 | |
| @@ -3195,68 +4437,8 @@ | ||
| 3195 | 4437 | return 0 === strpos( $page, 'sureforms' ) || 0 === strpos( $page, 'srfm' ); |
| 3196 | 4438 | } |
| 3197 | 4439 | |
| 3198 | 4440 | /** |
| 3199 | - * Build the shared HTML markup for admin notices. | |
| 3200 | - * | |
| 3201 | - * @since 2.5.2 | |
| 3202 | - * | |
| 3203 | - * All text parameters must be pre-escaped by the caller (e.g. via esc_html__()). | |
| 3204 | - * URL parameters must be pre-escaped via esc_url(). | |
| 3205 | - * | |
| 3206 | - * @param string $heading The notice heading text (pre-escaped). | |
| 3207 | - * @param string $message The notice body text (pre-escaped). | |
| 3208 | - * @param string $cta_url The primary CTA URL (pre-escaped). | |
| 3209 | - * @param string $cta_text The primary CTA button text (pre-escaped). | |
| 3210 | - * @param string $snooze_text The snooze button text (pre-escaped). | |
| 3211 | - * @param string $dismiss_text The dismiss button text (pre-escaped). | |
| 3212 | - * @param int $snooze_duration Snooze duration in seconds for the data-repeat-notice-after attribute. | |
| 3213 | - * @param bool $external_cta Whether the CTA opens in a new tab and also dismisses the notice | |
| 3214 | - * via the astra-notice-close class. Default false. | |
| 3215 | - * @return string The notice HTML markup. | |
| 3216 | - */ | |
| 3217 | - private function build_notice_markup( $heading, $message, $cta_url, $cta_text, $snooze_text, $dismiss_text, $snooze_duration, $external_cta = false ) { | |
| 3218 | - $image_path = esc_url( SRFM_URL . 'admin/assets/sureforms-logo.png' ); | |
| 3219 | - $cta_class = $external_cta ? 'astra-notice-close button-primary' : 'button-primary'; | |
| 3220 | - $cta_attrs = $external_cta ? ' target="_blank" rel="noopener noreferrer"' : ''; | |
| 3221 | - | |
| 3222 | - return sprintf( | |
| 3223 | - '<div class="notice-image"> | |
| 3224 | - <img src="%1$s" class="custom-logo" alt="SureForms" itemprop="logo"> | |
| 3225 | - </div> | |
| 3226 | - <div class="notice-content"> | |
| 3227 | - <div class="notice-heading"> | |
| 3228 | - %2$s | |
| 3229 | - </div> | |
| 3230 | - %3$s<br /> | |
| 3231 | - <div class="astra-review-notice-container"> | |
| 3232 | - <a href="%4$s" class="%5$s"%6$s> | |
| 3233 | - %7$s | |
| 3234 | - </a> | |
| 3235 | - <span class="dashicons dashicons-clock" aria-hidden="true"></span> | |
| 3236 | - <a href="#" data-repeat-notice-after="%8$s" class="astra-notice-close"> | |
| 3237 | - %9$s | |
| 3238 | - </a> | |
| 3239 | - <span class="dashicons dashicons-smiley" aria-hidden="true"></span> | |
| 3240 | - <a href="#" class="astra-notice-close"> | |
| 3241 | - %10$s | |
| 3242 | - </a> | |
| 3243 | - </div> | |
| 3244 | - </div>', | |
| 3245 | - $image_path, | |
| 3246 | - $heading, | |
| 3247 | - $message, | |
| 3248 | - $cta_url, | |
| 3249 | - esc_attr( $cta_class ), | |
| 3250 | - $cta_attrs, | |
| 3251 | - $cta_text, | |
| 3252 | - $snooze_duration, | |
| 3253 | - $snooze_text, | |
| 3254 | - $dismiss_text | |
| 3255 | - ); | |
| 3256 | - } | |
| 3257 | - | |
| 3258 | - /** | |
| 3259 | 4441 | * Callback for displaying the rating notice conditionally. |
| 3260 | 4442 | * |
| 3261 | 4443 | * Returns true if the user has 3 or more published forms or 3 or more form entries. |
| 3262 | 4444 | * |
| @@ -3356,11 +4538,13 @@ | ||
| 3356 | 4538 | private function is_admin_pointer_visible() { |
| 3357 | 4539 | global $pagenow; |
| 3358 | 4540 | $allowed_pages = [ 'index.php', 'options-general.php' ]; |
| 3359 | 4541 | |
| 3360 | - // Do not show if pointer dismissed, accepted, or more than 1 form exists. | |
| 4542 | + // Do not show if promotions are hidden, the pointer was dismissed or | |
| 4543 | + // accepted, or more than 1 form exists. | |
| 3361 | 4544 | if ( |
| 3362 | - ! empty( Helper::get_srfm_option( 'pointer_popup_dismissed' ) ) | |
| 4545 | + Helper::hide_promotions() | |
| 4546 | + || ! empty( Helper::get_srfm_option( 'pointer_popup_dismissed' ) ) | |
| 3363 | 4547 | || ! empty( Helper::get_srfm_option( 'pointer_popup_accepted' ) ) |
| 3364 | 4548 | || (int) ( wp_count_posts( SRFM_FORMS_POST_TYPE )->publish ?? 0 ) > 1 |
| 3365 | 4549 | ) { |
| 3366 | 4550 | return false; |
| @@ -3372,5 +4556,502 @@ | ||
| 3372 | 4556 | |
| 3373 | 4557 | return false; |
| 3374 | 4558 | } |
| 3375 | 4559 | |
| 4560 | + /** | |
| 4561 | + * Nonced URL that dismisses one action item without JavaScript. | |
| 4562 | + * | |
| 4563 | + * The classic notice cannot use the AJAX dismissal the carousel uses, and | |
| 4564 | + * WordPress's own `is-dismissible` only hides the notice for that pageview. | |
| 4565 | + * | |
| 4566 | + * @param string $item_id Item to dismiss. | |
| 4567 | + * @since 2.12.6 | |
| 4568 | + * @return string | |
| 4569 | + */ | |
| 4570 | + private function get_dismiss_action_item_url( $item_id ) { | |
| 4571 | + return wp_nonce_url( | |
| 4572 | + add_query_arg( | |
| 4573 | + [ | |
| 4574 | + 'action' => 'srfm_dismiss_action_item_link', | |
| 4575 | + 'item' => $item_id, | |
| 4576 | + ], | |
| 4577 | + admin_url( 'admin-post.php' ) | |
| 4578 | + ), | |
| 4579 | + 'srfm_dismiss_action_item' | |
| 4580 | + ); | |
| 4581 | + } | |
| 4582 | + | |
| 4583 | + /** | |
| 4584 | + * Count one sighting of each warning, at most once per user per day. | |
| 4585 | + * | |
| 4586 | + * Throttled because the classic notice renders on every admin page: counting | |
| 4587 | + * each render would measure how much wp-admin someone browses, not how many | |
| 4588 | + * sites are affected. A day per user answers the question that matters -- how | |
| 4589 | + * many people are seeing this -- for one option write. | |
| 4590 | + * | |
| 4591 | + * Counts SureForms' own items only. It runs before `srfm_action_items`, so a | |
| 4592 | + * third party's contribution is not counted here -- SureForms has no name for | |
| 4593 | + * it and no analytics key that would mean anything. | |
| 4594 | + * | |
| 4595 | + * @param array<int,array<string,mixed>> $warnings SureForms' own items. | |
| 4596 | + * @since 2.12.6 | |
| 4597 | + * @return void | |
| 4598 | + */ | |
| 4599 | + private function track_action_item_impressions( $warnings ) { | |
| 4600 | + if ( empty( $warnings ) || wp_doing_ajax() ) { | |
| 4601 | + return; | |
| 4602 | + } | |
| 4603 | + | |
| 4604 | + $user_id = get_current_user_id(); | |
| 4605 | + | |
| 4606 | + if ( ! $user_id ) { | |
| 4607 | + return; | |
| 4608 | + } | |
| 4609 | + | |
| 4610 | + $counts = Helper::get_array_value( Helper::get_srfm_option( 'action_item_impressions', [] ) ); | |
| 4611 | + $changed = false; | |
| 4612 | + | |
| 4613 | + foreach ( $warnings as $warning ) { | |
| 4614 | + $item_id = Helper::get_string_value( $warning['id'] ?? '' ); | |
| 4615 | + | |
| 4616 | + if ( '' === $item_id ) { | |
| 4617 | + continue; | |
| 4618 | + } | |
| 4619 | + | |
| 4620 | + $seen_key = 'srfm_action_item_seen_' . $item_id . '_' . $user_id; | |
| 4621 | + | |
| 4622 | + if ( get_transient( $seen_key ) ) { | |
| 4623 | + continue; | |
| 4624 | + } | |
| 4625 | + | |
| 4626 | + set_transient( $seen_key, 1, DAY_IN_SECONDS ); | |
| 4627 | + | |
| 4628 | + $counts[ $item_id ] = Helper::get_integer_value( $counts[ $item_id ] ?? 0 ) + 1; | |
| 4629 | + $changed = true; | |
| 4630 | + | |
| 4631 | + // Cumulative, so $force = true: each new count is a new value and is | |
| 4632 | + // re-sent, while an identical repeat short-circuits inside track(). | |
| 4633 | + Analytics::events()->track( | |
| 4634 | + $item_id . '_notice_shown', | |
| 4635 | + (string) $counts[ $item_id ], | |
| 4636 | + [], | |
| 4637 | + true | |
| 4638 | + ); | |
| 4639 | + } | |
| 4640 | + | |
| 4641 | + if ( $changed ) { | |
| 4642 | + Helper::update_srfm_option( 'action_item_impressions', $counts ); | |
| 4643 | + } | |
| 4644 | + } | |
| 4645 | + | |
| 4646 | + /** | |
| 4647 | + * Record one interaction with a Form Checks notice, cumulatively. | |
| 4648 | + * | |
| 4649 | + * Both the value and `$force` matter. Analytics_Events::track() returns early | |
| 4650 | + * when the event name is already in `usage_events_pushed`, so a call with | |
| 4651 | + * `$force` omitted records each name at most once per site, ever -- the report | |
| 4652 | + * could then say whether a button had ever been clicked but not how often, and | |
| 4653 | + * these events exist to answer the second question. Sending a running total | |
| 4654 | + * with `$force = true` re-sends each new value while an identical repeat still | |
| 4655 | + * short-circuits inside track(). Same reasoning as | |
| 4656 | + * track_action_item_impressions(). | |
| 4657 | + * | |
| 4658 | + * @param string $event_name Analytics key from the allowlist. | |
| 4659 | + * @since 2.12.7 | |
| 4660 | + * @return void | |
| 4661 | + */ | |
| 4662 | + private function track_notice_event( $event_name ) { | |
| 4663 | + $counts = Helper::get_array_value( Helper::get_srfm_option( 'action_item_events', [] ) ); | |
| 4664 | + | |
| 4665 | + $counts[ $event_name ] = Helper::get_integer_value( $counts[ $event_name ] ?? 0 ) + 1; | |
| 4666 | + | |
| 4667 | + Helper::update_srfm_option( 'action_item_events', $counts ); | |
| 4668 | + | |
| 4669 | + Analytics::events()->track( $event_name, (string) $counts[ $event_name ], [], true ); | |
| 4670 | + } | |
| 4671 | + | |
| 4672 | + /** | |
| 4673 | + * A pre-addressed support email for the failure being reported. | |
| 4674 | + * | |
| 4675 | + * Restores the 2.12.6 behaviour: the button opens the composer the person | |
| 4676 | + * already uses, with the subject and the whole report written for them. What | |
| 4677 | + * 2.12.7 replaced it with -- a web form -- could carry neither the diagnostics | |
| 4678 | + * nor the log, so the button had to be gated behind copying them by hand and | |
| 4679 | + * pasting them into a field on the far side. That is three deliberate steps to | |
| 4680 | + * report a fault the plugin had already written up. | |
| 4681 | + * | |
| 4682 | + * The log is pasted into the body rather than attached because mailto has no | |
| 4683 | + * attachment parameter -- browsers drop any attempt to add one -- and it is a | |
| 4684 | + * tail rather than the whole file because a megabyte of JSON would exceed the | |
| 4685 | + * URL length every mail client enforces. The finished URL is capped at | |
| 4686 | + * SUPPORT_MAILTO_MAX_LENGTH, and the log is what gives way to meet it. | |
| 4687 | + * | |
| 4688 | + * The subject and body are English on every site, deliberately untranslated: | |
| 4689 | + * they are written for SureForms support, and plain literals cannot be | |
| 4690 | + * rewritten by a locale, a translation plugin or a gettext filter. | |
| 4691 | + * | |
| 4692 | + * @param string $category One of Client_Logger::CATEGORIES, naming the failure | |
| 4693 | + * being reported. An unknown or absent one gets | |
| 4694 | + * deliberately neutral wording via get_support_copy(). | |
| 4695 | + * @param string $form_title Form the failure was recorded against, when known. | |
| 4696 | + * @since 2.12.8 | |
| 4697 | + * @return string | |
| 4698 | + */ | |
| 4699 | + private function get_support_contact_url( $category, $form_title = '' ) { | |
| 4700 | + $copy = $this->get_support_copy( $category ); | |
| 4701 | + $host = Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) ); | |
| 4702 | + | |
| 4703 | + $subject = sprintf( $copy['subject'], $host ); | |
| 4704 | + | |
| 4705 | + $url = $this->build_support_mailto_within_limit( $category, $form_title, $subject ); | |
| 4706 | + | |
| 4707 | + // The last resort: the subject alone still names the problem and the site. | |
| 4708 | + if ( '' === $url ) { | |
| 4709 | + $url = $this->build_support_mailto( $subject ); | |
| 4710 | + } | |
| 4711 | + | |
| 4712 | + /** | |
| 4713 | + * Filter where the Contact Support action sends people. | |
| 4714 | + * | |
| 4715 | + * A white-label install wants its own inbox or its own support page, so both | |
| 4716 | + * are accepted. Returning an http(s) URL is supported but drops the body -- | |
| 4717 | + * a web form cannot carry it -- so the person arrives without the site | |
| 4718 | + * details or the debug log. They can still download the log from SureForms → | |
| 4719 | + * Settings → General, but nothing prompts them to, so a filter returning a | |
| 4720 | + * page should ask for it there. | |
| 4721 | + * | |
| 4722 | + * @since 2.12.7 | |
| 4723 | + * | |
| 4724 | + * @param string $url The pre-addressed mailto: URL. | |
| 4725 | + * @param string $category The failure being reported. | |
| 4726 | + * @param string $form_title Form the failure was recorded against, or ''. | |
| 4727 | + */ | |
| 4728 | + $filtered = Helper::get_string_value( apply_filters( 'srfm_support_contact_url', $url, $category, $form_title ) ); | |
| 4729 | + | |
| 4730 | + // Escaped after the filter, not before: the point of escaping here is that | |
| 4731 | + // neither renderer has to trust what comes back. | |
| 4732 | + $safe = esc_url_raw( $filtered, [ 'http', 'https', 'mailto' ] ); | |
| 4733 | + | |
| 4734 | + // Never empty. Contact Support is the only action that retires these | |
| 4735 | + // notices and they are dismissible => false, so returning '' for a filter | |
| 4736 | + // value that cannot survive escaping leaves an undismissable notice with | |
| 4737 | + // nothing on it that works. The unfiltered URL is built here rather than | |
| 4738 | + // supplied, so it always escapes. | |
| 4739 | + return '' !== $safe ? $safe : esc_url_raw( $url, [ 'mailto' ] ); | |
| 4740 | + } | |
| 4741 | + | |
| 4742 | + /** | |
| 4743 | + * The fullest support mailto: that fits SUPPORT_MAILTO_MAX_LENGTH. | |
| 4744 | + * | |
| 4745 | + * A mailto: is a URL and every client enforces a length limit on it. | |
| 4746 | + * Overrunning it does not truncate politely -- it drops the body, or the | |
| 4747 | + * whole link -- while the click still retires the notice. So the cap is on | |
| 4748 | + * the encoded URL, not the raw log: JSON-escaped non-ASCII text grows about | |
| 4749 | + * eight times once percent-encoded. The log gives way first, because the | |
| 4750 | + * site details are the part support cannot do without. | |
| 4751 | + * | |
| 4752 | + * @param string $category The failure being reported. | |
| 4753 | + * @param string $form_title Form the failure was recorded against, or ''. | |
| 4754 | + * @param string $subject Subject line. | |
| 4755 | + * @since 2.12.8 | |
| 4756 | + * @return string The URL, or '' when even the body without a log is too long. | |
| 4757 | + */ | |
| 4758 | + private function build_support_mailto_within_limit( $category, $form_title, $subject ) { | |
| 4759 | + // CRLF, not "\n". RFC 6068 leaves the line ending to the client and the | |
| 4760 | + // major composers normalise either, but Outlook renders a bare LF body as a | |
| 4761 | + // single run-on line -- which is exactly the report a support agent has to | |
| 4762 | + // read. | |
| 4763 | + $message = str_replace( "\n", "\r\n", $this->get_support_message( $category, $form_title ) ); | |
| 4764 | + | |
| 4765 | + foreach ( [ 1200, 800, 400, 0 ] as $budget ) { | |
| 4766 | + $log = 0 < $budget | |
| 4767 | + ? $this->get_support_log_block( $budget ) | |
| 4768 | + : '---' . "\n" . 'Debug log left out to keep this email short enough to send. The full log can be downloaded from SureForms → Settings → General.'; | |
| 4769 | + | |
| 4770 | + $url = $this->build_support_mailto( $subject, $message . "\r\n\r\n" . str_replace( "\n", "\r\n", $log ) ); | |
| 4771 | + | |
| 4772 | + if ( strlen( $url ) <= self::SUPPORT_MAILTO_MAX_LENGTH ) { | |
| 4773 | + return $url; | |
| 4774 | + } | |
| 4775 | + } | |
| 4776 | + | |
| 4777 | + return ''; | |
| 4778 | + } | |
| 4779 | + | |
| 4780 | + /** | |
| 4781 | + * A mailto: to the support inbox. | |
| 4782 | + * | |
| 4783 | + * @param string $subject Subject line. | |
| 4784 | + * @param string $body Body, with CRLF line endings. Omitted when empty. | |
| 4785 | + * @since 2.12.8 | |
| 4786 | + * @return string | |
| 4787 | + */ | |
| 4788 | + private function build_support_mailto( $subject, $body = '' ) { | |
| 4789 | + $query = [ 'subject' => $subject ]; | |
| 4790 | + | |
| 4791 | + if ( '' !== $body ) { | |
| 4792 | + $query['body'] = $body; | |
| 4793 | + } | |
| 4794 | + | |
| 4795 | + return 'mailto:' . self::SUPPORT_EMAIL . '?' . http_build_query( | |
| 4796 | + $query, | |
| 4797 | + '', | |
| 4798 | + '&', | |
| 4799 | + // RFC 3986, so a space is %20 rather than +. A mail client reading a | |
| 4800 | + // mailto: body decodes it as a URI, not as form data, so + arrives as a | |
| 4801 | + // literal plus in every word gap. | |
| 4802 | + PHP_QUERY_RFC3986 | |
| 4803 | + ); | |
| 4804 | + } | |
| 4805 | + | |
| 4806 | + /** | |
| 4807 | + * The log tail, formatted for pasting. | |
| 4808 | + * | |
| 4809 | + * The budget is a parameter because it goes into a mailto: URL, and | |
| 4810 | + * get_support_contact_url() lowers it until the encoded URL fits. | |
| 4811 | + * | |
| 4812 | + * @param int $max_chars Characters of log to include. | |
| 4813 | + * @since 2.12.7 | |
| 4814 | + * @return string | |
| 4815 | + */ | |
| 4816 | + private function get_support_log_block( $max_chars = 1200 ) { | |
| 4817 | + $log = Client_Logger::get_tail( $max_chars ); | |
| 4818 | + $block = '---' . "\n"; | |
| 4819 | + | |
| 4820 | + if ( '' === $log['text'] ) { | |
| 4821 | + return $block . 'Debug log: no entries recorded.'; | |
| 4822 | + } | |
| 4823 | + | |
| 4824 | + $block .= sprintf( | |
| 4825 | + 'Debug log (most recent %1$d of %2$d entries)', | |
| 4826 | + $log['shown'], | |
| 4827 | + $log['total'] | |
| 4828 | + ) . "\n"; | |
| 4829 | + | |
| 4830 | + // Fenced so it survives a reply and reads as data rather than prose wherever | |
| 4831 | + // Markdown is rendered. | |
| 4832 | + $block .= '```' . "\n" . $log['text'] . "\n" . '```'; | |
| 4833 | + | |
| 4834 | + if ( $log['shown'] < $log['total'] ) { | |
| 4835 | + $block .= "\n\n" . 'Older entries were left out to keep this excerpt readable. The full log can be downloaded from SureForms → Settings → General.'; | |
| 4836 | + } | |
| 4837 | + | |
| 4838 | + return $block; | |
| 4839 | + } | |
| 4840 | + | |
| 4841 | + /** | |
| 4842 | + * Subject and countless opening line for one kind of failure. | |
| 4843 | + * | |
| 4844 | + * Both come from here so they cannot drift apart: a subject naming one problem | |
| 4845 | + * over a body describing another is worse than either alone. The counted form | |
| 4846 | + * of the opening line lives in get_support_count_sentence(). | |
| 4847 | + * | |
| 4848 | + * An unknown or absent category gets deliberately neutral wording. The | |
| 4849 | + * alternative -- defaulting to the submission copy -- states something specific | |
| 4850 | + * that may not be true, and an item contributed through srfm_action_items has no | |
| 4851 | + * category at all. | |
| 4852 | + * | |
| 4853 | + * @param string $category One of Client_Logger::CATEGORIES. | |
| 4854 | + * @since 2.12.7 | |
| 4855 | + * @return array{subject:string,anon:string} | |
| 4856 | + */ | |
| 4857 | + private function get_support_copy( $category ) { | |
| 4858 | + $copy = [ | |
| 4859 | + 'submission' => [ | |
| 4860 | + 'subject' => 'SureForms: form submissions are failing on %s', | |
| 4861 | + 'anon' => 'SureForms has recorded form submissions on %s that could not be completed.', | |
| 4862 | + ], | |
| 4863 | + 'notification' => [ | |
| 4864 | + 'subject' => 'SureForms: notification emails are not being sent on %s', | |
| 4865 | + 'anon' => 'SureForms saved entries on %s but could not send the notification emails for them.', | |
| 4866 | + ], | |
| 4867 | + 'integration' => [ | |
| 4868 | + 'subject' => 'SureForms: an integration is not receiving entries on %s', | |
| 4869 | + 'anon' => 'SureForms saved entries on %s but could not pass them to a connected service.', | |
| 4870 | + ], | |
| 4871 | + ]; | |
| 4872 | + | |
| 4873 | + if ( isset( $copy[ $category ] ) ) { | |
| 4874 | + return $copy[ $category ]; | |
| 4875 | + } | |
| 4876 | + | |
| 4877 | + return [ | |
| 4878 | + 'subject' => 'SureForms: a problem with the forms on %s', | |
| 4879 | + 'anon' => 'SureForms has recorded a problem with the forms on %s.', | |
| 4880 | + ]; | |
| 4881 | + } | |
| 4882 | + | |
| 4883 | + /** | |
| 4884 | + * The sentence that opens the support email, with the failure count in it. | |
| 4885 | + * | |
| 4886 | + * English only, like the rest of the support email, so `1 === $count` is the | |
| 4887 | + * whole plural rule. | |
| 4888 | + * | |
| 4889 | + * @param string $category One of Client_Logger::CATEGORIES. Unknown or absent | |
| 4890 | + * gets neutral wording rather than a specific claim. | |
| 4891 | + * @param int $count Failures recorded for that category. | |
| 4892 | + * @since 2.12.7 | |
| 4893 | + * @return string | |
| 4894 | + */ | |
| 4895 | + private function get_support_count_sentence( $category, $count ) { | |
| 4896 | + switch ( $category ) { | |
| 4897 | + case 'submission': | |
| 4898 | + return sprintf( | |
| 4899 | + ( 1 === $count | |
| 4900 | + ? 'SureForms has recorded %d form submission that could not be completed.' | |
| 4901 | + : 'SureForms has recorded %d form submissions that could not be completed.' ), | |
| 4902 | + $count | |
| 4903 | + ); | |
| 4904 | + | |
| 4905 | + case 'notification': | |
| 4906 | + return sprintf( | |
| 4907 | + ( 1 === $count | |
| 4908 | + ? 'SureForms saved %d entry but could not send the notification email for it.' | |
| 4909 | + : 'SureForms saved %d entries but could not send the notification emails for them.' ), | |
| 4910 | + $count | |
| 4911 | + ); | |
| 4912 | + | |
| 4913 | + case 'integration': | |
| 4914 | + return sprintf( | |
| 4915 | + ( 1 === $count | |
| 4916 | + ? 'SureForms saved %d entry but could not pass it to a connected service.' | |
| 4917 | + : 'SureForms saved %d entries but could not pass them to a connected service.' ), | |
| 4918 | + $count | |
| 4919 | + ); | |
| 4920 | + | |
| 4921 | + default: | |
| 4922 | + return sprintf( | |
| 4923 | + ( 1 === $count | |
| 4924 | + ? 'SureForms has recorded %d problem with the forms on this site.' | |
| 4925 | + : 'SureForms has recorded %d problems with the forms on this site.' ), | |
| 4926 | + $count | |
| 4927 | + ); | |
| 4928 | + } | |
| 4929 | + } | |
| 4930 | + | |
| 4931 | + /** | |
| 4932 | + * Diagnostics block for the support report. | |
| 4933 | + * | |
| 4934 | + * Carries what support would otherwise have to ask for, so the first reply can | |
| 4935 | + * be an answer rather than a questionnaire. | |
| 4936 | + * | |
| 4937 | + * The count is the one for this category, not get_fault_streak(), which reports | |
| 4938 | + * submissions only -- so a notification failure used to quote a number from an | |
| 4939 | + * unrelated counter, often zero. | |
| 4940 | + * | |
| 4941 | + * @param string $category One of Client_Logger::CATEGORIES. | |
| 4942 | + * @param string $form_title Form the failure was recorded against, when known. | |
| 4943 | + * @since 2.12.6 | |
| 4944 | + * @return string | |
| 4945 | + */ | |
| 4946 | + private function get_support_message( $category = '', $form_title = '' ) { | |
| 4947 | + global $wp_version; | |
| 4948 | + | |
| 4949 | + $failures = Client_Logger::get_failures(); | |
| 4950 | + $count = Helper::get_integer_value( $failures[ $category ]['count'] ?? 0 ); | |
| 4951 | + $copy = $this->get_support_copy( $category ); | |
| 4952 | + | |
| 4953 | + // With nothing recorded, describe the failure without a number. The old | |
| 4954 | + // max( 1, $count ) reported "recorded 1 problem" and "Recorded failures: 1" | |
| 4955 | + // for a count nobody recorded -- a number support would then chase. | |
| 4956 | + $host = Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) ); | |
| 4957 | + | |
| 4958 | + $lines = [ | |
| 4959 | + 'Hello SureForms support,', | |
| 4960 | + '', | |
| 4961 | + $count > 0 | |
| 4962 | + ? $this->get_support_count_sentence( $category, $count ) | |
| 4963 | + : sprintf( $copy['anon'], $host ), | |
| 4964 | + ]; | |
| 4965 | + | |
| 4966 | + if ( '' !== $form_title ) { | |
| 4967 | + $lines[] = ''; | |
| 4968 | + $lines[] = sprintf( | |
| 4969 | + 'Form: %s', | |
| 4970 | + $form_title | |
| 4971 | + ); | |
| 4972 | + } | |
| 4973 | + | |
| 4974 | + // Once: each call reads an option and a site option. | |
| 4975 | + $caching = Helper::get_active_caching_plugin(); | |
| 4976 | + | |
| 4977 | + $lines = array_merge( | |
| 4978 | + $lines, | |
| 4979 | + [ | |
| 4980 | + '', | |
| 4981 | + '---', | |
| 4982 | + 'Site details', | |
| 4983 | + sprintf( 'Site: %s', home_url() ), | |
| 4984 | + sprintf( 'SureForms: %s', SRFM_VER ), | |
| 4985 | + sprintf( | |
| 4986 | + 'SureForms Pro: %s', | |
| 4987 | + Helper::has_pro() && defined( 'SRFM_PRO_VER' ) ? SRFM_PRO_VER : 'not active' | |
| 4988 | + ), | |
| 4989 | + sprintf( 'WordPress: %s', Helper::get_string_value( $wp_version ) ), | |
| 4990 | + sprintf( 'PHP: %s', PHP_VERSION ), | |
| 4991 | + sprintf( | |
| 4992 | + 'Caching: %s', | |
| 4993 | + '' !== $caching ? $caching : 'none detected' | |
| 4994 | + ), | |
| 4995 | + sprintf( | |
| 4996 | + 'Recorded failures: %s', | |
| 4997 | + $count > 0 ? Helper::get_string_value( $count ) : 'none recorded' | |
| 4998 | + ), | |
| 4999 | + ] | |
| 5000 | + ); | |
| 5001 | + | |
| 5002 | + // Only when there is one. A repeat report is worth knowing about: the same | |
| 5003 | + // category having been reported before means the last answer did not hold, | |
| 5004 | + // which is a different conversation from a first report. Appended with the | |
| 5005 | + // rest of the site details rather than raised to the top, because it is | |
| 5006 | + // context for them rather than a headline. | |
| 5007 | + // | |
| 5008 | + // Survives only until the next success in that category, because | |
| 5009 | + // clear_category() unsets the whole record -- so in practice it is | |
| 5010 | + // reachable for 'integration', which has no success signal, and transient | |
| 5011 | + // for the other two. | |
| 5012 | + // | |
| 5013 | + // Stored as time(), a UTC epoch comparable with the sibling 'at', and | |
| 5014 | + // formatted here with wp_date() so it reads in the site's timezone rather | |
| 5015 | + // than the server's. | |
| 5016 | + $acked_at = Helper::get_integer_value( $failures[ $category ]['acked_at'] ?? 0 ); | |
| 5017 | + | |
| 5018 | + if ( $acked_at > 0 ) { | |
| 5019 | + $lines[] = sprintf( | |
| 5020 | + 'Previously reported: %s', | |
| 5021 | + Helper::get_string_value( wp_date( 'Y-m-d H:i T', $acked_at ) ) | |
| 5022 | + ); | |
| 5023 | + } | |
| 5024 | + | |
| 5025 | + return implode( "\n", $lines ); | |
| 5026 | + } | |
| 5027 | + | |
| 5028 | + /** | |
| 5029 | + * Record one dismissal, shared by the AJAX and no-JS entry points. | |
| 5030 | + * | |
| 5031 | + * Allowlisted, so only advisory items can be dismissed. A run of failed | |
| 5032 | + * submissions is a fault and must stay put until it actually resolves -- | |
| 5033 | + * otherwise a crafted request could silence the one message that matters. | |
| 5034 | + * | |
| 5035 | + * @param string $item_id Item to dismiss. | |
| 5036 | + * @since 2.12.6 | |
| 5037 | + * @return bool False when the id is not dismissible. | |
| 5038 | + */ | |
| 5039 | + private function dismiss_action_item( $item_id ) { | |
| 5040 | + if ( ! in_array( $item_id, [ 'caching_plugin' ], true ) ) { | |
| 5041 | + return false; | |
| 5042 | + } | |
| 5043 | + | |
| 5044 | + $dismissed = Helper::get_array_value( Helper::get_srfm_option( 'dismissed_action_items', [] ) ); | |
| 5045 | + | |
| 5046 | + if ( ! in_array( $item_id, $dismissed, true ) ) { | |
| 5047 | + $dismissed[] = $item_id; | |
| 5048 | + Helper::update_srfm_option( 'dismissed_action_items', $dismissed ); | |
| 5049 | + | |
| 5050 | + // Recorded here rather than at each caller: both the cross in the | |
| 5051 | + // dashboard panel and the no-JS link in the classic notice land here. | |
| 5052 | + $this->track_notice_event( $item_id . '_notice_dismiss' ); | |
| 5053 | + } | |
| 5054 | + | |
| 5055 | + return true; | |
| 5056 | + } | |
| 3376 | 5057 | } |