PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
2.12.8 2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 All 98 releases
← All changes | inc/generate-form-markup.php +615 -43 2.8.2 → 2.12.8 View file →
@@ -7,8 +7,10 @@
7 7 */
8 8
9 9 namespace SRFM\Inc;
10 10
11 +use SRFM\Inc\Compatibility\Multilingual\Multilingual_Manager;
12 +use SRFM\Inc\Compatibility\Multilingual\String_Translator;
11 13 use SRFM\Inc\Traits\Get_Instance;
12 14
13 15 if ( ! defined( 'ABSPATH' ) ) {
14 16 exit; // Exit if accessed directly.
@@ -22,8 +24,16 @@
22 24 class Generate_Form_Markup {
23 25 use Get_Instance;
24 26
25 27 /**
28 + * Query arg marking an editor visit as arriving from the front-end "Edit Form"
29 + * pill, so the click can be attributed without any front-end JavaScript.
30 + *
31 + * @since 2.12.6
32 + */
33 + public const EDIT_FORM_BUTTON_SOURCE_ARG = 'srfm_edit_src';
34 +
35 + /**
26 36 * Current block attributes for the form being rendered.
27 37 * Used by child blocks (like inline button) to access parent form's embed styling.
28 38 *
29 39 * @var array<string,mixed>
@@ -31,8 +41,22 @@
31 41 */
32 42 private static $current_block_attrs = [];
33 43
34 44 /**
45 + * IDs of the forms known to be on the current request, keyed by form ID.
46 + *
47 + * Seeded at the `wp` hook (collect_queried_form_ids(), before any output) by
48 + * parsing the queried post, and added to at render time by get_form_markup().
49 + * The seed is load-bearing: on modern themes the admin bar renders at
50 + * wp_body_open (priority 0) — BEFORE the_content — so the render-time registry
51 + * alone would be empty when the node is built.
52 + *
53 + * @var array<int,bool>
54 + * @since 2.12.3
55 + */
56 + private static $rendered_form_ids = [];
57 +
58 + /**
35 59 * Constructor
36 60 *
37 61 * @since 0.0.1
38 62 */
@@ -37,11 +61,61 @@
37 61 * @since 0.0.1
38 62 */
39 63 public function __construct() {
40 64 add_action( 'rest_api_init', [ $this, 'register_custom_endpoint' ] );
65 + // Seed the form registry from the queried post before any output, so the
66 + // admin bar (which renders at wp_body_open, before the_content) has the list.
67 + add_action( 'wp', [ $this, 'collect_queried_form_ids' ] );
68 + // Frontend admin-bar "Entries" deep-link. Priority 100 mirrors the
69 + // existing "Edit Form" node in Post_Types.
70 + add_action( 'admin_bar_menu', [ $this, 'add_entries_admin_bar_node' ], 100 );
41 71 }
42 72
43 73 /**
74 + * Seed the rendered-form registry from the queried singular post's content,
75 + * before any output.
76 + *
77 + * The admin bar renders at wp_body_open (priority 0) on modern themes — before
78 + * the_content — so relying on the render-time registry alone would leave the
79 + * node empty on essentially every embed. Parsing the queried post here (srfm/form
80 + * blocks incl. reusable/synced patterns, and [sureforms] shortcodes, via the
81 + * shared Form_Styling helper) covers those; get_form_markup() then adds anything
82 + * a static parse can't see (page builders, FSE template parts).
83 + *
84 + * @since 2.12.3
85 + * @return void
86 + */
87 + public function collect_queried_form_ids() {
88 + if ( is_admin() || ! is_singular() ) {
89 + return;
90 + }
91 +
92 + // The only consumer is the admin-bar node, which bails for anyone without
93 + // manage_options. Without this guard every anonymous front-end request ran
94 + // parse_blocks() plus recursive get_post() expansion of synced patterns for a
95 + // feature it could never see. The current user is already resolved at `wp`.
96 + if ( ! is_admin_bar_showing() || ! Helper::current_user_can() ) {
97 + return;
98 + }
99 +
100 + $post_id = absint( get_queried_object_id() );
101 + if ( 0 === $post_id ) {
102 + return;
103 + }
104 +
105 + // 'raw' context: the default 'display' context applies the post_content filter,
106 + // so the parsed list could disagree with Form_Styling::should_skip_frontend_styles(),
107 + // which reads raw.
108 + $content = Helper::get_string_value( get_post_field( 'post_content', $post_id, 'raw' ) );
109 + foreach ( Form_Styling::get_form_ids_from_content( $content ) as $form_id ) {
110 + $fid = absint( $form_id );
111 + if ( $fid > 0 ) {
112 + self::$rendered_form_ids[ $fid ] = true;
113 + }
114 + }
115 + }
116 +
117 + /**
44 118 * Get the current block attributes.
45 119 *
46 120 * @return array<string,mixed>
47 121 * @since 2.7.0
@@ -50,8 +124,133 @@
50 124 return self::$current_block_attrs;
51 125 }
52 126
53 127 /**
128 + * Add an "Entries" node to the frontend admin bar on any page that contains a
129 + * SureForms form, deep-linking to the Entries admin page pre-filtered to that
130 + * form. The form list comes from collect_queried_form_ids() (seeded at `wp`)
131 + * plus the render-time registry.
132 + *
133 + * ACTUAL COVERAGE: srfm/form blocks, synced/reusable patterns (core/block) and
134 + * [sureforms] shortcodes in the queried post's content, plus a singular form CPT
135 + * page. Page builders that store layout outside post_content (Elementor in
136 + * _elementor_data, Bricks in _bricks_page_content_*) and FSE template parts are
137 + * NOT covered: the render-time registry is written during the_content, which on
138 + * block themes runs after wp_admin_bar_render() at wp_body_open, so the node is
139 + * already built. On classic themes those paths happen to work via core's wp_footer
140 + * fallback, which makes the feature silently theme-dependent. Use the
141 + * `srfm_admin_bar_entries_form_ids` filter to contribute builder-sourced IDs until
142 + * early builder detection lands. With multiple forms the node becomes a
143 + * submenu (one child per form); the parent then links to the unfiltered page.
144 + *
145 + * Runs on admin_bar_menu, which fires as the bar renders (wp_body_open on modern
146 + * themes). Gated to users who can view the Entries page (the same
147 + * `manage_options` capability the admin page and entries REST endpoints use).
148 + *
149 + * @param \WP_Admin_Bar $wp_admin_bar The admin bar instance.
150 + * @since 2.12.3
151 + * @return void
152 + */
153 + public function add_entries_admin_bar_node( $wp_admin_bar ) {
154 + // Frontend only, and only when the bar is actually shown for this user.
155 + if ( is_admin() || ! is_admin_bar_showing() || ! $wp_admin_bar instanceof \WP_Admin_Bar ) {
156 + return;
157 + }
158 +
159 + // Match who can view entries (admin page + entries REST capability).
160 + if ( ! Helper::current_user_can() ) {
161 + return;
162 + }
163 +
164 + $form_ids = array_map( 'absint', array_keys( self::$rendered_form_ids ) );
165 +
166 + // Fallback for a form's own singular page if nothing was recorded.
167 + if ( empty( $form_ids ) && is_singular( SRFM_FORMS_POST_TYPE ) ) {
168 + $singular_id = absint( get_the_ID() );
169 + if ( $singular_id > 0 ) {
170 + $form_ids[] = $singular_id;
171 + }
172 + }
173 +
174 + /**
175 + * Filter the form IDs offered in the admin-bar Entries node. Lets sources a
176 + * content parse / render can't see contribute — Elementor (_elementor_data),
177 + * Bricks (_bricks_page_content_*), FSE template parts, or Pro's
178 + * [srfm_show_entries] shortcode.
179 + *
180 + * @since 2.12.3
181 + * @param array<int> $form_ids Form IDs detected on the current request.
182 + */
183 + $form_ids = array_map( 'absint', (array) apply_filters( 'srfm_admin_bar_entries_form_ids', $form_ids ) );
184 +
185 + // Keep only real SureForms forms. The [sureforms] shortcode accepts any
186 + // published post ID, so esc_html() below must not be the only barrier
187 + // against a hostile post title (e.g. authored by an Editor with unfiltered_html).
188 + $form_ids = array_values(
189 + array_unique(
190 + array_filter(
191 + $form_ids,
192 + static function ( $fid ) {
193 + return $fid > 0 && SRFM_FORMS_POST_TYPE === get_post_type( $fid );
194 + }
195 + )
196 + )
197 + );
198 + if ( empty( $form_ids ) ) {
199 + return;
200 + }
201 +
202 + $entries_base = admin_url( 'admin.php?page=' . SRFM_ENTRIES );
203 + $node_id = 'srfm-entries';
204 + $icon = '<span class="ab-icon dashicons dashicons-list-view" style="line-height:1.2;margin-right:4px;"></span>';
205 +
206 + // Single form — link straight to its filtered entries.
207 + if ( 1 === count( $form_ids ) ) {
208 + $wp_admin_bar->add_node(
209 + [
210 + 'id' => $node_id,
211 + 'title' => $icon . '<span class="ab-label">' . esc_html__( 'Entries', 'sureforms' ) . '</span>',
212 + 'href' => esc_url( $entries_base . '#/?form=' . $form_ids[0] ),
213 + // Core esc_attr()s meta['title'], so pass it unescaped here.
214 + 'meta' => [ 'title' => __( 'View entries for this form', 'sureforms' ) ],
215 + ]
216 + );
217 + return;
218 + }
219 +
220 + // Multiple forms — parent links to unfiltered Entries, one child per form.
221 + $wp_admin_bar->add_node(
222 + [
223 + 'id' => $node_id,
224 + 'title' => $icon . '<span class="ab-label">' . esc_html__( 'Entries', 'sureforms' ) . '</span>',
225 + 'href' => esc_url( $entries_base ),
226 + 'meta' => [ 'title' => __( 'View form entries', 'sureforms' ) ],
227 + ]
228 + );
229 +
230 + // Cap the submenu; the parent's unfiltered link covers the overflow so a page
231 + // with many forms can't blow past the (non-scrolling) admin bar.
232 + foreach ( array_slice( $form_ids, 0, 10 ) as $form_id ) {
233 + $title = get_the_title( $form_id );
234 + // get_the_title() runs the_title filters that may inject markup, and
235 + // WP_Admin_Bar does not escape node titles — strip tags and escape here.
236 + $title = '' !== $title
237 + ? esc_html( wp_strip_all_tags( $title ) )
238 + /* translators: %d: form ID. */
239 + : esc_html( sprintf( __( 'Form #%d', 'sureforms' ), $form_id ) );
240 +
241 + $wp_admin_bar->add_node(
242 + [
243 + 'id' => $node_id . '-' . $form_id,
244 + 'parent' => $node_id,
245 + 'title' => $title,
246 + 'href' => esc_url( $entries_base . '#/?form=' . $form_id ),
247 + ]
248 + );
249 + }
250 + }
251 +
252 + /**
54 253 * Add custom API Route to generate form markup.
55 254 *
56 255 * @return void
57 256 * @since 0.0.1
@@ -61,15 +260,84 @@
61 260 'sureforms/v1',
62 261 '/generate-form-markup',
63 262 [
64 263 'methods' => 'GET',
65 - 'callback' => [ $this, 'get_form_markup' ],
66 - 'permission_callback' => '__return_true',
264 + 'callback' => [ $this, 'render_form_markup_endpoint' ],
265 + 'permission_callback' => [ $this, 'render_form_markup_permissions_check' ],
266 + 'args' => [
267 + 'id' => [
268 + 'required' => true,
269 + 'type' => 'integer',
270 + 'sanitize_callback' => 'absint',
271 + 'validate_callback' => static function ( $value ) {
272 + return absint( $value ) > 0;
273 + },
274 + ],
275 + ],
67 276 ]
68 277 );
69 278 }
70 279
71 280 /**
281 + * Permission check for the form-markup endpoint.
282 + *
283 + * The endpoint exists for one purpose: rendering the editor preview when a user
284 + * picks a form in the srfm/form block. So the caller must at least be able to
285 + * edit content. A nonce is not sufficient — `srfm_form_markup` is minted in
286 + * enqueue_block_editor_assets, so passing it proves only that the caller reached
287 + * the editor, never what they are allowed to read.
288 + *
289 + * @since 2.12.3
290 + * @return bool|\WP_Error True when allowed, WP_Error otherwise.
291 + */
292 + public function render_form_markup_permissions_check() {
293 + if ( ! current_user_can( 'edit_posts' ) ) {
294 + return new \WP_Error(
295 + 'srfm_rest_cannot_render_form',
296 + __( 'Sorry, you are not allowed to render form markup.', 'sureforms' ),
297 + [ 'status' => rest_authorization_required_code() ]
298 + );
299 + }
300 +
301 + return true;
302 + }
303 +
304 + /**
305 + * Render the requested form for the block-editor preview.
306 + *
307 + * Constrains the requested ID to a SureForms form, and to one the caller is
308 + * allowed to see: published forms are already public, anything else (draft,
309 + * pending, private, trashed) needs the SureForms forms capability.
310 + *
311 + * @param \WP_REST_Request<array<string,mixed>> $request REST request.
312 + *
313 + * @since 2.12.3
314 + * @return string|\WP_Error Form markup, or WP_Error when the form is not renderable for this caller.
315 + */
316 + public function render_form_markup_endpoint( $request ) {
317 + $form_id = Helper::get_integer_value( $request->get_param( 'id' ) );
318 + $form = $form_id > 0 ? get_post( $form_id ) : null;
319 +
320 + if ( ! $form instanceof \WP_Post || SRFM_FORMS_POST_TYPE !== $form->post_type ) {
321 + return new \WP_Error(
322 + 'srfm_rest_form_not_found',
323 + __( 'No form was found with the given ID.', 'sureforms' ),
324 + [ 'status' => 404 ]
325 + );
326 + }
327 +
328 + if ( 'publish' !== $form->post_status && ! Helper::current_user_can() ) {
329 + return new \WP_Error(
330 + 'srfm_rest_cannot_render_form',
331 + __( 'Sorry, you are not allowed to render this form.', 'sureforms' ),
332 + [ 'status' => rest_authorization_required_code() ]
333 + );
334 + }
335 +
336 + return Helper::get_string_value( self::get_form_markup( $form_id ) );
337 + }
338 +
339 + /**
72 340 * Handle Form status
73 341 *
74 342 * @param int|string $id Contains form ID.
75 343 * @param bool $show_title_current_page Boolean to srfm-show/srfm-hide form title.
@@ -81,17 +349,27 @@
81 349 * @return string|false
82 350 * @since 0.0.1
83 351 */
84 352 public static function get_form_markup( $id, $show_title_current_page = true, $sf_classname = '', $post_type = 'post', $do_blocks = false, $block_attrs = [] ) {
85 - if ( isset( $_GET['id'] ) && isset( $_GET['srfm_form_markup_nonce'] ) ) {
86 - $nonce = isset( $_GET['srfm_form_markup_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['srfm_form_markup_nonce'] ) ) : '';
87 - $id = wp_verify_nonce( $nonce, 'srfm_form_markup' ) && ! empty( $_GET['srfm_form_markup_nonce'] ) ? Helper::get_integer_value( sanitize_text_field( wp_unslash( $_GET['id'] ) ) ) : '';
88 - } else {
89 - $id = Helper::get_integer_value( $id );
90 - }
353 + // SECURITY INVARIANT — a renderer must never read the request to decide what to
354 + // render. The caller's `$id` is the only source of truth here; the REST route
355 + // owns request parsing (see render_form_markup_endpoint). Reintroducing any
356 + // query-string override would let a URL change which form a page renders.
357 + $id = Helper::get_integer_value( $id );
91 358
92 359 // Check for any form restrictions.
93 360 $form_id = Helper::get_integer_value( $id );
361 +
362 + // Additively record the form for the admin-bar "Entries" node. The registry
363 + // is primarily seeded at `wp` (collect_queried_form_ids) because the bar
364 + // renders before the_content; this render-time write is what covers paths a
365 + // content parse can't see — page builders (Elementor/Bricks) and FSE template
366 + // parts. Recorded before the restriction check: a restricted form is still on
367 + // the page, and its admin still wants its entries link.
368 + if ( $form_id > 0 ) {
369 + self::$rendered_form_ids[ $form_id ] = true;
370 + }
371 +
94 372 if ( Form_Restriction::is_form_restricted( $form_id ) ) {
95 373 return Form_Restriction::display_form_restriction_message( $form_id );
96 374 }
97 375
@@ -100,9 +378,10 @@
100 378
101 379 do_action( 'srfm_localize_conditional_logic_data', $id );
102 380 $post = get_post( Helper::get_integer_value( $id ) );
103 381
104 - $content = '';
382 + $content = '';
383 + $form_blocks = [];
105 384
106 385 $active_plugins = Helper::get_array_value( get_option( 'active_plugins', [] ) );
107 386 $is_learndash_active = in_array( 'sfwd-lms/sfwd_lms.php', $active_plugins, true );
108 387
@@ -113,8 +392,14 @@
113 392 if ( $post && ! empty( $post->post_content ) ) {
114 393 // Filter to get the post content for the form.
115 394 $post_content = apply_filters( 'srfm_get_form_post_content', $post->post_content, $id );
116 395
396 + // Pre-translate block-attribute strings (labels, placeholders, options, etc.)
397 + // before rendering, so the visitor's chosen language is honoured. Returns the
398 + // translated markup plus the parsed top-level blocks so we can derive the block
399 + // count without re-parsing the rendered HTML. No-op when no provider is active.
400 + [ $post_content, $form_blocks ] = String_Translator::get_instance()->translate_form_content_with_blocks( (int) $id, Helper::get_string_value( $post_content ), $post );
401 +
117 402 if ( ! empty( $do_blocks ) ) {
118 403 $content = do_blocks( $post_content );
119 404 } else {
120 405 $content = apply_filters( 'the_content', $post_content ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- wordpress hook
@@ -120,15 +405,24 @@
120 405 $content = apply_filters( 'the_content', $post_content ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- wordpress hook
121 406 }
122 407 }
123 408
124 - $blocks = parse_blocks( $content );
125 - $block_count = count( $blocks );
409 + // Reuse the translator's parse on the multilingual path; otherwise parse once here
410 + // (single-language path). Either way the content is parsed exactly once, never three times.
411 + $form_blocks = ! empty( $form_blocks ) ? $form_blocks : parse_blocks( $content );
412 + $block_count = count( $form_blocks );
126 413 $current_post_type = get_post_type();
127 414
128 - // load all the frontend assets.
129 - Frontend_Assets::enqueue_scripts_and_styles();
415 + // When enabled, the form renders without the SureForms inline CSS variables so
416 + // the site's own CSS fully controls its appearance. Per-form Custom CSS still applies.
417 + // Read ONCE through the canonical checker so the `srfm_disable_default_styles`
418 + // filter runs a single time per render and governs the enqueue path, the
419 + // marker class and the inline CSS guard alike.
420 + $disable_default_styles = Form_Styling::is_default_styling_disabled( $id );
130 421
422 + // load all the frontend assets. Skips the SureForms stylesheets when the form has default styling disabled.
423 + Frontend_Assets::enqueue_scripts_and_styles( $disable_default_styles );
424 +
131 425 ob_start();
132 426 if ( '' !== $id && 0 !== $block_count ) {
133 427
134 428 // Create unique container ID using blockId if available (for multiple embeds of same form).
@@ -142,8 +436,9 @@
142 436 // Apply per-embed styling customization when formTheme is not 'inherit'.
143 437 if ( Form_Styling::has_custom_styling( $block_attrs ) ) {
144 438 $form_styling = Form_Styling::map_block_attrs_to_styling( $form_styling, $block_attrs );
145 439 }
440 +
146 441 // Background Settings.
147 442 $bg_type = $form_styling['bg_type'] ?? 'color';
148 443 $bg_color = $form_styling['bg_color'] ?? '';
149 444 $bg_image = $form_styling['bg_image'] ?? '';
@@ -229,8 +524,9 @@
229 524 $base_container_class, // Base class for JS compatibility (frontend.js, phone.js).
230 525 ! empty( $block_id_suffix ) ? $container_id : '', // Unique class for CSS scoping when blockId exists.
231 526 $sf_classname,
232 527 'Neve' === $theme_name ? $neve_theme_margin_class_name : '', // compatibility with Neve theme for margin between main content and footer.
528 + $disable_default_styles ? 'srfm-styling-none' : '', // Marker class when default styling is disabled, so custom CSS can target the state.
233 529 $background_classes,
234 530 ];
235 531
236 532 $custom_added_classes = Helper::get_meta_value( $id, '_srfm_additional_classes' );
@@ -242,11 +538,30 @@
242 538 }
243 539 }
244 540 }
245 541
246 - $page_break_settings = defined( 'SRFM_PRO_VER' ) && apply_filters( 'srfm_use_page_break_layout', true ) ? get_post_meta( $id, '_srfm_page_break_settings', true ) : [];
247 - $page_break_settings = ! empty( $page_break_settings ) && is_array( $page_break_settings ) ? $page_break_settings : [];
248 - $is_page_break = ! empty( $page_break_settings ) ? $page_break_settings['is_page_break'] : false;
542 + $page_break_settings = defined( 'SRFM_PRO_VER' ) && apply_filters( 'srfm_use_page_break_layout', true ) ? get_post_meta( $id, '_srfm_page_break_settings', true ) : [];
543 + $page_break_settings = ! empty( $page_break_settings ) && is_array( $page_break_settings ) ? $page_break_settings : [];
544 + $is_page_break = ! empty( $page_break_settings ) ? $page_break_settings['is_page_break'] : false;
545 + // Auto-advance is read here rather than in Pro's button renderer because
546 + // save & resume replaces that whole container through the
547 + // srfm_page_break_buttons_html filter, which would drop the attributes.
548 + // The form tag is rendered exactly once and is already how both step
549 + // runtimes receive their per-form settings (form-id, ajaxurl,
550 + // data-submit-token).
551 + //
552 + // Two stored settings rather than one because the two layouts are
553 + // mutually exclusive: Pro filters srfm_use_page_break_layout to false
554 + // when the conversational layout is on, so $page_break_settings is
555 + // empty there and its editor panel is hidden. Each layout keeps the
556 + // toggle with the rest of its own settings, and only one can apply.
557 + $conversational_settings = defined( 'SRFM_PRO_VER' ) ? get_post_meta( $id, '_srfm_conversational_form', true ) : [];
558 + $conversational_settings = ! empty( $conversational_settings ) && is_array( $conversational_settings ) ? $conversational_settings : [];
559 + $is_conversational = ! empty( $conversational_settings['is_cf_enabled'] );
560 + $active_step_settings = $is_conversational ? $conversational_settings : ( $is_page_break ? $page_break_settings : [] );
561 + $auto_advance_key = $is_conversational ? 'cf_auto_advance' : 'auto_advance';
562 + $auto_advance = ! empty( $active_step_settings[ $auto_advance_key ] );
563 + $auto_advance_hide_next = $auto_advance && ! empty( $active_step_settings[ $auto_advance_key . '_hide_next' ] );
249 564 $page_break_progress_type = ! empty( $page_break_settings ) ? $page_break_settings['progress_indicator_type'] : 'none';
250 565 $form_confirmation = get_post_meta( $id, '_srfm_form_confirmation' );
251 566 $confirmation_type = '';
252 567 $submission_action = '';
@@ -266,8 +581,9 @@
266 581 }
267 582
268 583 // Submit button.
269 584 $button_text = Helper::get_meta_value( $id, '_srfm_submit_button_text' );
585 + $button_text = String_Translator::get_instance()->translate_submit_button( (int) $id, Helper::get_string_value( $button_text ) );
270 586 $submit_button_alignment = ! empty( $form_styling['submit_button_alignment'] ) ? $form_styling['submit_button_alignment'] : 'left';
271 587
272 588 if ( is_rtl() && ( 'left' === $submit_button_alignment || 'right' === $submit_button_alignment ) ) {
273 589 $submit_button_alignment = 'right' === $submit_button_alignment ? 'left' : 'right';
@@ -342,13 +658,19 @@
342 658 if ( ! $should_show_submit_button ) {
343 659 $form_classes[] = 'srfm-submit-button-hidden';
344 660 }
345 661
662 + // The scoped Custom CSS below is for embedded views only: on the form's own
663 + // single/instant view, templates/single-form.php already outputs the Custom
664 + // CSS (unscoped) in <head> — emitting it here too would duplicate it.
665 + $embed_custom_css = 'sureforms_form' !== $current_post_type ? $custom_css : '';
346 666 ?>
347 667 <div class="<?php echo esc_attr( implode( ' ', array_filter( $form_classes ) ) ); ?>">
668 + <?php if ( ! $disable_default_styles || '' !== $embed_custom_css ) { // Nothing to print otherwise — avoid an empty style block. ?>
348 669 <style>
349 670 /* Need to check and remove the input variables related to the Style Tab. */
350 671 <?php echo esc_html( ".{$container_id}" ); ?> {
672 + <?php if ( ! $disable_default_styles ) { ?>
351 673 /* New test variables */
352 674 --srfm-color-scheme-primary: <?php echo esc_html( $primary_color_var ); ?>;
353 675 --srfm-color-scheme-text-on-primary: <?php echo esc_html( $label_text_color_var ); ?>;
354 676 --srfm-color-scheme-text: <?php echo esc_html( $help_color_var ); ?>;
@@ -385,9 +707,9 @@
385 707 --srfm-dropdown-icon-color: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.65 );
386 708 --srfm-dropdown-icon-disabled: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.25 );
387 709
388 710 /* Background Control Variables */
389 - <?php
711 + <?php
390 712 // Form Styles.
391 713 $styling_vars = [
392 714 // Instant Form Padding.
393 715 '--srfm-instant-form-padding-top' => sanitize_text_field( "{$instant_form['padding_top']}{$instant_form['padding_unit']}" ),
@@ -461,33 +783,33 @@
461 783 foreach ( $styling_vars as $key => $value ) {
462 784 echo esc_html( Helper::get_string_value( $key ) ) . ': ' . esc_html( Helper::get_string_value( $value ) ) . ';';
463 785 }
464 786 ?>
465 - <?php
466 - // Echo the CSS variables for the form according to the field spacing selected.
467 - foreach ( $selected_size as $variable => $value ) {
468 - echo esc_html( Helper::get_string_value( $variable ) ) . ': ' . esc_html( Helper::get_string_value( $value ) ) . ';';
469 - }
470 - do_action(
471 - 'srfm_form_css_variables',
472 - [
473 - 'id' => $id,
474 - 'primary_color' => $primary_color_var,
475 - 'help_color' => $help_color_var,
476 - 'form_styling' => $form_styling,
477 - 'block_attrs' => $block_attrs,
478 - ]
479 - );
480 - // echo custom css on page/post.
481 - if ( 'sureforms_form' !== $current_post_type ) {
482 - echo wp_kses_post( $custom_css );
483 - }
787 + <?php
788 + // Echo the CSS variables for the form according to the field spacing selected.
789 + foreach ( $selected_size as $variable => $value ) {
790 + echo esc_html( Helper::get_string_value( $variable ) ) . ': ' . esc_html( Helper::get_string_value( $value ) ) . ';';
791 + }
792 + do_action(
793 + 'srfm_form_css_variables',
794 + [
795 + 'id' => $id,
796 + 'primary_color' => $primary_color_var,
797 + 'help_color' => $help_color_var,
798 + 'form_styling' => $form_styling,
799 + 'block_attrs' => $block_attrs,
800 + ]
801 + );
802 + } // End if default styling is not disabled.
803 + echo wp_kses_post( $embed_custom_css );
484 804 ?>
485 805 }
486 806 </style>
807 + <?php } // End if the style block has content. ?>
487 808 <?php
488 809 if ( 'sureforms_form' !== $current_post_type && true === $show_title_current_page ) {
489 810 $title = ! empty( get_the_title( (int) $id ) ) ? get_the_title( (int) $id ) : '';
811 + $title = String_Translator::get_instance()->translate_form_title( (int) $id, $title );
490 812 ?>
491 813 <h2 class="srfm-form-title"><?php echo esc_html( $title ); ?></h2>
492 814 <?php
493 815 }
@@ -559,12 +881,25 @@
559 881 self::$current_block_attrs = [];
560 882 return ob_get_clean();
561 883 }
562 884 $submit_token = Submit_Token::generate( (int) $id );
885 + // Separately namespaced from the submission token: this one is only good
886 + // for incrementing a view counter, so scraping it from the page buys an
887 + // attacker nothing beyond what the beacon already does, and it cannot be
888 + // replayed against the submit endpoint.
889 + $view_token = Submit_Token::generate( (int) $id, Submit_Token::NAMESPACE_VIEW );
563 890
891 + // Admin-only shortcut into the form editor. Emitted here, immediately
892 + // above the <form>, so it occupies its own row in normal flow and can
893 + // never overlap a field. Already inside the `.srfm-form-container`
894 + // branch, so a zero-block form (no container) never reaches here and
895 + // cannot emit an orphaned pill. Works for every embed method (block,
896 + // shortcode, widget) because they all render through this function.
897 + self::render_edit_form_button( (int) $id );
898 +
564 899 ?>
565 900 <form method="post" enctype="multipart/form-data" id="srfm-form-<?php echo esc_attr( Helper::get_string_value( $id ) ); ?>" class="srfm-form <?php echo esc_attr( 'sureforms_form' === $post_type ? 'srfm-single-form ' : '' ); ?>"
566 - form-id="<?php echo esc_attr( Helper::get_string_value( $id ) ); ?>" after-submission="<?php echo esc_attr( $submission_action ); ?>" message-type="<?php echo esc_attr( $confirmation_type ? $confirmation_type : 'same page' ); ?>" success-url="<?php echo esc_attr( $success_url ? $success_url : '' ); ?>" ajaxurl="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>" data-submit-token="<?php echo esc_attr( $submit_token ); ?>"
901 + form-id="<?php echo esc_attr( Helper::get_string_value( $id ) ); ?>" after-submission="<?php echo esc_attr( $submission_action ); ?>" message-type="<?php echo esc_attr( $confirmation_type ? $confirmation_type : 'same page' ); ?>" success-url="<?php echo esc_attr( $success_url ? $success_url : '' ); ?>" ajaxurl="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>" data-submit-token="<?php echo esc_attr( $submit_token ); ?>" data-view-token="<?php echo esc_attr( $view_token ); ?>"<?php echo $auto_advance ? ' data-srfm-auto-advance="1"' : ''; ?><?php echo $auto_advance_hide_next ? ' data-srfm-hide-next="1"' : ''; ?>
567 902 >
568 903 <?php
569 904 // Submission security is handled via the HMAC token in data-submit-token.
570 905 $global_setting_options = get_option( 'srfm_security_settings_options' );
@@ -575,8 +910,21 @@
575 910 }
576 911 ?>
577 912
578 913 <input type="hidden" value="<?php echo esc_attr( Helper::get_string_value( $id ) ); ?>" name="form-id">
914 + <?php
915 + /*
916 + * Submission language. Captured client-side because the REST submit endpoint
917 + * loses WPML's URL-based language context. The value is baked into the markup
918 + * at render time, so accurate entry-language tagging requires the page cache to
919 + * be language-aware (the default for WPML's language-per-URL modes). At submit
920 + * time the server re-validates this value against the active language list and
921 + * falls back to its own current_language() when it can't be confirmed
922 + * (see Form_Submit::is_known_language()), so a stale/forged value is never
923 + * trusted blindly.
924 + */
925 + ?>
926 + <input type="hidden" value="<?php echo esc_attr( Multilingual_Manager::get_instance()->provider()->current_language() ); ?>" name="srfm-form-language">
579 927 <input type="hidden" value="" name="srfm-sender-email-field" id="srfm-sender-email">
580 928 <input type="hidden" value="<?php echo esc_attr( Helper::get_string_value( $is_page_break ) ); ?>" id="srfm-page-break">
581 929 <?php if ( $honeypot_spam ) { ?>
582 930 <input type="hidden" value="" name="srfm-honeypot-field">
@@ -734,9 +1082,10 @@
734 1082 <?php } ?>
735 1083
736 1084 <?php if ( 'v3-reCAPTCHA' === $recaptcha_version ) { ?>
737 1085 <?php
738 - wp_enqueue_script( // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- This is a third-party script, and specifying a version may lead to caching issues. Using null ensures the latest version is always loaded.
1086 + // phpcs:disable WordPress.WP.EnqueuedResourceParameters.MissingVersion, PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent -- Google reCAPTCHA must be loaded from Google's servers for token verification; the version is controlled by Google, and passing null avoids stale caching.
1087 + wp_enqueue_script(
739 1088 'srfm-google-recaptchaV3',
740 1089 'https://www.google.com/recaptcha/api.js?render=' . $google_captcha_site_key,
741 1090 [],
742 1091 null,
@@ -741,8 +1090,9 @@
741 1090 [],
742 1091 null,
743 1092 true
744 1093 );
1094 + // phpcs:enable WordPress.WP.EnqueuedResourceParameters.MissingVersion, PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent
745 1095 ?>
746 1096 <?php
747 1097 }
748 1098 }
@@ -757,18 +1107,19 @@
757 1107 */
758 1108 public static function get_cf_turnstile_script( $srfm_cf_appearance_mode, $srfm_cf_turnstile_site_key ) {
759 1109 if ( ! empty( $srfm_cf_turnstile_site_key ) ) {
760 1110 // Cloudflare Turnstile script.
761 - wp_enqueue_script( // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Third-party script; version not under our control.
1111 + // phpcs:disable WordPress.WP.EnqueuedResourceParameters.MissingVersion, PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent -- Cloudflare Turnstile must be loaded from Cloudflare's servers for token verification; the version is controlled by Cloudflare.
1112 + wp_enqueue_script(
762 1113 SRFM_SLUG . '-cf-turnstile',
763 1114 'https://challenges.cloudflare.com/turnstile/v0/api.js',
764 1115 [],
765 1116 null,
766 1117 [
767 - false,
768 - 'defer' => true,
1118 + 'strategy' => 'defer',
769 1119 ]
770 1120 );
1121 + // phpcs:enable WordPress.WP.EnqueuedResourceParameters.MissingVersion, PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent
771 1122 ?>
772 1123 <!-- The callback methods below are available on frontend.js. onTurnstileError displays and error in place of recaptcha dialog. -->
773 1124 <div id="srfm-cf-sitekey" class="cf-turnstile" data-callback="onSuccess" data-error-callback="onTurnstileError" data-theme="<?php echo esc_attr( $srfm_cf_appearance_mode ); ?>" data-sitekey="<?php echo esc_attr( $srfm_cf_turnstile_site_key ); ?>"></div>
774 1125 <?php
@@ -787,9 +1138,9 @@
787 1138 public static function common_error_message( $position = 'footer' ) {
788 1139 $icon = Helper::fetch_svg( 'info_circle', '', 'aria-hidden="true"' );
789 1140 $classes = "srfm-common-error-message srfm-error-message srfm-{$position}-error";
790 1141 ?>
791 - <p id="srfm-error-message" class="<?php echo esc_attr( $classes ); ?>" hidden><?php echo wp_kses( $icon, Helper::$allowed_tags_svg ); ?><span class="srfm-error-content"><?php echo esc_html__( 'There was an error trying to submit your form. Please try again.', 'sureforms' ); ?></span></p>
1142 + <p id="srfm-error-message" class="<?php echo esc_attr( $classes ); ?>" hidden><?php echo wp_kses( $icon, Helper::$allowed_tags_svg ); ?><span class="srfm-error-content"><?php echo esc_html( String_Translator::get_instance()->translate_validation_message( 'srfm_submit_error', __( 'There was an error trying to submit your form. Please try again.', 'sureforms' ) ) ); ?></span></p>
792 1143 <?php
793 1144 }
794 1145
795 1146 /**
@@ -868,8 +1219,9 @@
868 1219 $confirmation_data = is_array( $form_confirmation[0] ) && isset( $form_confirmation[0][0] ) ? $form_confirmation[0][0] : null;
869 1220
870 1221 if ( is_array( $form_confirmation ) && isset( $confirmation_data['message'] ) && is_string( $confirmation_data['message'] ) ) {
871 1222 $confirmation_message = $confirmation_data['message'];
1223 + $confirmation_message = String_Translator::get_instance()->translate_confirmation_message( (int) $form_id, 0, $confirmation_message );
872 1224 }
873 1225 if ( empty( $submission_data ) ) {
874 1226 return $confirmation_message;
875 1227 }
@@ -973,10 +1325,230 @@
973 1325 // Adding upload_format_type = 'raw' to retrieve urls as comma separated values.
974 1326 $form_data['upload_format_type'] = 'raw';
975 1327 // Skip auto-linking URLs in smart tag values — redirect query params need raw values, not HTML.
976 1328 $form_data['smart_tag_context'] = 'redirect';
977 - $redirect_url = html_entity_decode( Helper::get_string_value( $smart_tags->process_smart_tags( $redirect_url, $submission_data, $form_data ) ) );
1329 +
1330 + /*
1331 + * Resolve smart tags in the URL, normalize the multi-value delimiters
1332 + * left behind by the substitution, then decode any HTML entities.
1333 + *
1334 + * Multi-select dropdown values are packed as "Red | Blue" by the frontend
1335 + * (srfmUtility.prepareValue in assets/js/unminified/frontend.js), and
1336 + * checkbox multi-choice values are rendered as "Red<br>Blue" by
1337 + * Smart_Tags::parse_form_input. Neither delimiter is URL-friendly as-is:
1338 + * " | " leaks whitespace into the query string and "<br>" gets mangled
1339 + * by esc_url_raw below. Normalize both to a plain "|" so the final
1340 + * redirect URL carries a clean, URL-safe list that the receiver can
1341 + * split on "|".
1342 + *
1343 + * The str_replace runs before html_entity_decode so that any literal
1344 + * "<br>" character sequence inside an option label — which
1345 + * Smart_Tags::parse_form_input escapes to "&lt;br&gt;" before joining
1346 + * — survives intact. Only the actual delimiter (the unescaped "<br>"
1347 + * emitted by the implode) is converted to a pipe; html_entity_decode
1348 + * then restores the option's original text.
1349 + */
1350 + $resolved_redirect_url = Helper::get_string_value( $smart_tags->process_smart_tags( $redirect_url, $submission_data, $form_data ) );
1351 + $multi_value_delimiters = [ '<br>', ' | ' ];
1352 + $redirect_url = html_entity_decode( str_replace( $multi_value_delimiters, '|', $resolved_redirect_url ) );
978 1353 }
979 1354
980 1355 return esc_url_raw( apply_filters( 'srfm_after_submit_redirect_url', $redirect_url ) );
1356 + }
1357 +
1358 + /**
1359 + * Print the admin-only "Edit Form" shortcut on an embedded form.
1360 + *
1361 + * Renders a small pill link that opens the block editor for this form, on its
1362 + * own right-aligned row directly above the form.
1363 + *
1364 + * It sits in normal flow rather than being absolutely positioned over the
1365 + * form's top-right corner, which is what it used to do. An overlay can only
1366 + * avoid the fields when the container happens to have enough top padding —
1367 + * with the default theme styling it landed on top of the first row's last
1368 + * field (#3062). Flow layout cannot overlap anything by construction, at any
1369 + * width, with any theme. The cost is that the form shifts down by the pill's
1370 + * height, which happens only for users who can edit the form; the markup and
1371 + * its styles remain entirely absent from the DOM for everyone else, so no
1372 + * regular visitor sees a layout change.
1373 + *
1374 + * Admin-only by construction: the `sureforms_form` CPT registers with
1375 + * `map_meta_cap => false`, so `edit_post` collapses to a blanket
1376 + * `manage_options` check with no per-post component — an editor never sees the
1377 + * pill on any form. For every other viewer the markup and its styles are
1378 + * entirely absent from the DOM.
1379 + *
1380 + * The stylesheet is attached to a registered inline-only handle so `WP_Styles`
1381 + * dedupes it by handle (surviving a discarded `the_content` pass, e.g. an SEO
1382 + * plugin building `og:description` during `wp_head`) and it survives a strict
1383 + * `style-src` CSP. It is not cache-signalled here: the payload is only a
1384 + * `wp-admin/post.php?post=N` link an anonymous visitor cannot act on, and a
1385 + * `DONOTCACHEPAGE` define from a fragment renderer is both inert on the normal
1386 + * (headers-already-sent) path and an irreversible process-global side effect.
1387 + *
1388 + * @param int $form_id Form post ID.
1389 + *
1390 + * @return void
1391 + * @since 2.12.4
1392 + */
1393 + public static function render_edit_form_button( $form_id ) {
1394 + $form_id = absint( $form_id );
1395 +
1396 + // Only for real SureForms forms — the [sureforms] shortcode accepts any
1397 + // post ID, and a non-form target would map `edit_post` normally and leak
1398 + // the pill to an ordinary editor.
1399 + if ( 0 === $form_id || ! defined( 'SRFM_FORMS_POST_TYPE' ) || SRFM_FORMS_POST_TYPE !== get_post_type( $form_id ) ) {
1400 + return;
1401 + }
1402 +
1403 + // Capability gate first, before the suppression filter, so no work is done
1404 + // for the anonymous visitors who make up almost every page view.
1405 + if ( ! current_user_can( 'edit_post', $form_id ) ) {
1406 + return;
1407 + }
1408 +
1409 + // Contexts where the pill is redundant or wrong:
1410 + // - the single-form / Instant Form page, where the form IS the whole page
1411 + // and the admin bar already links to its editor. This is also what
1412 + // suppresses the block editor's preview — that preview is an iframe to
1413 + // the form's own permalink (an ordinary front-end request), NOT a REST
1414 + // render, so `is_singular` is the load-bearing guard there;
1415 + // - any admin / AJAX / REST / JSON request, or a feed (the markup would
1416 + // otherwise land inside `content:encoded` CDATA).
1417 + if (
1418 + is_singular( SRFM_FORMS_POST_TYPE )
1419 + || is_admin()
1420 + || wp_doing_ajax()
1421 + || wp_is_json_request()
1422 + || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
1423 + || is_feed()
1424 + ) {
1425 + return;
1426 + }
1427 +
1428 + // Page-builder editor canvases render the form directly (not over REST),
1429 + // where their own element-edit handles would collide with the pill.
1430 + // `$instance` is checked as well as the class name: Elementor declares
1431 + // `public static $instance = null` and only populates it on boot, so the
1432 + // class can exist while the singleton is still null. Dereferencing it then
1433 + // is a fatal Error, not a warning, and guarding only on class_exists() left
1434 + // that reachable — test-generate-form-markup.php hit it. The bundled stub
1435 + // types $instance as non-nullable, which is why PHPStan reads the isset()
1436 + // as redundant and has to be told otherwise.
1437 + //
1438 + // ->editor is checked for the same reason one level down: Elementor assigns it
1439 + // in init_components() on `init`, while the singleton itself is created on
1440 + // `plugins_loaded`. Between those two hooks $instance is set and ->editor is
1441 + // still null, so checking only the singleton reproduces the original fatal a
1442 + // property later.
1443 + // @phpstan-ignore-next-line -- Stub disagrees with runtime; see above.
1444 + if ( class_exists( '\Elementor\Plugin' ) && isset( \Elementor\Plugin::$instance->editor ) && \Elementor\Plugin::$instance->editor->is_edit_mode() ) {
1445 + return;
1446 + }
1447 + if ( function_exists( 'bricks_is_builder' ) && bricks_is_builder() ) {
1448 + return;
1449 + }
1450 +
1451 + /**
1452 + * Allow integrations to suppress the admin "Edit Form" shortcut entirely.
1453 + *
1454 + * @param bool $show Whether to render the shortcut. Default true.
1455 + * @param int $form_id Form post ID.
1456 + *
1457 + * @since 2.12.4
1458 + */
1459 + if ( ! apply_filters( 'srfm_show_edit_form_button', true, $form_id ) ) {
1460 + return;
1461 + }
1462 +
1463 + $edit_link = get_edit_post_link( $form_id, 'raw' );
1464 +
1465 + if ( empty( $edit_link ) ) {
1466 + return;
1467 + }
1468 +
1469 + // Attribution marker read back by Admin::maybe_track_edit_form_button_click()
1470 + // when the editor loads. Added before the filter below so an integration that
1471 + // replaces the link wholesale drops the marker with it, rather than having our
1472 + // query arg appended to a third-party URL.
1473 + // 'url' context, not the default 'display': the latter returns &amp;-escaped
1474 + // separators, and feeding those to add_query_arg() only round-trips because
1475 + // build_query() happens to re-emit the mangled `amp;action` key verbatim. The
1476 + // raw form has no such dependency, and esc_url() below still escapes on output.
1477 + $edit_link = add_query_arg( self::EDIT_FORM_BUTTON_SOURCE_ARG, 'embed', $edit_link );
1478 +
1479 + /**
1480 + * Filter the target of the admin "Edit Form" shortcut.
1481 + *
1482 + * @param string $edit_link Editor URL for the form.
1483 + * @param int $form_id Form post ID.
1484 + *
1485 + * @since 2.12.4
1486 + */
1487 + $edit_link = Helper::get_string_value( apply_filters( 'srfm_edit_form_button_link', $edit_link, $form_id ) );
1488 +
1489 + if ( '' === $edit_link ) {
1490 + return;
1491 + }
1492 +
1493 + // Registered inline-only handle: WP_Styles dedupes by handle across every
1494 + // embedded form and prints via print_late_styles() in the footer even when
1495 + // enqueued this late (during the_content).
1496 + $style_handle = 'srfm-edit-form-btn';
1497 + if ( ! wp_style_is( $style_handle, 'registered' ) ) {
1498 + wp_register_style( $style_handle, false, [], SRFM_VER );
1499 + wp_add_inline_style( $style_handle, self::get_edit_form_button_css() );
1500 + }
1501 + wp_enqueue_style( $style_handle );
1502 + ?>
1503 + <div class="srfm-edit-form-btn-wrap">
1504 + <a class="srfm-edit-form-btn" href="<?php echo esc_url( $edit_link ); ?>" target="_blank" rel="noopener noreferrer">
1505 + <svg width="20" height="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>
1506 + <span><?php esc_html_e( 'Edit Form', 'sureforms' ); ?></span>
1507 + <span class="screen-reader-text"><?php esc_html_e( '(opens in a new tab)', 'sureforms' ); ?></span>
1508 + </a>
1509 + </div>
1510 + <?php
1511 + }
1512 +
1513 + /**
1514 + * Stylesheet for the admin "Edit Form" pill (#3029).
1515 + *
1516 + * The wrapper is a flow-level flex row rather than an absolute overlay, so the
1517 + * pill reserves its own space and cannot cover a field (#3062). `justify-content`
1518 + * uses the logical `flex-end`, which follows the writing direction and is
1519 + * therefore RTL-correct without a separate rule.
1520 + *
1521 + * No `position: relative` on the container any more: that rule existed solely to
1522 + * be the positioning context for the old overlay.
1523 + *
1524 + * @return string
1525 + * @since 2.12.4
1526 + */
1527 + private static function get_edit_form_button_css() {
1528 + return '
1529 + .srfm-edit-form-btn-wrap {
1530 + display: flex;
1531 + justify-content: flex-end;
1532 + margin-block-end: 8px;
1533 + }
1534 + .srfm-edit-form-btn {
1535 + display: inline-flex;
1536 + align-items: center;
1537 + gap: 6px;
1538 + padding: 6px 12px;
1539 + font-size: 13px;
1540 + font-weight: 500;
1541 + line-height: 1;
1542 + color: #1e293b;
1543 + background: #ffffff;
1544 + border: 1px solid #e2e8f0;
1545 + border-radius: 9999px;
1546 + box-shadow: 0 2px 6px rgba( 0, 0, 0, 0.12 );
1547 + text-decoration: none;
1548 + }
1549 + .srfm-edit-form-btn:hover { border-color: #cbd5e1; color: #0f172a; }
1550 + .srfm-edit-form-btn:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
1551 + .srfm-edit-form-btn svg { width: 14px; height: 14px; }
1552 + ';
981 1553 }
982 1554 }