diff --git a/.distignore b/.distignore
index d2d03a916..49a490979 100644
--- a/.distignore
+++ b/.distignore
@@ -67,6 +67,11 @@ auth.json
credentials.json
# Directories
+# Build-time artwork source. Webpack re-emits every @Image/onboarding import as a
+# hashed file under assets/build/images/, so shipping this directory as well put
+# the same 2.8 MB in the ZIP twice. The rest of images/ cannot be ignored --
+# inc/gutenberg-hooks.php serves images/field-previews/* directly.
+images/onboarding
node_modules
vendor
tests
diff --git a/README.md b/README.md
index 59f74f8e1..49eb1708f 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,9 @@
**Contributors:** [brainstormforce](https://profiles.wordpress.org/brainstormforce/)
**Tags:** forms, ai forms, contact form, form builder, payment form
**Requires at least:** 6.4
-**Tested up to:** 7.1
+**Tested up to:** 7.1.2
**Requires PHP:** 7.4
-**Stable tag:** 2.12.7
+**Stable tag:** 2.12.8
**License:** GPLv2 or later
**License URI:** http://www.gnu.org/licenses/gpl-2.0.html
@@ -426,6 +426,10 @@ Visit the [SureForms features page](https://sureforms.com/features/?utm_source=w
You can report security issues through our [Bug Bounty Program](https://brainstormforce.com/bug-bounty-program/). We collaborate with Patchstack to validate, triage, and handle security reports.
## Changelog ##
+### 2.12.8 - 25th September 2026 ###
+* Improvement: The debug log no longer records routine validation stops, such as an empty required field, keeping it focused on real failures.
+* Fix: Entry Logs now record the email notification sent when a form is submitted.
+* Fix: Resolved an editor crash that prevented forms containing a Register block from opening.
### 2.12.7 - 12th September 2026 ###
* Improvement: Submission failure notices now offer View details, so you can read and copy the full diagnostics before contacting support.
* Improvement: The Form Checks panel now appears only when something genuinely needs attention, with clearer wording, keeping the sidebar focused.
@@ -441,8 +445,6 @@ You can report security issues through our [Bug Bounty Program](https://brainsto
* New: SureForms now detects a missing entries database table and offers a one-click repair, so submissions start saving again without manual database work.
* Fix: The Edit Form button no longer overlaps form fields on the front end.
* Fix: This update addressed a security bug. Props to Patchstack for reporting it responsibly to our team.
-### 2.12.5 - 25th August 2026 ###
-* Fix: Form submissions now go through reliably, even on sites where a performance plugin combines or defers JavaScript.
The full changelog is available [here](https://sureforms.com/whats-new/?utm_source=wordpress.org&utm_medium=whats_new).
## Upgrade Notice ##
\ No newline at end of file
diff --git a/admin/admin.php b/admin/admin.php
index 25a308164..a445eca49 100644
--- a/admin/admin.php
+++ b/admin/admin.php
@@ -89,15 +89,27 @@ class Admin {
public const THANKYOU_PROMPT_NOTICE_ID = 'srfm-thankyou-prompt';
/**
- * Where the dialog's Contact Support button goes.
+ * Where the Contact Support button writes to.
*
- * A form rather than an inbox: it collects the licence and site details support
- * would otherwise have to ask for, and the diagnostics are already on the
- * clipboard by the time someone gets here.
+ * An inbox rather than a form, restoring the 2.12.6 behaviour. A mailto: opens
+ * the composer the person already has open with the subject and the whole
+ * report in the body, so reporting a fault is one click and a send. The
+ * troubleshooting form could carry neither, which is why 2.12.7 had to gate the
+ * button behind copying the diagnostics by hand first.
*
- * @since 2.12.7
+ * @since 2.12.8
*/
- private const SUPPORT_CONTACT_URL = 'https://sureforms.com/form/troubleshooting-form/';
+ private const SUPPORT_EMAIL = 'support@sureforms.com';
+
+ /**
+ * Longest Contact Support mailto: URL we hand to a mail client.
+ *
+ * Below the roughly 2000-character limit the strictest common clients and
+ * browsers apply to a link, with room to spare.
+ *
+ * @since 2.12.8
+ */
+ private const SUPPORT_MAILTO_MAX_LENGTH = 1800;
/**
* Dashboard widget entries data.
@@ -178,7 +190,9 @@ public function __construct() {
add_action( 'admin_menu', [ $this, 'settings_page' ] );
add_action( 'admin_menu', [ $this, 'add_learn_page' ] );
add_action( 'admin_menu', [ $this, 'add_new_form' ] );
- add_action( 'admin_menu', [ $this, 'add_suremail_page' ] );
+ if ( ! Helper::hide_promotions() ) {
+ add_action( 'admin_menu', [ $this, 'add_suremail_page' ] );
+ }
if ( ! Helper::has_pro() ) {
add_action( 'admin_menu', [ $this, 'add_quiz_page' ] );
add_action( 'admin_menu', [ $this, 'add_survey_reports_page' ] );
@@ -233,7 +247,6 @@ public function __construct() {
add_action( 'wp_ajax_sureforms_dismiss_pointer', [ $this, 'pointer_dismissed' ] );
add_action( 'wp_ajax_sureforms_accept_cta', [ $this, 'pointer_accepted_cta' ] );
add_action( 'wp_ajax_srfm_notice_response', [ $this, 'handle_notice_response' ] );
- add_action( 'wp_ajax_srfm_action_item_details', [ $this, 'handle_action_item_details' ] );
add_action( 'wp_ajax_srfm_dismiss_action_item', [ $this, 'handle_dismiss_action_item' ] );
add_action( 'admin_post_srfm_dismiss_action_item_link', [ $this, 'handle_dismiss_action_item_link' ] );
add_action( 'wp_ajax_srfm_ai_widget_usage', [ $this, 'track_ai_widget_usage' ] );
@@ -628,7 +641,7 @@ public function dismiss_form_setup_card( $request ) {
* @return void
*/
public function register_form_setup_widget() {
- if ( ! Helper::current_user_can() ) {
+ if ( ! Helper::current_user_can() || Helper::hide_promotions() ) {
return;
}
@@ -735,7 +748,7 @@ public function render_form_setup_widget() {
* @return void
*/
public function enqueue_form_setup_widget_assets( $hook_suffix ) {
- if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) {
+ if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() || Helper::hide_promotions() ) {
return;
}
@@ -1296,10 +1309,7 @@ public function add_quiz_page() {
add_submenu_page(
'sureforms_menu',
__( 'Quiz Entries', 'sureforms' ),
- __( 'Quizzes', 'sureforms' ) .
- ' ' .
- esc_html__( 'New', 'sureforms' ) .
- '',
+ __( 'Quizzes', 'sureforms' ),
self::$sureforms_page_default_capability,
'sureforms_quiz_entries',
[ $this, 'render_quiz_empty_state' ],
@@ -1329,10 +1339,7 @@ public function add_survey_reports_page() {
add_submenu_page(
'sureforms_menu',
__( 'Survey Reports', 'sureforms' ),
- __( 'Survey Reports', 'sureforms' ) .
- ' ' .
- esc_html__( 'New', 'sureforms' ) .
- '',
+ __( 'Survey Reports', 'sureforms' ),
self::$sureforms_page_default_capability,
'sureforms_survey_reports',
[ $this, 'render_survey_empty_state' ],
@@ -1362,10 +1369,7 @@ public function add_partial_entries_page() {
add_submenu_page(
'sureforms_menu',
__( 'Partial Entries', 'sureforms' ),
- __( 'Partial Entries', 'sureforms' ) .
- ' ' .
- esc_html__( 'New', 'sureforms' ) .
- '',
+ __( 'Partial Entries', 'sureforms' ),
self::$sureforms_page_default_capability,
'sureforms_partial_entries',
[ $this, 'render_partial_entries_empty_state' ],
@@ -1815,18 +1819,12 @@ public function enqueue_scripts() {
'field_spacing_vars' => Helper::get_css_vars(),
'is_ver_lower_than_6_7' => version_compare( $wp_version, '6.6.2', '<=' ),
'integrations' => Helper::sureforms_get_integration(),
- 'rotating_plugin_banner' => Helper::get_rotating_plugin_banner(),
+ 'hide_promotions' => Helper::hide_promotions(),
+ // Null makes the dashboard's ExtendTab render nothing.
+ 'rotating_plugin_banner' => Helper::hide_promotions() ? null : Helper::get_rotating_plugin_banner(),
'ajax_url' => admin_url( 'admin-ajax.php' ),
'client_logs_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_client_logs' ) : '',
'action_items' => $this->get_action_items(),
- 'details_dialog' => $this->get_details_dialog_labels(),
- // Where Contact Support goes when the details fetch fails and there is
- // no category-tagged URL to use. Untagged, because at that point we do
- // not know which check sent them -- but still a way out: these notices
- // are not dismissible and Contact Support is the only action that
- // retires them.
- 'support_url' => $this->get_support_contact_url( '' ),
- 'action_item_details_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_action_item_details' ) : '',
'notice_response_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_notice_response' ) : '',
'dismiss_action_item_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_dismiss_action_item' ) : '',
'sf_plugin_manager_nonce' => wp_create_nonce( 'sf_plugin_manager_nonce' ),
@@ -1839,6 +1837,10 @@ public function enqueue_scripts() {
'privacy_policy_url' => Helper::get_sureforms_website_url( 'privacy-policy/' ),
'is_rtl' => $is_rtl,
'onboarding_completed' => method_exists( $onboarding_instance, 'get_onboarding_status' ) ? $onboarding_instance->get_onboarding_status() : false,
+ // Read by the onboarding cache-conflict step: the name decides whether the
+ // step renders, the URL is where "View full guide" points.
+ 'caching_plugin' => Helper::get_active_caching_plugin(),
+ 'caching_plugin_doc_url' => Helper::get_caching_plugin_doc_url( 'onboarding' ),
'migration_banner_dismissed' => method_exists( $onboarding_instance, 'is_migration_banner_dismissed' ) ? $onboarding_instance->is_migration_banner_dismissed() : false,
'migration_settings_url' => admin_url( 'admin.php?page=sureforms_form_settings&tab=migration-settings' ),
'onboarding_redirect' => isset( $_GET['srfm-activation-redirect'] ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is not required for the activation redirection.
@@ -2674,8 +2676,8 @@ public function display_srfm_rating_notice() {
return;
}
- // Allow the notice to be disabled.
- if ( ! apply_filters( 'srfm_show_rating_notice', true ) ) {
+ // Allow the notice to be disabled; never shown while promotions are hidden.
+ if ( Helper::hide_promotions() || ! apply_filters( 'srfm_show_rating_notice', true ) ) {
return;
}
@@ -2818,87 +2820,18 @@ public function enqueue_notice_response_script() {
'srfm-notice-response',
'srfmNoticeResponse',
[
- 'ajaxurl' => admin_url( 'admin-ajax.php' ),
- 'nonce' => wp_create_nonce( 'srfm_notice_response' ),
- // The diagnostics are fetched when the dialog opens rather than
- // shipped with every page, so the dialog needs its own nonce.
- 'detailsNonce' => wp_create_nonce( 'srfm_action_item_details' ),
+ 'ajaxurl' => admin_url( 'admin-ajax.php' ),
+ 'nonce' => wp_create_nonce( 'srfm_notice_response' ),
// Carousel chrome. Built in the browser rather than printed here so
// that with JavaScript off every notice simply stays visible, which
// is the behaviour this replaced -- controls that cannot work must
// not be what hides a warning.
- 'carousel' => [
+ 'carousel' => [
'previous' => __( 'Previous notice', 'sureforms' ),
'next' => __( 'Next notice', 'sureforms' ),
/* translators: 1: current position, 2: total notices. */
'counter' => __( '%1$d of %2$d', 'sureforms' ),
],
- // Details modal chrome, translated here so the script carries no
- // user-facing English of its own.
- 'details' => $this->get_details_dialog_labels(),
- // Where Contact Support goes when the fetch fails and there is no
- // category-tagged URL to use. Untagged, because at that point we do
- // not know which check sent them -- but still a way out: these
- // notices are not dismissible and Contact Support is the only action
- // that retires them.
- 'supportUrl' => $this->get_support_contact_url( '' ),
- ]
- );
- }
-
- /**
- * Serve one failure category's diagnostics, on demand.
- *
- * Hooked - wp_ajax_srfm_action_item_details.
- *
- * The report is built here rather than shipped with the page. Its content
- * comes from the client error log, and that log is filled through a public
- * REST route gated on a submit token any visitor can obtain from a form page
- * rather than on a capability -- so the text is attacker-authored, and putting
- * it in the localisation JSON and a hidden div on every admin screen exposed
- * it far beyond the one admin who opens the dialog.
- *
- * Capability first, then nonce, then the category, matching the ordering of
- * the sibling handlers in this class.
- *
- * @since 2.12.7
- * @return void
- */
- public function handle_action_item_details() {
- if ( ! Helper::current_user_can() ) {
- wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 );
- return;
- }
-
- if ( ! check_ajax_referer( 'srfm_action_item_details', 'nonce', false ) ) {
- wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 );
- return;
- }
-
- // sanitize_key() returns '' for anything non-scalar (formatting.php:2194), so
- // a category[]= in the body arrives here as the empty string and falls into
- // the refusal below rather than needing a type branch of its own.
- $category = isset( $_POST['category'] ) ? sanitize_key( wp_unslash( $_POST['category'] ) ) : '';
-
- // The only check the category needs, and the reason there is no separate
- // allowlist above it: get_open_failures() returns nothing but keys in
- // Client_Logger::CATEGORIES, so an absent category, an unrecognised one and
- // a recognised one with nothing wrong all land here. Asking for a category
- // with no fault must not mint a report describing one.
- $open = Client_Logger::get_open_failures();
-
- if ( ! isset( $open[ $category ] ) ) {
- wp_send_json_error( [ 'message' => __( 'Nothing to report.', 'sureforms' ) ], 404 );
- return;
- }
-
- $form_title = Helper::get_string_value( $open[ $category ]['form_title'] ?? '' );
-
- wp_send_json_success(
- [
- 'details' => $this->get_support_message( $category, $form_title )
- . "\n\n" . $this->get_support_log_block( 8000 ),
- 'support_url' => $this->get_support_contact_url( $category ),
]
);
}
@@ -2947,21 +2880,15 @@ public function handle_notice_response() {
],
// The "Finish setting up" prompt (#3030): three CTAs, plus the ✕.
'form_submission_error' => [
- 'view_details' => 'submission_failure_notice_view',
- 'copy_details' => 'submission_failure_notice_copy',
'contact_support' => 'submission_failure_notice_cta',
'dismissed' => 'submission_failure_notice_dismiss',
],
'notification_error' => [
- 'view_details' => 'notification_failure_notice_view',
- 'copy_details' => 'notification_failure_notice_copy',
'contact_support' => 'notification_failure_notice_cta',
'help_me_fix' => 'notification_failure_notice_guide',
'dismissed' => 'notification_failure_notice_dismiss',
],
'integration_error' => [
- 'view_details' => 'integration_failure_notice_view',
- 'copy_details' => 'integration_failure_notice_copy',
'contact_support' => 'integration_failure_notice_cta',
'dismissed' => 'integration_failure_notice_dismiss',
],
@@ -2988,8 +2915,10 @@ public function handle_notice_response() {
$this->track_notice_event( $valid[ $notice_id ][ $button ] );
// Reporting the failures retires the notice until something new fails.
- // Handled here rather than in the browser so it holds for the classic
- // wp-admin notice too, which is a plain link with no JavaScript.
+ // Handled here rather than in the browser so both surfaces share it. The
+ // click still reaches here only through JavaScript -- notice-response.js
+ // on the classic notice, ActionItems.js on the dashboard -- so with
+ // JavaScript off the link opens the email but the notice stays.
$categories = [
'form_submission_error' => 'submission',
'notification_error' => 'notification',
@@ -3148,8 +3077,9 @@ public function pointer_accepted_cta() {
*/
public function maybe_register_dashboard_widget() {
- // Only for users with manage_options capability.
- if ( ! Helper::current_user_can() ) {
+ // Only for users with manage_options capability, and never while
+ // promotions are hidden: no SureForms widget on the WordPress dashboard.
+ if ( ! Helper::current_user_can() || Helper::hide_promotions() ) {
return;
}
@@ -3254,7 +3184,7 @@ class="widefat"
*/
public function enqueue_ai_dashboard_widget_assets( $hook_suffix ) {
// Only on the main dashboard, and only for capable users (matches the widget gate).
- if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) {
+ if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() || Helper::hide_promotions() ) {
return;
}
@@ -3627,17 +3557,10 @@ class=""
data-srfm-notice-id=""
data-srfm-button=""
@@ -3786,10 +3709,9 @@ public function get_action_items() {
* handle_dismiss_action_item()'s allowlist can actually be dismissed, so
* adding a dismissible item here also needs a line there.
*
- * The details dialog is not available here: it is served by
- * handle_action_item_details(), which reads SureForms' own client error log
- * and knows nothing about a third-party item. Such an item's cta_url is
- * followed as a link, which is what it does with JavaScript off anyway.
+ * A third-party item's cta_url is followed as a plain link. The prefilled
+ * support email is built only for SureForms' own failure items, from its own
+ * client error log.
*
* @since 2.12.6
*
@@ -3830,11 +3752,12 @@ public function get_action_items() {
);
}
- // The id ends up in a data attribute the dialog matches on with an
- // attribute selector, and in the dismiss allowlist. sanitize_key() is
- // what both dismiss paths already apply, so applying it once here means
- // the value that renders is the value they compare against -- and a
- // filter-contributed id carrying a quote cannot break the selector.
+ // The id ends up in the notice's data-srfm-notice-id attribute, which
+ // notice-response.js matches on, and in the dismiss allowlist.
+ // sanitize_key() is what both dismiss paths already apply, so applying
+ // it once here means the value that renders is the value they compare
+ // against -- and a filter-contributed id carrying a quote cannot break
+ // the selector.
if ( isset( $item['id'] ) ) {
$items[ $index ]['id'] = sanitize_key( Helper::get_string_value( $item['id'] ) );
}
@@ -3878,15 +3801,14 @@ public function handle_dismiss_action_item() {
}
/**
- * The stylesheet for the notice carousel and the details dialog.
+ * The stylesheet for the notice carousel.
*
* In a stylesheet rather than inline style assignments in
- * notice-response.js, so the rules use logical properties, an RTL sheet can
- * override them, and a site can restyle the dialog without patching a script.
+ * notice-response.js, so the rules use logical properties and an RTL sheet can
+ * override them.
*
- * Only the classic wp-admin surface needs these. The SureForms dashboard's
- * dialog is force-ui's, styled by the Tailwind build, so nothing here reaches
- * it -- the two surfaces share their strings, not their markup.
+ * Only the classic wp-admin surface needs these. The SureForms dashboard is
+ * styled by the Tailwind build, so nothing here reaches it.
*
* Attached to a registered handle with no file of its own, which is the WP way
* to ship CSS tied to one script.
@@ -3899,13 +3821,6 @@ public function handle_dismiss_action_item() {
* the notice text, and the defensive `display: none` on the hidden payload was
* inert, which is the exact window that rule exists for.
*
- * The buttons are painted explicitly. They carry core's `button` classes for
- * their shape and focus behaviour, and core paints those with
- * `var(--wp-admin-theme-color)` -- so without this the dialog renders in
- * whichever admin colour scheme the user picked, which on a default install is
- * blue, on a SureForms panel that is otherwise entirely brand orange. Same
- * approach and same values as print_srfm_notice_styles().
- *
* @since 2.12.7
* @return void
*/
@@ -3956,151 +3871,11 @@ public function enqueue_action_item_styles() {
align-items: center;
gap: 8px;
}
-.srfm-details-overlay {
- position: fixed;
- inset: 0;
- z-index: 999999;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(0, 0, 0, .5);
- padding: 16px;
-}
-.srfm-details-panel {
- background: #fff;
- border-radius: 8px;
- padding: 16px;
- width: 100%;
- max-width: 800px;
- box-shadow: 0 10px 30px rgba(0, 0, 0, .2);
-}
-.srfm-details-panel h2 { margin: 0 0 4px; font-size: 14px; }
-.srfm-details-panel .srfm-details-description { margin: 0 0 12px; color: #50575e; }
-.srfm-details-panel pre {
- margin: 0;
- max-height: 320px;
- overflow: auto;
- white-space: pre-wrap;
- word-break: break-word;
- background: #f6f7f7;
- padding: 12px;
- border-radius: 6px;
- font-size: 12px;
-}
-.srfm-details-actions {
- display: flex;
- gap: 8px;
- align-items: center;
- flex-wrap: wrap;
- justify-content: flex-end;
- margin: 12px 0 0;
-}
-.srfm-details-hint {
- margin-inline-end: auto;
- font-size: 12px;
- color: #4b5563;
-}
-/* Core paints .button with the admin colour scheme, so these say what they are
- rather than inheriting whichever scheme the user picked. */
-.srfm-details-panel .srfm-details-close.button-link {
- color: #50575e;
- text-decoration: none;
-}
-.srfm-details-panel .srfm-details-close.button-link:hover,
-.srfm-details-panel .srfm-details-close.button-link:focus {
- color: #1e1e1e;
-}
-.srfm-details-panel .srfm-details-copy.button {
- background: #fff;
- border-color: #c3c4c7;
- color: #1e1e1e;
-}
-.srfm-details-panel .srfm-details-copy.button:hover,
-.srfm-details-panel .srfm-details-copy.button:focus {
- background: #f6f7f7;
- border-color: #8c8f94;
- color: #1e1e1e;
-}
-.srfm-details-panel .srfm-details-contact.button-primary,
-.srfm-details-panel .srfm-details-contact.button-primary:hover,
-.srfm-details-panel .srfm-details-contact.button-primary:focus {
- background: #D54407;
- border-color: #D54407;
- color: #fff;
- box-shadow: none;
- text-shadow: none;
- text-decoration: none;
-}
-.srfm-details-panel .srfm-details-contact.button-primary:hover,
-.srfm-details-panel .srfm-details-contact.button-primary:focus {
- background: #C83B00;
- border-color: #C83B00;
-}
-/* Grey rather than a dimmed orange fill. Core sets the disabled text colour with
- !important, so an orange background here leaves grey on orange at 1.31:1 --
- and a control that cannot be used should not wear the primary colour anyway.
- This is what core gives every other disabled button, and what force-ui renders
- for the same state on the dashboard, so the two surfaces agree. */
-.srfm-details-panel .srfm-details-contact.button-primary[aria-disabled="true"],
-.srfm-details-panel .srfm-details-contact.button-primary[aria-disabled="true"]:hover,
-.srfm-details-panel .srfm-details-contact.button-primary[aria-disabled="true"]:focus {
- background: #f6f7f7;
- border-color: #dcdcde;
- pointer-events: none;
- box-shadow: none;
-}
-.srfm-details-panel .button:focus {
- outline: 2px solid #D54407;
- outline-offset: 1px;
- box-shadow: none;
-}
CSS;
wp_add_inline_style( 'srfm-action-items', $css );
}
- /**
- * The details dialog's strings.
- *
- * One array, two consumers: the classic wp-admin dialog in
- * notice-response.js, and the dashboard's force-ui one. Declared here rather
- * than inline in each, because the same sentence written as `__()` in PHP and
- * again in JSX looks identical to translators until the first edit to either,
- * after which one surface silently reverts to English.
- *
- * @since 2.12.7
- * @return array
- */
- private function get_details_dialog_labels() {
- return [
- 'title' => __( 'Details', 'sureforms' ),
- 'description' => __( 'What we recorded about this problem. Copy it into your support request so we can start from the cause rather than a description of it.', 'sureforms' ),
- 'copy' => __( 'Copy details', 'sureforms' ),
- 'copied' => __( 'Copied', 'sureforms' ),
- 'contact' => __( 'Contact Support', 'sureforms' ),
- 'close' => __( 'Close', 'sureforms' ),
- // Shown beside the buttons rather than as a title attribute:
- // pointer-events:none suppresses the native tooltip, a title
- // never fires on keyboard focus, and screen readers commonly
- // drop it on an unavailable control -- so the sentence saying
- // why the button is inert could not be read by anyone.
- 'copyFirst' => __( 'Copy the details first, so you have them to paste.', 'sureforms' ),
- // The unlock changes the label, the icon and whether Contact
- // Support works, none of which was announced. This goes in a
- // role="status" node so it is.
- 'unlocked' => __( 'Copied. Contact Support is now available.', 'sureforms' ),
- 'copyFailed' => __( 'Your browser would not let us copy. Select the text above and copy it by hand.', 'sureforms' ),
- // The scrollable diagnostics block is focusable, so it needs a name of
- // its own.
- 'logRegion' => __( 'Recorded diagnostics', 'sureforms' ),
- // The dialog opens before its payload arrives -- see
- // handle_action_item_details() for why the report is not shipped with
- // the page.
- 'loading' => __( 'Collecting the details…', 'sureforms' ),
- 'unavailable' => __( 'We could not collect the details. Contact Support and describe what happened, and we will take it from there.', 'sureforms' ),
- ];
- }
-
/**
* SureForms' own action items, before the filter.
*
@@ -4170,30 +3945,24 @@ private function get_first_party_action_items() {
? sprintf( $copy['title'], $form_title )
: $copy['generic'],
'message' => $copy['message'],
- // Shows what would be sent before anything is sent. Someone reporting
- // a fault on their own site is entitled to read the diagnostics and
- // the log first, and a support agent gets a cleaner paste than a
- // screenshot of a notice.
- 'cta_label' => __( 'View details', 'sureforms' ),
- // Where the classic wp-admin notice sends people, since it cannot open
- // the panel's dialog. The dashboard is where the details are readable.
- 'cta_url' => admin_url( 'admin.php?page=sureforms_menu' ),
- 'cta_action' => 'view_details',
- // Not the payload itself, only that one exists. The diagnostics are
- // fetched when the dialog opens -- see handle_action_item_details().
+ // Straight to a composed email, as 2.12.6 did. The subject, the
+ // diagnostics and the log tail are already in it, so reporting a
+ // fault is one click and a send.
//
- // They used to ride along in the localisation JSON and in a hidden
- // div on every admin page. The content is authored by whoever
- // triggered the failure, and the client-error-log route is a public
- // endpoint gated on a submit token rather than a capability, so an
- // anonymous visitor can fill that excerpt. Broadcasting it to every
- // admin screen -- read or not -- put attacker-authored text in page
- // source site-wide and made any future escaping slip a
- // manage_options-context problem. On demand, it reaches only the
- // admin who asked for it.
- 'has_details' => true,
- // Which record to fetch. Not the payload, just the key.
- 'category' => $category,
+ // Built when the page renders, so the report ships in the href of the
+ // classic notice on every admin screen and in srfm_admin.action_items
+ // on the dashboard, both for capable users only. Its log comes from
+ // the client error log, which any visitor with a form's submit token
+ // can write to, so treat it as untrusted text. It is inert here:
+ // http_build_query() percent-encodes all of it, so it cannot break
+ // out of the attribute or add &cc= / &bcc= to the mailto:, and the
+ // URL is length-capped. Building it on click instead would bring back
+ // an AJAX round trip and a nonce to open an email -- the 2.12.7
+ // dialog's machinery -- for text the person reads in the composer
+ // before anything is sent.
+ 'cta_label' => __( 'Contact Support', 'sureforms' ),
+ 'cta_url' => $this->get_support_contact_url( $category, $form_title ),
+ 'cta_action' => 'contact_support',
'dismissible' => false,
];
@@ -4770,9 +4539,11 @@ private function is_admin_pointer_visible() {
global $pagenow;
$allowed_pages = [ 'index.php', 'options-general.php' ];
- // Do not show if pointer dismissed, accepted, or more than 1 form exists.
+ // Do not show if promotions are hidden, the pointer was dismissed or
+ // accepted, or more than 1 form exists.
if (
- ! empty( Helper::get_srfm_option( 'pointer_popup_dismissed' ) )
+ Helper::hide_promotions()
+ || ! empty( Helper::get_srfm_option( 'pointer_popup_dismissed' ) )
|| ! empty( Helper::get_srfm_option( 'pointer_popup_accepted' ) )
|| (int) ( wp_count_posts( SRFM_FORMS_POST_TYPE )->publish ?? 0 ) > 1
) {
@@ -4899,103 +4670,144 @@ private function track_notice_event( $event_name ) {
}
/**
- * The contact form's address, tagged with where the click came from.
- *
- * One campaign, tagged per failure, so the report answers which check actually
- * sends people to support rather than only how many arrive. A submission
- * failure and a caching advisory are different problems and it is worth knowing
- * which one drives the tickets.
- *
- * Prefilled with what SureForms already knows -- the admin's address, which
- * failure it is, and the site host -- so the person reporting a fault does not
- * retype it. Worth knowing that the address travels in the query string, so it
- * reaches browser history and any referrer along the way; it is the site
- * owner's own address going to SureForms' own form, which is the flow this
- * button exists for.
- *
- * Built with add_query_arg rather than string concatenation, so it stays
- * correct if the constant ever gains a query string of its own.
- *
- * @param string $category One of Client_Logger::CATEGORIES, naming the failure
- * the visitor is reporting.
- * @since 2.12.7
+ * A pre-addressed support email for the failure being reported.
+ *
+ * Restores the 2.12.6 behaviour: the button opens the composer the person
+ * already uses, with the subject and the whole report written for them. What
+ * 2.12.7 replaced it with -- a web form -- could carry neither the diagnostics
+ * nor the log, so the button had to be gated behind copying them by hand and
+ * pasting them into a field on the far side. That is three deliberate steps to
+ * report a fault the plugin had already written up.
+ *
+ * The log is pasted into the body rather than attached because mailto has no
+ * attachment parameter -- browsers drop any attempt to add one -- and it is a
+ * tail rather than the whole file because a megabyte of JSON would exceed the
+ * URL length every mail client enforces. The finished URL is capped at
+ * SUPPORT_MAILTO_MAX_LENGTH, and the log is what gives way to meet it.
+ *
+ * The subject and body are English on every site, deliberately untranslated:
+ * they are written for SureForms support, and plain literals cannot be
+ * rewritten by a locale, a translation plugin or a gettext filter.
+ *
+ * @param string $category One of Client_Logger::CATEGORIES, naming the failure
+ * being reported. An unknown or absent one gets
+ * deliberately neutral wording via get_support_copy().
+ * @param string $form_title Form the failure was recorded against, when known.
+ * @since 2.12.8
* @return string
*/
- private function get_support_contact_url( $category ) {
- // Deliberately not translated. These are matched against the options on the
- // troubleshooting form, so they are machine values, not copy -- a German
- // site sending "E-Mail-Benachrichtigungsfehler" would arrive as an
- // unrecognised subject and land in the wrong queue.
- $subjects = [
- 'submission' => 'Form submission failure',
- 'notification' => 'Email notification failure',
- 'integration' => 'Integration failure',
- ];
+ private function get_support_contact_url( $category, $form_title = '' ) {
+ $copy = $this->get_support_copy( $category );
+ $host = Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) );
- $user = wp_get_current_user();
+ $subject = sprintf( $copy['subject'], $host );
- $url = add_query_arg(
- [
- // Prefills the form, so the person reporting a fault does not retype
- // what SureForms already knows. Empty rather than absent when the
- // address is unusable, so the form still opens.
- 'mail' => is_email( $user->user_email ) ? $user->user_email : '',
- // Falls back to "Other" for a category SureForms does not define --
- // srfm_action_items is public, so an item can carry any category or
- // none.
- 'subject' => $subjects[ $category ] ?? 'Other',
- 'site_url' => Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) ),
- 'utm_source' => 'sureforms',
- 'utm_medium' => 'form_checks',
- 'utm_campaign' => 'contact_support',
- // Which check sent them. The one part that differs per button, and
- // the reason for tagging at all.
- 'utm_content' => $category,
- ],
- self::SUPPORT_CONTACT_URL
- );
+ $url = $this->build_support_mailto_within_limit( $category, $form_title, $subject );
+
+ // The last resort: the subject alone still names the problem and the site.
+ if ( '' === $url ) {
+ $url = $this->build_support_mailto( $subject );
+ }
/**
* Filter where the Contact Support action sends people.
*
- * Replaces the `srfm_support_email_address` filter, which pointed at an
- * inbox and has no destination left to change now that the action opens a
- * form. A white-label install wants to point this at its own support page.
+ * A white-label install wants its own inbox or its own support page, so both
+ * are accepted. Returning an http(s) URL is supported but drops the body --
+ * a web form cannot carry it -- so the person arrives without the site
+ * details or the debug log. They can still download the log from SureForms →
+ * Settings → General, but nothing prompts them to, so a filter returning a
+ * page should ask for it there.
*
* @since 2.12.7
*
- * @param string $url Contact form URL, already UTM-tagged.
- * @param string $category The failure being reported.
+ * @param string $url The pre-addressed mailto: URL.
+ * @param string $category The failure being reported.
+ * @param string $form_title Form the failure was recorded against, or ''.
*/
- $filtered = Helper::get_string_value( apply_filters( 'srfm_support_contact_url', $url, $category ) );
+ $filtered = Helper::get_string_value( apply_filters( 'srfm_support_contact_url', $url, $category, $form_title ) );
// Escaped after the filter, not before: the point of escaping here is that
- // neither renderer has to trust what comes back. mailto: is allowed because
- // an inbox is a legitimate destination for a white-label support contact,
- // and get_action_items() already allows it on the sibling item URLs.
+ // neither renderer has to trust what comes back.
$safe = esc_url_raw( $filtered, [ 'http', 'https', 'mailto' ] );
// Never empty. Contact Support is the only action that retires these
// notices and they are dismissible => false, so returning '' for a filter
// value that cannot survive escaping leaves an undismissable notice with
- // nothing on it that works. Falling back to SureForms' own form is worse
- // for a white-label than their own URL and better than a dead end, and the
- // unfiltered URL is built here rather than supplied, so it always escapes.
- return '' !== $safe ? $safe : esc_url_raw( $url, [ 'http', 'https' ] );
+ // nothing on it that works. The unfiltered URL is built here rather than
+ // supplied, so it always escapes.
+ return '' !== $safe ? $safe : esc_url_raw( $url, [ 'mailto' ] );
}
/**
- * The log tail, formatted for pasting.
+ * The fullest support mailto: that fits SUPPORT_MAILTO_MAX_LENGTH.
+ *
+ * A mailto: is a URL and every client enforces a length limit on it.
+ * Overrunning it does not truncate politely -- it drops the body, or the
+ * whole link -- while the click still retires the notice. So the cap is on
+ * the encoded URL, not the raw log: JSON-escaped non-ASCII text grows about
+ * eight times once percent-encoded. The log gives way first, because the
+ * site details are the part support cannot do without.
+ *
+ * @param string $category The failure being reported.
+ * @param string $form_title Form the failure was recorded against, or ''.
+ * @param string $subject Subject line.
+ * @since 2.12.8
+ * @return string The URL, or '' when even the body without a log is too long.
+ */
+ private function build_support_mailto_within_limit( $category, $form_title, $subject ) {
+ // CRLF, not "\n". RFC 6068 leaves the line ending to the client and the
+ // major composers normalise either, but Outlook renders a bare LF body as a
+ // single run-on line -- which is exactly the report a support agent has to
+ // read.
+ $message = str_replace( "\n", "\r\n", $this->get_support_message( $category, $form_title ) );
+
+ foreach ( [ 1200, 800, 400, 0 ] as $budget ) {
+ $log = 0 < $budget
+ ? $this->get_support_log_block( $budget )
+ : '---' . "\n" . 'Debug log left out to keep this email short enough to send. The full log can be downloaded from SureForms → Settings → General.';
+
+ $url = $this->build_support_mailto( $subject, $message . "\r\n\r\n" . str_replace( "\n", "\r\n", $log ) );
+
+ if ( strlen( $url ) <= self::SUPPORT_MAILTO_MAX_LENGTH ) {
+ return $url;
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * A mailto: to the support inbox.
*
- * One builder, so the text someone reads before sending is the text that gets
- * sent. They used to be built separately, which is how a "details" view drifts
- * from what it claims to show.
+ * @param string $subject Subject line.
+ * @param string $body Body, with CRLF line endings. Omitted when empty.
+ * @since 2.12.8
+ * @return string
+ */
+ private function build_support_mailto( $subject, $body = '' ) {
+ $query = [ 'subject' => $subject ];
+
+ if ( '' !== $body ) {
+ $query['body'] = $body;
+ }
+
+ return 'mailto:' . self::SUPPORT_EMAIL . '?' . http_build_query(
+ $query,
+ '',
+ '&',
+ // RFC 3986, so a space is %20 rather than +. A mail client reading a
+ // mailto: body decodes it as a URI, not as form data, so + arrives as a
+ // literal plus in every word gap.
+ PHP_QUERY_RFC3986
+ );
+ }
+
+ /**
+ * The log tail, formatted for pasting.
*
- * The budget is a parameter because nothing here is going into a URL any more.
- * Client_Logger::get_tail()'s 1200-character default existed to fit a compose
- * URL; a clipboard and a
have no such limit, so the dialog asks for more
- * and the note below describes the real constraint rather than a mail client
- * that is not in this flow.
+ * The budget is a parameter because it goes into a mailto: URL, and
+ * get_support_contact_url() lowers it until the encoded URL fits.
*
* @param int $max_chars Characters of log to include.
* @since 2.12.7
@@ -5006,12 +4818,11 @@ private function get_support_log_block( $max_chars = 1200 ) {
$block = '---' . "\n";
if ( '' === $log['text'] ) {
- return $block . __( 'Debug log: no entries recorded.', 'sureforms' );
+ return $block . 'Debug log: no entries recorded.';
}
$block .= sprintf(
- /* translators: 1: entries shown, 2: entries recorded. */
- __( 'Debug log (most recent %1$d of %2$d entries)', 'sureforms' ),
+ 'Debug log (most recent %1$d of %2$d entries)',
$log['shown'],
$log['total']
) . "\n";
@@ -5021,7 +4832,7 @@ private function get_support_log_block( $max_chars = 1200 ) {
$block .= '```' . "\n" . $log['text'] . "\n" . '```';
if ( $log['shown'] < $log['total'] ) {
- $block .= "\n\n" . __( 'Older entries were left out to keep this excerpt readable. The full log can be downloaded from SureForms → Settings → General.', 'sureforms' );
+ $block .= "\n\n" . 'Older entries were left out to keep this excerpt readable. The full log can be downloaded from SureForms → Settings → General.';
}
return $block;
@@ -5032,8 +4843,7 @@ private function get_support_log_block( $max_chars = 1200 ) {
*
* Both come from here so they cannot drift apart: a subject naming one problem
* over a body describing another is worse than either alone. The counted form
- * of the opening line lives in get_support_count_sentence(), which needs
- * `_n()`'s literals and so cannot be an array lookup.
+ * of the opening line lives in get_support_count_sentence().
*
* An unknown or absent category gets deliberately neutral wording. The
* alternative -- defaulting to the submission copy -- states something specific
@@ -5047,22 +4857,16 @@ private function get_support_log_block( $max_chars = 1200 ) {
private function get_support_copy( $category ) {
$copy = [
'submission' => [
- /* translators: %s: site host. */
- 'subject' => __( 'SureForms: form submissions are failing on %s', 'sureforms' ),
- /* translators: %s: site host. */
- 'anon' => __( 'SureForms has recorded form submissions on %s that could not be completed.', 'sureforms' ),
+ 'subject' => 'SureForms: form submissions are failing on %s',
+ 'anon' => 'SureForms has recorded form submissions on %s that could not be completed.',
],
'notification' => [
- /* translators: %s: site host. */
- 'subject' => __( 'SureForms: notification emails are not being sent on %s', 'sureforms' ),
- /* translators: %s: site host. */
- 'anon' => __( 'SureForms saved entries on %s but could not send the notification emails for them.', 'sureforms' ),
+ 'subject' => 'SureForms: notification emails are not being sent on %s',
+ 'anon' => 'SureForms saved entries on %s but could not send the notification emails for them.',
],
'integration' => [
- /* translators: %s: site host. */
- 'subject' => __( 'SureForms: an integration is not receiving entries on %s', 'sureforms' ),
- /* translators: %s: site host. */
- 'anon' => __( 'SureForms saved entries on %s but could not pass them to a connected service.', 'sureforms' ),
+ 'subject' => 'SureForms: an integration is not receiving entries on %s',
+ 'anon' => 'SureForms saved entries on %s but could not pass them to a connected service.',
],
];
@@ -5071,22 +4875,16 @@ private function get_support_copy( $category ) {
}
return [
- /* translators: %s: site host. */
- 'subject' => __( 'SureForms: a problem with the forms on %s', 'sureforms' ),
- /* translators: %s: site host. */
- 'anon' => __( 'SureForms has recorded a problem with the forms on %s.', 'sureforms' ),
+ 'subject' => 'SureForms: a problem with the forms on %s',
+ 'anon' => 'SureForms has recorded a problem with the forms on %s.',
];
}
/**
* The sentence that opens the support email, with the failure count in it.
*
- * A switch with literal `_n()` calls rather than a singular/plural pair looked
- * up from an array. `_n()` has to see its two literals at extraction time to
- * emit an `msgid_plural`, and only that lets a locale supply the number of
- * forms it actually uses -- Polish and Russian need three, Arabic six,
- * Japanese one. Choosing on `1 === $count` in PHP is correct for English and
- * wrong everywhere with a different plural rule.
+ * English only, like the rest of the support email, so `1 === $count` is the
+ * whole plural rule.
*
* @param string $category One of Client_Logger::CATEGORIES. Unknown or absent
* gets neutral wording rather than a specific claim.
@@ -5098,49 +4896,33 @@ private function get_support_count_sentence( $category, $count ) {
switch ( $category ) {
case 'submission':
return sprintf(
- /* translators: %d: number of failed submissions. */
- _n(
- 'SureForms has recorded %d form submission that could not be completed.',
- 'SureForms has recorded %d form submissions that could not be completed.',
- $count,
- 'sureforms'
- ),
+ ( 1 === $count
+ ? 'SureForms has recorded %d form submission that could not be completed.'
+ : 'SureForms has recorded %d form submissions that could not be completed.' ),
$count
);
case 'notification':
return sprintf(
- /* translators: %d: number of failed notifications. */
- _n(
- 'SureForms saved %d entry but could not send the notification email for it.',
- 'SureForms saved %d entries but could not send the notification emails for them.',
- $count,
- 'sureforms'
- ),
+ ( 1 === $count
+ ? 'SureForms saved %d entry but could not send the notification email for it.'
+ : 'SureForms saved %d entries but could not send the notification emails for them.' ),
$count
);
case 'integration':
return sprintf(
- /* translators: %d: number of failed integration hand-offs. */
- _n(
- 'SureForms saved %d entry but could not pass it to a connected service.',
- 'SureForms saved %d entries but could not pass them to a connected service.',
- $count,
- 'sureforms'
- ),
+ ( 1 === $count
+ ? 'SureForms saved %d entry but could not pass it to a connected service.'
+ : 'SureForms saved %d entries but could not pass them to a connected service.' ),
$count
);
default:
return sprintf(
- /* translators: %d: number of recorded problems. */
- _n(
- 'SureForms has recorded %d problem with the forms on this site.',
- 'SureForms has recorded %d problems with the forms on this site.',
- $count,
- 'sureforms'
- ),
+ ( 1 === $count
+ ? 'SureForms has recorded %d problem with the forms on this site.'
+ : 'SureForms has recorded %d problems with the forms on this site.' ),
$count
);
}
@@ -5174,7 +4956,7 @@ private function get_support_message( $category = '', $form_title = '' ) {
$host = Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) );
$lines = [
- __( 'Hello SureForms support,', 'sureforms' ),
+ 'Hello SureForms support,',
'',
$count > 0
? $this->get_support_count_sentence( $category, $count )
@@ -5184,8 +4966,7 @@ private function get_support_message( $category = '', $form_title = '' ) {
if ( '' !== $form_title ) {
$lines[] = '';
$lines[] = sprintf(
- /* translators: %s: form title. */
- __( 'Form: %s', 'sureforms' ),
+ 'Form: %s',
$form_title
);
}
@@ -5198,33 +4979,22 @@ private function get_support_message( $category = '', $form_title = '' ) {
[
'',
'---',
- __( 'Site details', 'sureforms' ),
- // Labels translated, values not. The site owner reads this on screen
- // before sending it, so the labels are copy; the values are machine
- // data -- a version, a URL, a plugin name -- and stay verbatim. The
- // debug log below is left alone entirely for the same reason.
- /* translators: %s: site address. */
- sprintf( __( 'Site: %s', 'sureforms' ), home_url() ),
- /* translators: %s: SureForms version. */
- sprintf( __( 'SureForms: %s', 'sureforms' ), SRFM_VER ),
+ 'Site details',
+ sprintf( 'Site: %s', home_url() ),
+ sprintf( 'SureForms: %s', SRFM_VER ),
sprintf(
- /* translators: %s: SureForms Pro version, or a note that it is not active. */
- __( 'SureForms Pro: %s', 'sureforms' ),
- Helper::has_pro() && defined( 'SRFM_PRO_VER' ) ? SRFM_PRO_VER : __( 'not active', 'sureforms' )
+ 'SureForms Pro: %s',
+ Helper::has_pro() && defined( 'SRFM_PRO_VER' ) ? SRFM_PRO_VER : 'not active'
),
- /* translators: %s: WordPress version. */
- sprintf( __( 'WordPress: %s', 'sureforms' ), Helper::get_string_value( $wp_version ) ),
- /* translators: %s: PHP version. */
- sprintf( __( 'PHP: %s', 'sureforms' ), PHP_VERSION ),
+ sprintf( 'WordPress: %s', Helper::get_string_value( $wp_version ) ),
+ sprintf( 'PHP: %s', PHP_VERSION ),
sprintf(
- /* translators: %s: caching plugin name, or a note that none was detected. */
- __( 'Caching: %s', 'sureforms' ),
- '' !== $caching ? $caching : __( 'none detected', 'sureforms' )
+ 'Caching: %s',
+ '' !== $caching ? $caching : 'none detected'
),
sprintf(
- /* translators: %s: number of recorded failures, or a note that none were. */
- __( 'Recorded failures: %s', 'sureforms' ),
- $count > 0 ? Helper::get_string_value( $count ) : __( 'none recorded', 'sureforms' )
+ 'Recorded failures: %s',
+ $count > 0 ? Helper::get_string_value( $count ) : 'none recorded'
),
]
);
@@ -5247,8 +5017,7 @@ private function get_support_message( $category = '', $form_title = '' ) {
if ( $acked_at > 0 ) {
$lines[] = sprintf(
- /* translators: %s: date and time of the previous report, in the site's timezone. */
- __( 'Previously reported: %s', 'sureforms' ),
+ 'Previously reported: %s',
Helper::get_string_value( wp_date( 'Y-m-d H:i T', $acked_at ) )
);
}
diff --git a/admin/analytics.php b/admin/analytics.php
index 806aaaf79..c652395f4 100644
--- a/admin/analytics.php
+++ b/admin/analytics.php
@@ -990,6 +990,14 @@ private function detect_state_events() {
$onboarding_props = [];
$onboarding_analytics = Helper::get_srfm_option( 'onboarding_analytics', [] );
+ // Which wizard produced the blob. Outside the non-empty guard and not
+ // isset()-gated like the flags below, because absence IS the answer: a
+ // blob from the old wizard, or no blob at all, must still report 'no'.
+ // Without this the two wizards share one event name and can only be
+ // told apart by which properties happen to be present -- and a v2 run
+ // on Pro with no caching plugin emits none of the new ones.
+ $onboarding_props['onboarding_v2'] = ! empty( $onboarding_analytics['onboardingV2'] ) ? 'yes' : 'no';
+
if ( ! empty( $onboarding_analytics ) && is_array( $onboarding_analytics ) ) {
if ( ! empty( $onboarding_analytics['skippedSteps'] ) && is_array( $onboarding_analytics['skippedSteps'] ) ) {
$onboarding_props['skipped_steps'] = implode( ',', $onboarding_analytics['skippedSteps'] );
@@ -1011,15 +1019,38 @@ private function detect_state_events() {
$onboarding_props['exited_early'] = (bool) $onboarding_analytics['exitedEarly'] ? 'yes' : 'no';
}
- if ( ! empty( $onboarding_analytics['premiumFeatures']['selectedFeatures'] ) && is_array( $onboarding_analytics['premiumFeatures']['selectedFeatures'] ) ) {
- $premium = array_filter(
- $onboarding_analytics['premiumFeatures']['selectedFeatures'],
- static function( $f ) {
- return 'ai-form-generation' !== $f && 'entries' !== $f;
- }
+ // Add-ons step: the wizard shows one feature tab at a time
+ // instead of a checkbox list, so we report which tabs were opened and
+ // whether Upgrade was clicked. Blobs written by older wizards carry
+ // neither key and emit neither property.
+ if ( ! empty( $onboarding_analytics['premiumFeatures']['viewedTabs'] ) && is_array( $onboarding_analytics['premiumFeatures']['viewedTabs'] ) ) {
+ // The blob is whatever the wizard POSTed. The tabs are a closed
+ // set of four, so intersect against it rather than filtering on
+ // type: that drops anything unrecognised and caps both the
+ // content and the length of the property in one step.
+ $viewed_tabs = array_values(
+ array_intersect(
+ array_map( 'strval', array_filter( (array) $onboarding_analytics['premiumFeatures']['viewedTabs'], 'is_scalar' ) ),
+ [ 'multistep', 'conditional', 'calculation', 'conversational' ]
+ )
);
- $onboarding_props['selected_premium_features'] = implode( ',', $premium );
- $onboarding_props['premium_features_count'] = (string) count( $premium );
+ $onboarding_props['viewed_premium_tabs'] = implode( ',', $viewed_tabs );
+ }
+
+ if ( isset( $onboarding_analytics['premiumFeatures']['upgradeClicked'] ) ) {
+ $onboarding_props['premium_upgrade_clicked'] = (bool) $onboarding_analytics['premiumFeatures']['upgradeClicked'] ? 'yes' : 'no';
+ }
+
+ // Cache-conflict step: shown only when a recognised caching plugin
+ // is active; "yes" means the user pressed "I've fixed this".
+ //
+ // Read this as an abandonment signal, not as intent. Acknowledging
+ // is the only way past the step, so everyone who completes the
+ // wizard reports "yes" and "no" only ever appears next to
+ // exited_early. It cannot answer "did people act on the warning" --
+ // only "did they stop here".
+ if ( isset( $onboarding_analytics['cacheConflictAcknowledged'] ) ) {
+ $onboarding_props['cache_conflict_acknowledged'] = (bool) $onboarding_analytics['cacheConflictAcknowledged'] ? 'yes' : 'no';
}
}
diff --git a/admin/assets/js/notice-response.js b/admin/assets/js/notice-response.js
index 88c5fb900..4e15292f5 100644
--- a/admin/assets/js/notice-response.js
+++ b/admin/assets/js/notice-response.js
@@ -300,576 +300,4 @@
} else {
buildNoticeCarousel();
}
-
- // ------------------------------------------------------------------
- // Details modal
- // ------------------------------------------------------------------
-
- /**
- * The details as HTML, for the clipboard's text/html flavour.
- *
- * Gmail's composer is a rich-text field: it drops the newlines out of plain
- * text, which is what turned the diagnostics into one paragraph. Pasting HTML
- * instead keeps every break, and
keeps the log's columns lined up.
- *
- * Escaped here, not on the server, so the escaping happens once and in the same
- * place the markup is built. The source is a log holding whatever a server put
- * in an error message, so it is never trusted as markup.
- *
- * @param {string} text Plain-text details.
- * @return {string} Escaped HTML.
- */
- function detailsAsHtml( text ) {
- const escaped = text
- .replace( /&/g, '&' )
- .replace( //g, '>' );
-
- return (
- '
`;
-
- if ( window.ClipboardItem && navigator.clipboard?.write ) {
- await navigator.clipboard.write( [
- new window.ClipboardItem( {
- 'text/plain': new Blob( [ text ], {
- type: 'text/plain',
- } ),
- 'text/html': new Blob( [ html ], {
- type: 'text/html',
- } ),
- } ),
- ] );
- } else if ( navigator.clipboard?.writeText ) {
- // No ClipboardItem: the formatting is lost, which still beats
- // copying nothing.
- await navigator.clipboard.writeText( text );
- } else {
- throw new Error( 'no clipboard' );
- }
-
- setCopied( true );
- setCopiedOnce( true );
- setHint( dialogLabels.unlocked || '' );
- // Reverts on its own: a button stuck on "Copied" says nothing about the
- // next click.
- window.clearTimeout( revertRef.current );
- revertRef.current = window.setTimeout( () => setCopied( false ), 2000 );
-
- // Reported inside the try, so the event means a copy happened rather
- // than a copy was attempted. The classic notice records it the same way.
- post( 'srfm_notice_response', srfm_admin?.notice_response_nonce, {
- notice_id: item.id,
- button: 'copy_details',
- } );
- } catch ( e ) {
- // Refused outright, or no clipboard API at all. Do not claim it was
- // copied -- but do release Contact Support. It is the only route that
- // acknowledges the failure, these items are not dismissible, and only a
- // submission failure ever clears itself, so keeping it locked behind a
- // clipboard that cannot work leaves an undismissable notice with no
- // working action. The text is on screen and selectable either way.
- setCopied( false );
- setCopiedOnce( true );
- setHint( dialogLabels.copyFailed || '' );
- }
- };
-
- const closeDetails = () => {
- // Invalidates any fetch still in flight, so a slow response cannot refill a
- // dialog the user has already closed.
- requestRef.current += 1;
- setDetails( null );
- };
-
- // Fetched on open rather than localised with the page. The diagnostics are
- // written through a public REST route, so shipping them in srfm_admin put
- // attacker-authored text into every admin screen's HTML whether or not anyone
- // opened this dialog. See Admin::handle_action_item_details().
- //
- // The dialog opens immediately with a placeholder and fills in when the
- // response lands. A surface whose whole job is reporting a failure must not be
- // a button that does nothing until the network answers.
- const openDetails = ( item ) => {
- // Guards against interleaving: open A, close it, open B, and A's response
- // would otherwise land in B's dialog.
- const token = ++requestRef.current;
-
- setDetails( {
- ...item,
- details: dialogLabels.loading || '',
- support_url: '',
- pending: true,
- } );
-
- const fail = () => {
- // No report to paste, so the copy-first gate has nothing to gate on.
- // Contact Support is the only action that retires these notices, and
- // they are not dismissible -- leaving it locked would be an
- // undismissable notice with no working action on it.
- setCopiedOnce( true );
- setHint( '' );
-
- setDetails( ( prev ) =>
- prev
- ? {
- ...prev,
- details: dialogLabels.unavailable || '',
- // Untagged fallback: Contact Support is the only action
- // that retires these notices, so a failed fetch must not
- // take the way out with it.
- support_url: srfm_admin?.support_url || '',
- pending: false,
- failed: true,
- }
- : prev
- );
- };
-
- const ajaxUrl = srfm_admin?.ajax_url;
- const nonce = srfm_admin?.action_item_details_nonce;
-
- if ( ! ajaxUrl || ! nonce ) {
- fail();
- return;
- }
-
- const body = new FormData();
- body.append( 'action', 'srfm_action_item_details' );
- body.append( 'nonce', nonce );
- body.append( 'category', item.category || '' );
-
- fetch( ajaxUrl, { method: 'POST', credentials: 'same-origin', body } )
- .then( ( response ) => response.json() )
- .then( ( json ) => {
- if ( ! json?.success || ! json?.data ) {
- throw new Error( 'unavailable' );
- }
-
- if ( token !== requestRef.current ) {
- return;
- }
-
- setDetails( ( prev ) =>
- prev
- ? {
- ...prev,
- details: json.data.details || '',
- // Same fallback as the failure path. A successful
- // fetch must not end up with less to act on than a
- // failed one: these notices are not dismissible and
- // Contact Support is the only thing that retires
- // them, so an empty destination here is a dead end.
- support_url:
- json.data.support_url ||
- srfm_admin?.support_url ||
- '',
- pending: false,
- }
- : prev
- );
- } )
- .catch( () => {
- if ( token !== requestRef.current ) {
- return;
- }
-
- fail();
- } );
- };
-
- // Opens the dialog rather than navigating: the details are read here before
- // anything is sent. Nothing to download, nothing to intercept.
const handleFix = ( item, action ) => () =>
post( 'srfm_notice_response', srfm_admin?.notice_response_nonce, {
notice_id: item.id,
@@ -499,327 +196,50 @@ export default () => {
{ !! actionsFor( item ).length && (
- { actionsFor( item ).map( ( action, index ) =>
- // A native button for the one that opens the
- // dialog. Routed through force-ui's Button it
- // followed the href instead -- the same page in
- // a new tab -- and a control that navigates is
- // the wrong element for something that opens a
- // panel in place.
- //
- // force-ui's Dialog rather than an overlay
- // rendered in place. An overlay here is a
- // descendant of the panel's Container nesting,
- // and position:fixed resolves against the
- // nearest ancestor establishing a containing
- // block -- which is why one rendered here never
- // appeared. Dialog portals out, and brings the
- // focus trap, scroll lock and return-focus with
- // it. The classic wp-admin notices keep their
- // own framework-free dialog, because there is
- // no React on those screens; both read their
- // strings from the same PHP array.
- action.dialog ? (
-
- ) : (
-
- )
- ) }
+ { actionsFor( item ).map( ( action, index ) => (
+
+ ) ) }
) }
) ) }
) }
- { /* Read before send: the same text a support request needs, so it can
- be pasted rather than described.
-
- exitOnClickOutside is passed explicitly -- force-ui defaults it to
- false, so without it a backdrop click does nothing, where the
- classic dialog closes. */ }
-
);
};
diff --git a/src/admin/dashboard/Dashboard.js b/src/admin/dashboard/Dashboard.js
index af313bce0..50cd33314 100644
--- a/src/admin/dashboard/Dashboard.js
+++ b/src/admin/dashboard/Dashboard.js
@@ -16,6 +16,10 @@ export default () => {
const isFirstFormCreated = srfm_admin?.is_first_form_created || false;
const isProActive = srfm_admin?.is_pro_active || false;
+ // Distraction Free (SureForms Pro): drop Quick Access and lay the page out
+ // in one full-width column. Action items are alerts, not promotions, so
+ // they stay, above everything else.
+ const hidePromotions = srfm_admin?.hide_promotions || false;
const leftSidebar = (
<>
@@ -55,12 +59,21 @@ export default () => {
cols={ 12 }
gap="2xl"
>
-
- { leftSidebar }
-
-
- { rightSidebar }
-
+ { hidePromotions ? (
+
+
+ { leftSidebar }
+
+ ) : (
+ <>
+
+ { leftSidebar }
+
+
+ { rightSidebar }
+
+ >
+ ) }
diff --git a/src/admin/dashboard/GetStarted.js b/src/admin/dashboard/GetStarted.js
index bcf9bfe5e..0c7cec1d3 100644
--- a/src/admin/dashboard/GetStarted.js
+++ b/src/admin/dashboard/GetStarted.js
@@ -26,7 +26,7 @@ export default () => {
className="w-full bg-background-primary p-4 gap-8 shadow-sm-blur-1 rounded-xl border-0.5 border-solid border-border-subtle"
containerType="grid"
cols={ 12 }
- align="center"
+ align="start"
>
@@ -93,8 +93,10 @@ export default () => {
+ { /* Capped so the thumbnail stays the same size when the card
+ spans the full page (Distraction Free dashboard). */ }
{
setPopupVideo( videoUrl );
} }
diff --git a/src/admin/dashboard/index.js b/src/admin/dashboard/index.js
index abeeadedf..f074690fe 100644
--- a/src/admin/dashboard/index.js
+++ b/src/admin/dashboard/index.js
@@ -14,11 +14,35 @@ import {
PremiumFeatures,
UserDetails,
ImportForms,
+ CacheConflict,
Done,
} from '../onboarding';
import '../tw-base.scss';
import '../onboarding/styles.scss';
+// One definition for both Router branches below. Keeping two hand-maintained
+// copies is how `cache-conflict` ended up registered in only one of them: a
+// missing path falls through to `*`, which unmounts OnboardingLayout and wipes
+// the wizard's state mid-flow.
+const ONBOARDING_ROUTES = [
+ [ 'welcome', ],
+ [ 'connect', ],
+ [ 'email-delivery', ],
+ [ 'premium-features', ],
+ [ 'import-forms', ],
+ [ 'cache-conflict', ],
+ [ 'user-details', ],
+ [ 'done', ],
+];
+
+const renderOnboardingRoutes = () => (
+ }>
+ { ONBOARDING_ROUTES.map( ( [ path, element ] ) => (
+
+ ) ) }
+
+);
+
const APP = () => {
const { onboarding_completed, onboarding_redirect } = srfm_admin || {};
@@ -29,26 +53,10 @@ const APP = () => {
return (
- }>
- } />
- } />
- }
- />
- }
- />
- } />
- } />
- } />
-
+ { renderOnboardingRoutes() }
- }
+ element={ }
/>
@@ -60,21 +68,7 @@ const APP = () => {
} />
- }>
- } />
- } />
- }
- />
- }
- />
- } />
- } />
- } />
-
+ { renderOnboardingRoutes() }
} />
diff --git a/src/admin/onboarding/components/before-after-slider.js b/src/admin/onboarding/components/before-after-slider.js
new file mode 100644
index 000000000..a73ad2033
--- /dev/null
+++ b/src/admin/onboarding/components/before-after-slider.js
@@ -0,0 +1,104 @@
+import { useRef, useState } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { ChevronsLeftRight } from 'lucide-react';
+
+/**
+ * Before/after comparison. `before` fills the box; `after` is revealed from
+ * the right edge up to a divider that follows the pointer -- move across the
+ * card and the two versions wipe between each other, with nothing to click.
+ *
+ * A transparent native range input sits over the box so the comparison is
+ * reachable by keyboard. It takes no pointer events, so mouse and touch are
+ * driven only by the move handler below and the two cannot compute slightly
+ * different positions and fight over the divider.
+ *
+ * Pointer rather than mouse events: touch has no hover, but a finger dragged
+ * across the card still emits pointermove, so the wipe works there too.
+ *
+ * @param {Object} props
+ * @param {Node} props.before Full-size "before" card.
+ * @param {Node} props.after Full-size "after" card (same dimensions).
+ * @param {number} props.width Box width in px.
+ * @param {number} props.height Box height in px.
+ * @param {number} props.initial Initial divider position, 0–100 (% from the left).
+ * @param {string} props.label Accessible name for the drag handle.
+ * @return {JSX.Element} The slider.
+ */
+const BeforeAfterSlider = ( {
+ before,
+ after,
+ width = 283,
+ height = 246,
+ initial = 50,
+ label = __( 'Compare free and premium', 'sureforms' ),
+} ) => {
+ const [ position, setPosition ] = useState( initial );
+ const trackRef = useRef( null );
+
+ // Measured per event rather than cached: the card is inside a tab panel that
+ // can be scrolled or re-laid-out between renders, and a stale rect silently
+ // offsets the divider from the cursor.
+ const followPointer = ( event ) => {
+ const rect = trackRef.current?.getBoundingClientRect();
+
+ if ( ! rect?.width ) {
+ return;
+ }
+
+ const percent = ( ( event.clientX - rect.left ) / rect.width ) * 100;
+
+ setPosition( Math.min( 100, Math.max( 0, Math.round( percent ) ) ) );
+ };
+
+ // The ring is keyed to :focus-visible, not :focus-within. The range input
+ // covers the whole card, so focus-within also fires on click and drew a
+ // border around the card every time someone used the slider with a mouse.
+ // Keyboard focus still needs the ring: the input itself is invisible, so
+ // without it there is nothing on screen to show where focus is.
+ return (
+