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
sureforms / inc / generate-form-markup.php

generate-form-markup.php in SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz 2.12.8, at inc/generate-form-markup.php

1,555 lines 72.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Sureforms Generate Form Class file.
4 *
5 * @package sureforms.
6 * @since 0.0.1
7 */
8
9 namespace SRFM\Inc;
10
11 use SRFM\Inc\Compatibility\Multilingual\Multilingual_Manager;
12 use SRFM\Inc\Compatibility\Multilingual\String_Translator;
13 use SRFM\Inc\Traits\Get_Instance;
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit; // Exit if accessed directly.
17 }
18
19 /**
20 * Load Defaults Class.
21 *
22 * @since 0.0.1
23 */
24 class Generate_Form_Markup {
25 use Get_Instance;
26
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 /**
36 * Current block attributes for the form being rendered.
37 * Used by child blocks (like inline button) to access parent form's embed styling.
38 *
39 * @var array<string,mixed>
40 * @since 2.7.0
41 */
42 private static $current_block_attrs = [];
43
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 /**
59 * Constructor
60 *
61 * @since 0.0.1
62 */
63 public function __construct() {
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 );
71 }
72
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 /**
118 * Get the current block attributes.
119 *
120 * @return array<string,mixed>
121 * @since 2.7.0
122 */
123 public static function get_current_block_attrs() {
124 return self::$current_block_attrs;
125 }
126
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 /**
253 * Add custom API Route to generate form markup.
254 *
255 * @return void
256 * @since 0.0.1
257 */
258 public function register_custom_endpoint() {
259 register_rest_route(
260 'sureforms/v1',
261 '/generate-form-markup',
262 [
263 'methods' => 'GET',
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 ],
276 ]
277 );
278 }
279
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 /**
340 * Handle Form status
341 *
342 * @param int|string $id Contains form ID.
343 * @param bool $show_title_current_page Boolean to srfm-show/srfm-hide form title.
344 * @param string $sf_classname additional class_name.
345 * @param string $post_type Contains post type.
346 * @param bool $do_blocks Boolean to enable/disable parsing dynamic blocks.
347 * @param array<mixed> $block_attrs Block attributes for per-embed styling.
348 *
349 * @return string|false
350 * @since 0.0.1
351 */
352 public static function get_form_markup( $id, $show_title_current_page = true, $sf_classname = '', $post_type = 'post', $do_blocks = false, $block_attrs = [] ) {
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 );
358
359 // Check for any form restrictions.
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
372 if ( Form_Restriction::is_form_restricted( $form_id ) ) {
373 return Form_Restriction::display_form_restriction_message( $form_id );
374 }
375
376 // Store block_attrs for child blocks (like inline button) to access.
377 self::$current_block_attrs = $block_attrs;
378
379 do_action( 'srfm_localize_conditional_logic_data', $id );
380 $post = get_post( Helper::get_integer_value( $id ) );
381
382 $content = '';
383 $form_blocks = [];
384
385 $active_plugins = Helper::get_array_value( get_option( 'active_plugins', [] ) );
386 $is_learndash_active = in_array( 'sfwd-lms/sfwd_lms.php', $active_plugins, true );
387
388 if ( $is_learndash_active ) {
389 $do_blocks = true;
390 }
391
392 if ( $post && ! empty( $post->post_content ) ) {
393 // Filter to get the post content for the form.
394 $post_content = apply_filters( 'srfm_get_form_post_content', $post->post_content, $id );
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
402 if ( ! empty( $do_blocks ) ) {
403 $content = do_blocks( $post_content );
404 } else {
405 $content = apply_filters( 'the_content', $post_content ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- wordpress hook
406 }
407 }
408
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 );
413 $current_post_type = get_post_type();
414
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 );
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
425 ob_start();
426 if ( '' !== $id && 0 !== $block_count ) {
427
428 // Create unique container ID using blockId if available (for multiple embeds of same form).
429 // Base class (without blockId) is needed for JS compatibility - frontend.js and phone.js use form-id attribute to construct selectors.
430 $base_container_class = 'srfm-form-container-' . Helper::get_string_value( $id );
431 $block_id_suffix = ! empty( $block_attrs['blockId'] ) ? '-' . Helper::get_string_value( $block_attrs['blockId'] ) : '';
432 $container_id = $base_container_class . $block_id_suffix;
433 $form_styling = get_post_meta( $id, '_srfm_forms_styling', true );
434 $form_styling = ! empty( $form_styling ) && is_array( $form_styling ) ? $form_styling : [];
435
436 // Apply per-embed styling customization when formTheme is not 'inherit'.
437 if ( Form_Styling::has_custom_styling( $block_attrs ) ) {
438 $form_styling = Form_Styling::map_block_attrs_to_styling( $form_styling, $block_attrs );
439 }
440
441 // Background Settings.
442 $bg_type = $form_styling['bg_type'] ?? 'color';
443 $bg_color = $form_styling['bg_color'] ?? '';
444 $bg_image = $form_styling['bg_image'] ?? '';
445 $bg_image_position = $form_styling['bg_image_position'] ?? [];
446 $bg_image_attachment = $form_styling['bg_image_attachment'] ?? 'scroll';
447 $bg_image_repeat = $form_styling['bg_image_repeat'] ?? 'no-repeat';
448 $bg_image_size = $form_styling['bg_image_size'] ?? 'cover';
449 $bg_image_size_custom = $form_styling['bg_image_size_custom'] ?? 100;
450 $bg_image_size_custom_unit = $form_styling['bg_image_size_custom_unit'] ?? '%';
451 $bg_gradient = $form_styling['bg_gradient'] ?? 'linear-gradient(90deg, #FFC9B2 0%, #C7CBFF 100%)';
452 $gradient_type = $form_styling['gradient_type'] ?? 'basic'; // Basic or advanced.
453 $is_advanced_gradient = 'advanced' === $gradient_type ? true : false;
454 $bg_gradient_type = $is_advanced_gradient && isset( $form_styling['bg_gradient_type'] ) ? $form_styling['bg_gradient_type'] : 'linear'; // linear or radial gradient.
455 $bg_gradient_color_1 = $is_advanced_gradient && isset( $form_styling['bg_gradient_color_1'] ) ? $form_styling['bg_gradient_color_1'] : '';
456 $bg_gradient_color_2 = $is_advanced_gradient && isset( $form_styling['bg_gradient_color_2'] ) ? $form_styling['bg_gradient_color_2'] : '';
457 $bg_gradient_location_1 = $is_advanced_gradient && isset( $form_styling['bg_gradient_location_1'] ) ? $form_styling['bg_gradient_location_1'] : '';
458 $bg_gradient_location_2 = $is_advanced_gradient && isset( $form_styling['bg_gradient_location_2'] ) ? $form_styling['bg_gradient_location_2'] : '';
459 $bg_gradient_angle = $is_advanced_gradient && isset( $form_styling['bg_gradient_angle'] ) ? $form_styling['bg_gradient_angle'] : '';
460 // Overlay Settings.
461 $overlay_type = $form_styling['bg_gradient_overlay_type'] ?? '';
462 $overlay_size = $form_styling['bg_overlay_size'] ?? 'cover';
463 $overlay_opacity = $form_styling['bg_overlay_opacity'] ?? 1;
464 $overlay_color = $form_styling['bg_image_overlay_color'] ?? '';
465 $overlay_image = $form_styling['bg_overlay_image'] ?? '';
466 $overlay_position = $form_styling['bg_overlay_position'] ?? [];
467 $overlay_attachment = $form_styling['bg_overlay_attachment'] ?? 'scroll';
468 $overlay_repeat = $form_styling['bg_overlay_repeat'] ?? 'no-repeat';
469 $overlay_blend_mode = $form_styling['bg_overlay_blend_mode'] ?? 'normal';
470 // Gradient Overlay.
471 $bg_overlay_gradient = $form_styling['bg_overlay_gradient'] ?? 'linear-gradient(90deg, #FFC9B2 0%, #C7CBFF 100%)';
472 $overlay_gradient_type = $form_styling['overlay_gradient_type'] ?? 'basic'; // Basic or advanced.
473 $is_overlay_advanced_gradient = 'advanced' === $overlay_gradient_type ? true : false;
474 $bg_overlay_gradient_type = $is_overlay_advanced_gradient && isset( $form_styling['bg_overlay_gradient_type'] ) ? $form_styling['bg_overlay_gradient_type'] : 'linear';
475 $bg_overlay_gradient_color_1 = $is_overlay_advanced_gradient && isset( $form_styling['bg_overlay_gradient_color_1'] ) ? $form_styling['bg_overlay_gradient_color_1'] : '';
476 $bg_overlay_gradient_color_2 = $is_overlay_advanced_gradient && isset( $form_styling['bg_overlay_gradient_color_2'] ) ? $form_styling['bg_overlay_gradient_color_2'] : '';
477 $bg_overlay_gradient_location_1 = $is_overlay_advanced_gradient && isset( $form_styling['bg_overlay_gradient_location_1'] ) ? $form_styling['bg_overlay_gradient_location_1'] : '';
478 $bg_overlay_gradient_location_2 = $is_overlay_advanced_gradient && isset( $form_styling['bg_overlay_gradient_location_2'] ) ? $form_styling['bg_overlay_gradient_location_2'] : '';
479 $bg_overlay_gradient_angle = $is_overlay_advanced_gradient && isset( $form_styling['bg_overlay_gradient_angle'] ) ? $form_styling['bg_overlay_gradient_angle'] : '';
480 // Embed Form Settings.
481 $form = [
482 // Padding.
483 'padding_top' => isset( $form_styling['form_padding_top'] ) ? floatval( $form_styling['form_padding_top'] ) : 0,
484 'padding_right' => isset( $form_styling['form_padding_right'] ) ? floatval( $form_styling['form_padding_right'] ) : 0,
485 'padding_bottom' => isset( $form_styling['form_padding_bottom'] ) ? floatval( $form_styling['form_padding_bottom'] ) : 0,
486 'padding_left' => isset( $form_styling['form_padding_left'] ) ? floatval( $form_styling['form_padding_left'] ) : 0,
487 'padding_unit' => isset( $form_styling['form_padding_unit'] ) ? Helper::get_string_value( $form_styling['form_padding_unit'] ) : 'px',
488 // Border Radius.
489 'border_radius_top' => isset( $form_styling['form_border_radius_top'] ) ? floatval( $form_styling['form_border_radius_top'] ) : 0,
490 'border_radius_right' => isset( $form_styling['form_border_radius_right'] ) ? floatval( $form_styling['form_border_radius_right'] ) : 0,
491 'border_radius_bottom' => isset( $form_styling['form_border_radius_bottom'] ) ? floatval( $form_styling['form_border_radius_bottom'] ) : 0,
492 'border_radius_left' => isset( $form_styling['form_border_radius_left'] ) ? floatval( $form_styling['form_border_radius_left'] ) : 0,
493 'border_radius_unit' => isset( $form_styling['form_border_radius_unit'] ) ? Helper::get_string_value( $form_styling['form_border_radius_unit'] ) : 'px',
494 ];
495 // Instant Form Settings.
496 $instant_form = [
497 // Padding.
498 'padding_top' => isset( $form_styling['instant_form_padding_top'] ) ? floatval( $form_styling['instant_form_padding_top'] ) : 32,
499 'padding_right' => isset( $form_styling['instant_form_padding_right'] ) ? floatval( $form_styling['instant_form_padding_right'] ) : 32,
500 'padding_bottom' => isset( $form_styling['instant_form_padding_bottom'] ) ? floatval( $form_styling['instant_form_padding_bottom'] ) : 32,
501 'padding_left' => isset( $form_styling['instant_form_padding_left'] ) ? floatval( $form_styling['instant_form_padding_left'] ) : 32,
502 'padding_unit' => isset( $form_styling['instant_form_padding_unit'] ) ? Helper::get_string_value( $form_styling['instant_form_padding_unit'] ) : 'px',
503 // Border Radius.
504 'border_radius_top' => isset( $form_styling['instant_form_border_radius_top'] ) ? floatval( $form_styling['instant_form_border_radius_top'] ) : 12,
505 'border_radius_right' => isset( $form_styling['instant_form_border_radius_right'] ) ? floatval( $form_styling['instant_form_border_radius_right'] ) : 12,
506 'border_radius_bottom' => isset( $form_styling['instant_form_border_radius_bottom'] ) ? floatval( $form_styling['instant_form_border_radius_bottom'] ) : 12,
507 'border_radius_left' => isset( $form_styling['instant_form_border_radius_left'] ) ? floatval( $form_styling['instant_form_border_radius_left'] ) : 12,
508 'border_radius_unit' => isset( $form_styling['instant_form_border_radius_unit'] ) ? Helper::get_string_value( $form_styling['instant_form_border_radius_unit'] ) : 'px',
509 ];
510
511 if ( 'custom' === $overlay_size ) {
512 $bg_overlay_custom_size = $form_styling['bg_overlay_custom_size'] ?? 100;
513 $bg_overlay_custom_size_unit = $form_styling['bg_overlay_custom_size_unit'] ?? '%';
514 $overlay_size = $bg_overlay_custom_size . $bg_overlay_custom_size_unit;
515 }
516
517 $background_classes = apply_filters( 'srfm_add_background_classes', Helper::get_background_classes( $bg_type, $overlay_type, $bg_image ), $id, $block_attrs );
518
519 $neve_theme_margin_class_name = 'srfm-neve-theme-add-margin-bottom';
520 $theme_name = wp_get_theme()->get( 'Name' );
521
522 $form_classes = [
523 'srfm-form-container',
524 $base_container_class, // Base class for JS compatibility (frontend.js, phone.js).
525 ! empty( $block_id_suffix ) ? $container_id : '', // Unique class for CSS scoping when blockId exists.
526 $sf_classname,
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.
529 $background_classes,
530 ];
531
532 $custom_added_classes = Helper::get_meta_value( $id, '_srfm_additional_classes' );
533 if ( ! empty( $custom_added_classes ) && is_string( $custom_added_classes ) ) {
534 $custom_added_classes = explode( ' ', $custom_added_classes );
535 foreach ( $custom_added_classes as $class ) {
536 if ( Helper::is_valid_css_class_name( $class ) ) {
537 $form_classes[] = $class;
538 }
539 }
540 }
541
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' ] );
564 $page_break_progress_type = ! empty( $page_break_settings ) ? $page_break_settings['progress_indicator_type'] : 'none';
565 $form_confirmation = get_post_meta( $id, '_srfm_form_confirmation' );
566 $confirmation_type = '';
567 $submission_action = '';
568 $success_url = '';
569 if ( is_array( $form_confirmation ) && isset( $form_confirmation[0][0] ) ) {
570 $confirmation_data = $form_confirmation[0][0];
571 $page_url = $confirmation_data['page_url'] ?? '';
572 $custom_url = $confirmation_data['custom_url'] ?? '';
573 $confirmation_type = $confirmation_data['confirmation_type'] ?? '';
574 $submission_action = $confirmation_data['submission_action'] ?? '';
575 $success_url = '';
576 if ( 'different page' === $confirmation_type ) {
577 $success_url = $page_url;
578 } elseif ( 'custom url' === $confirmation_type ) {
579 $success_url = $custom_url;
580 }
581 }
582
583 // Submit button.
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 ) );
586 $submit_button_alignment = ! empty( $form_styling['submit_button_alignment'] ) ? $form_styling['submit_button_alignment'] : 'left';
587
588 if ( is_rtl() && ( 'left' === $submit_button_alignment || 'right' === $submit_button_alignment ) ) {
589 $submit_button_alignment = 'right' === $submit_button_alignment ? 'left' : 'right';
590 }
591
592 $btn_from_theme = Helper::get_meta_value( $id, '_srfm_inherit_theme_button' );
593 $is_inline_button = apply_filters( 'srfm_is_inline_button', Helper::get_meta_value( $id, '_srfm_is_inline_button' ) );
594 $security_type = Helper::get_meta_value( $id, '_srfm_captcha_security_type' );
595 $form_custom_css_meta = Helper::get_meta_value( $id, '_srfm_form_custom_css' );
596 $custom_css = ! empty( $form_custom_css_meta ) && is_string( $form_custom_css_meta ) ? $form_custom_css_meta : '';
597
598 $full = 'justify' === $submit_button_alignment ? true : false;
599 $recaptcha_version = 'g-recaptcha' === $security_type ? Helper::get_meta_value( $id, '_srfm_form_recaptcha' ) : '';
600 $srfm_cf_appearance_mode = '';
601 $srfm_cf_turnstile_site_key = '';
602 $srfm_hcaptcha_site_key = '';
603
604 $google_captcha_site_key = '';
605
606 if ( 'none' !== $security_type ) {
607 $global_setting_options = get_option( 'srfm_security_settings_options' );
608 } else {
609 $global_setting_options = [];
610 }
611
612 if ( is_array( $global_setting_options ) && 'cf-turnstile' === $security_type ) {
613 $srfm_cf_turnstile_site_key = $global_setting_options['srfm_cf_turnstile_site_key'] ?? '';
614 $srfm_cf_appearance_mode = $global_setting_options['srfm_cf_appearance_mode'] ?? 'auto';
615 }
616
617 if ( is_array( $global_setting_options ) && 'hcaptcha' === $security_type ) {
618 $srfm_hcaptcha_site_key = $global_setting_options['srfm_hcaptcha_site_key'] ?? '';
619 }
620
621 if ( is_array( $global_setting_options ) && 'g-recaptcha' === $security_type ) {
622 switch ( $recaptcha_version ) {
623 case 'v2-checkbox':
624 $google_captcha_site_key = $global_setting_options['srfm_v2_checkbox_site_key'] ?? '';
625 break;
626 case 'v2-invisible':
627 $google_captcha_site_key = $global_setting_options['srfm_v2_invisible_site_key'] ?? '';
628 break;
629 case 'v3-reCAPTCHA':
630 $google_captcha_site_key = $global_setting_options['srfm_v3_site_key'] ?? '';
631 break;
632 default:
633 break;
634 }
635 }
636
637 // Ensure $google_captcha_site_key is not empty, and if not, trim any leading or trailing whitespace.
638 $google_captcha_site_key = is_string( $google_captcha_site_key ) && ! empty( $google_captcha_site_key ) ? trim( $google_captcha_site_key ) : '';
639
640 $primary_color = $form_styling['primary_color'] ?? '';
641 $help_color_var = $form_styling['text_color'] ?? '';
642 $label_text_color = $form_styling['text_color_on_primary'] ?? '';
643 $field_spacing = $form_styling['field_spacing'] ?? 'small';
644
645 // New colors.
646
647 $primary_color_var = $primary_color ? $primary_color : '#046bd2';
648 $label_text_color_var = $label_text_color ? $label_text_color : '#111827';
649
650 $selected_size = Helper::get_css_vars( $field_spacing );
651
652 $should_show_submit_button = apply_filters(
653 'srfm_show_submit_button',
654 0 !== $block_count && ! $is_inline_button || $is_page_break,
655 $id
656 );
657
658 if ( ! $should_show_submit_button ) {
659 $form_classes[] = 'srfm-submit-button-hidden';
660 }
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 : '';
666 ?>
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. ?>
669 <style>
670 /* Need to check and remove the input variables related to the Style Tab. */
671 <?php echo esc_html( ".{$container_id}" ); ?> {
672 <?php if ( ! $disable_default_styles ) { ?>
673 /* New test variables */
674 --srfm-color-scheme-primary: <?php echo esc_html( $primary_color_var ); ?>;
675 --srfm-color-scheme-text-on-primary: <?php echo esc_html( $label_text_color_var ); ?>;
676 --srfm-color-scheme-text: <?php echo esc_html( $help_color_var ); ?>;
677 --srfm-quill-editor-color: <?php echo esc_html( $primary_color_var ); ?>;
678
679 --srfm-color-input-label: <?php echo esc_html( $help_color_var ); ?>;
680 --srfm-color-input-description: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.65 );
681 --srfm-color-input-placeholder: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.5 );
682 --srfm-color-input-text: <?php echo esc_html( $help_color_var ); ?>;
683 --srfm-color-input-prefix: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.65 );
684 --srfm-color-input-background: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.02 );
685 --srfm-color-input-background-hover: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.05 );
686 --srfm-color-input-background-disabled: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.07 );
687 --srfm-color-input-border: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.25 );
688 --srfm-color-input-border-disabled: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.15 );
689 --srfm-color-multi-choice-svg: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.7 );
690 --srfm-color-input-border-hover: hsl( from <?php echo esc_html( $primary_color_var ); ?> h s l / 0.65 );
691 --srfm-color-input-border-focus-glow: hsl( from <?php echo esc_html( $primary_color_var ); ?> h s l / 0.15 );
692 --srfm-color-input-selected: hsl( from <?php echo esc_html( $primary_color_var ); ?> h s l / 0.1 );
693 --srfm-btn-color-hover: hsl( from <?php echo esc_html( $primary_color_var ); ?> h s l / 0.9 );
694 --srfm-btn-color-disabled: hsl( from <?php echo esc_html( $primary_color_var ); ?> h s l / 0.25 );
695
696 /* Dropdown Variables */
697 --srfm-dropdown-input-background-hover: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.05 );
698 --srfm-dropdown-option-background-hover: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.10 );
699 --srfm-dropdown-option-background-selected: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.05 );
700 --srfm-dropdown-option-selected-icon: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.65 );
701 --srfm-dropdown-option-text-color: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.80 );
702 --srfm-dropdown-option-selected-text: <?php echo esc_html( $help_color_var ); ?>;
703 --srfm-dropdown-badge-background: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.05 );
704 --srfm-dropdown-badge-background-hover: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.10 );
705 --srfm-dropdown-menu-border-color: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.10 );
706 --srfm-dropdown-placeholder-color: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.50 );
707 --srfm-dropdown-icon-color: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.65 );
708 --srfm-dropdown-icon-disabled: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.25 );
709
710 /* Background Control Variables */
711 <?php
712 // Form Styles.
713 $styling_vars = [
714 // Instant Form Padding.
715 '--srfm-instant-form-padding-top' => sanitize_text_field( "{$instant_form['padding_top']}{$instant_form['padding_unit']}" ),
716 '--srfm-instant-form-padding-right' => sanitize_text_field( "{$instant_form['padding_right']}{$instant_form['padding_unit']}" ),
717 '--srfm-instant-form-padding-bottom' => sanitize_text_field( "{$instant_form['padding_bottom']}{$instant_form['padding_unit']}" ),
718 '--srfm-instant-form-padding-left' => sanitize_text_field( "{$instant_form['padding_left']}{$instant_form['padding_unit']}" ),
719 // Instant Form Border Radius.
720 '--srfm-instant-form-border-radius-top' => sanitize_text_field( "{$instant_form['border_radius_top']}{$instant_form['border_radius_unit']}" ),
721 '--srfm-instant-form-border-radius-right' => sanitize_text_field( "{$instant_form['border_radius_right']}{$instant_form['border_radius_unit']}" ),
722 '--srfm-instant-form-border-radius-bottom' => sanitize_text_field( "{$instant_form['border_radius_bottom']}{$instant_form['border_radius_unit']}" ),
723 '--srfm-instant-form-border-radius-left' => sanitize_text_field( "{$instant_form['border_radius_left']}{$instant_form['border_radius_unit']}" ),
724 // Embed Form Padding.
725 '--srfm-form-padding-top' => sanitize_text_field( "{$form['padding_top']}{$form['padding_unit']}" ),
726 '--srfm-form-padding-right' => sanitize_text_field( "{$form['padding_right']}{$form['padding_unit']}" ),
727 '--srfm-form-padding-bottom' => sanitize_text_field( "{$form['padding_bottom']}{$form['padding_unit']}" ),
728 '--srfm-form-padding-left' => sanitize_text_field( "{$form['padding_left']}{$form['padding_unit']}" ),
729 // Embed Form Border Radius.
730 '--srfm-form-border-radius-top' => sanitize_text_field( "{$form['border_radius_top']}{$form['border_radius_unit']}" ),
731 '--srfm-form-border-radius-right' => sanitize_text_field( "{$form['border_radius_right']}{$form['border_radius_unit']}" ),
732 '--srfm-form-border-radius-bottom' => sanitize_text_field( "{$form['border_radius_bottom']}{$form['border_radius_unit']}" ),
733 '--srfm-form-border-radius-left' => sanitize_text_field( "{$form['border_radius_left']}{$form['border_radius_unit']}" ),
734 ];
735 // Background Styles.
736 if ( 'image' === $bg_type && ! empty( $bg_image ) ) {
737 $bg_size_merged = 'custom' === $bg_image_size ? "{$bg_image_size_custom}{$bg_image_size_custom_unit}" : $bg_image_size;
738 $styling_vars += [
739 '--srfm-bg-image' => 'url(' . esc_url_raw( $bg_image ) . ')',
740 '--srfm-bg-position' => sanitize_text_field(
741 ( ( ! empty( $bg_image_position['x'] ) ? $bg_image_position['x'] : 0.5 ) * 100 ) . '% ' .
742 ( ( ! empty( $bg_image_position['y'] ) ? $bg_image_position['y'] : 0.5 ) * 100 ) . '% '
743 ),
744 '--srfm-bg-attachment' => sanitize_text_field( $bg_image_attachment ),
745 '--srfm-bg-repeat' => sanitize_text_field( $bg_image_repeat ),
746 '--srfm-bg-size' => sanitize_text_field( $bg_size_merged ),
747 ];
748 } elseif ( 'color' === $bg_type && ! empty( $bg_color ) ) {
749 $styling_vars['--srfm-bg-color'] = sanitize_text_field( $bg_color );
750 } elseif ( 'gradient' === $bg_type && ! empty( $bg_gradient ) ) {
751 if ( $is_advanced_gradient ) {
752 $bg_gradient = Helper::get_gradient_css( $bg_gradient_type, $bg_gradient_color_1, $bg_gradient_color_2, $bg_gradient_location_1, $bg_gradient_location_2, $bg_gradient_angle );
753 }
754 $styling_vars['--srfm-bg-gradient'] = sanitize_text_field( $bg_gradient );
755 }
756 // Overlay Variables.
757 if ( 'image' === $bg_type && 'image' === $overlay_type && ! empty( $overlay_image ) ) {
758 $styling_vars += [
759 '--srfm-bg-overlay-image' => 'url(' . esc_url_raw( $overlay_image ) . ')',
760 '--srfm-bg-overlay-position' => sanitize_text_field(
761 ( ( ! empty( $overlay_position['x'] ) ? $overlay_position['x'] : 0.5 ) * 100 ) . '% ' .
762 ( ( ! empty( $overlay_position['y'] ) ? $overlay_position['y'] : 0.5 ) * 100 ) . '%'
763 ),
764 '--srfm-bg-overlay-attachment' => sanitize_text_field( $overlay_attachment ),
765 '--srfm-bg-overlay-repeat' => sanitize_text_field( $overlay_repeat ),
766 '--srfm-bg-overlay-size' => sanitize_text_field( $overlay_size ),
767 '--srfm-bg-overlay-blend-mode' => sanitize_text_field( $overlay_blend_mode ),
768 ];
769 } elseif ( 'image' === $bg_type && 'color' === $overlay_type && ! empty( $overlay_color ) ) {
770 $styling_vars += [
771 '--srfm-bg-overlay-color' => sanitize_text_field( $overlay_color ),
772 ];
773 } elseif ( 'image' === $bg_type && 'gradient' === $overlay_type && ! empty( $bg_overlay_gradient ) ) {
774 if ( $is_overlay_advanced_gradient ) {
775 $bg_overlay_gradient = Helper::get_gradient_css( $bg_overlay_gradient_type, $bg_overlay_gradient_color_1, $bg_overlay_gradient_color_2, $bg_overlay_gradient_location_1, $bg_overlay_gradient_location_2, $bg_overlay_gradient_angle );
776 }
777 $styling_vars += [
778 '--srfm-bg-overlay-gradient' => sanitize_text_field( $bg_overlay_gradient ),
779 ];
780 }
781 $styling_vars['--srfm-bg-overlay-opacity'] = floatval( $overlay_opacity );
782 // Output the CSS variables.
783 foreach ( $styling_vars as $key => $value ) {
784 echo esc_html( Helper::get_string_value( $key ) ) . ': ' . esc_html( Helper::get_string_value( $value ) ) . ';';
785 }
786 ?>
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 );
804 ?>
805 }
806 </style>
807 <?php } // End if the style block has content. ?>
808 <?php
809 if ( 'sureforms_form' !== $current_post_type && true === $show_title_current_page ) {
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 );
812 ?>
813 <h2 class="srfm-form-title"><?php echo esc_html( $title ); ?></h2>
814 <?php
815 }
816
817 // Password protected form check.
818 if ( $post && post_password_required( $post ) ) {
819 // Define allowed HTML tags for password form output.
820 $allowed_password_form_tags = [
821 'form' => [
822 'action' => true,
823 'method' => true,
824 'class' => true,
825 'id' => true,
826 ],
827 'label' => [
828 'for' => true,
829 'class' => true,
830 ],
831 'input' => [
832 'type' => true,
833 'name' => true,
834 'id' => true,
835 'class' => true,
836 'value' => true,
837 'size' => true,
838 'placeholder' => true,
839 'required' => true,
840 ],
841 'p' => [
842 'class' => true,
843 'style' => true,
844 ],
845 'button' => [
846 'type' => true,
847 'name' => true,
848 'class' => true,
849 'id' => true,
850 'style' => true,
851 ],
852 'div' => [
853 'class' => true,
854 'id' => true,
855 'style' => true,
856 ],
857 'span' => [
858 'class' => true,
859 'aria-hidden' => true,
860 ],
861 'svg' => [
862 'xmlns' => true,
863 'width' => true,
864 'height' => true,
865 'viewBox' => true,
866 'fill' => true,
867 ],
868 'path' => [
869 'd' => true,
870 'stroke' => true,
871 'stroke-opacity' => true,
872 'stroke-width' => true,
873 'stroke-linecap' => true,
874 'stroke-linejoin' => true,
875 ],
876 ];
877 echo wp_kses( get_the_password_form( $post ), $allowed_password_form_tags );
878 ?>
879 </div>
880 <?php
881 self::$current_block_attrs = [];
882 return ob_get_clean();
883 }
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 );
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
899 ?>
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 ' : '' ); ?>"
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"' : ''; ?>
902 >
903 <?php
904 // Submission security is handled via the HMAC token in data-submit-token.
905 $global_setting_options = get_option( 'srfm_security_settings_options' );
906 $honeypot_spam = is_array( $global_setting_options ) && isset( $global_setting_options['srfm_honeypot'] ) ? $global_setting_options['srfm_honeypot'] : '';
907
908 if ( $is_page_break && 'none' !== $page_break_progress_type ) {
909 do_action( 'srfm_page_break_header', $id );
910 }
911 ?>
912
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">
927 <input type="hidden" value="" name="srfm-sender-email-field" id="srfm-sender-email">
928 <input type="hidden" value="<?php echo esc_attr( Helper::get_string_value( $is_page_break ) ); ?>" id="srfm-page-break">
929 <?php if ( $honeypot_spam ) { ?>
930 <input type="hidden" value="" name="srfm-honeypot-field">
931 <?php
932 }
933 self::common_error_message( 'head' );
934 if ( $is_page_break ) {
935 do_action( 'srfm_page_break_pagination', $post, $id );
936 } elseif ( ! apply_filters( 'srfm_use_custom_field_content', false ) ) {
937 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Content is filtered and sanitized by WordPress core blocks and filters.
938 echo $content;
939 }
940
941 do_action( 'srfm_after_field_content', $post, $id );
942
943 $captcha_container_hidden_class = ! empty( $google_captcha_site_key ) && ( 'v3-reCAPTCHA' === $recaptcha_version || 'v2-invisible' === $recaptcha_version ) ? 'srfm-display-none' : '';
944
945 ?>
946 <?php if ( $should_show_submit_button && ! empty( $security_type ) && 'none' !== $security_type ) { ?>
947 <div class="srfm-captcha-container <?php echo esc_attr( $captcha_container_hidden_class ); ?>">
948
949 <?php
950
951 if ( 'g-recaptcha' === $security_type ) {
952 self::get_google_captcha_script( $recaptcha_version, $google_captcha_site_key );
953 }
954
955 if ( 'cf-turnstile' === $security_type ) {
956 self::get_cf_turnstile_script( $srfm_cf_appearance_mode, $srfm_cf_turnstile_site_key );
957 }
958
959 if ( 'hcaptcha' === $security_type ) {
960 self::get_h_captcha_script( $srfm_hcaptcha_site_key );
961 }
962 ?>
963 <div class="srfm-validation-error" id="captcha-error" style="display: none;"><?php echo esc_html__( 'Please verify that you are not a robot.', 'sureforms' ); ?></div>
964 </div>
965 <?php } ?>
966
967 <?php
968 if ( $is_page_break ) {
969 do_action( 'srfm_page_break_btn', $id );
970 }
971 $srfm_button_classes = apply_filters( 'srfm_add_button_classes', [ '1' === $btn_from_theme ? 'wp-block-button__link' : 'srfm-btn-frontend srfm-button srfm-submit-button', 'v3-reCAPTCHA' === $recaptcha_version ? ' g-recaptcha' : '' ], $id, $block_attrs );
972 ?>
973
974 <div class="srfm-submit-container <?php echo esc_attr( $is_page_break ? 'srfm-hide' : '' ); ?>" style="<?php echo ! $should_show_submit_button ? 'visibility:hidden;position:absolute;' : ''; ?>">
975 <div style="width: <?php echo esc_attr( $full ? '100%' : '' ); ?>; text-align: <?php echo esc_attr( $submit_button_alignment ); ?>" class="wp-block-button">
976 <?php do_action( 'srfm_before_submit_button', $id ); ?>
977 <?php if ( $should_show_submit_button ) { ?>
978 <button style="<?php echo esc_attr( $full ? 'width: 100%;' : '' ); ?>" id="srfm-submit-btn" class="<?php echo esc_attr( implode( ' ', array_filter( $srfm_button_classes ) ) ); ?>"
979 <?php if ( 'v3-reCAPTCHA' === $recaptcha_version ) { ?>
980 data-callback="recaptchaCallback"
981 data-error-callback="onGCaptchaV3Error"
982 recaptcha-type="<?php echo esc_attr( $recaptcha_version ); ?>"
983 data-sitekey="<?php echo esc_attr( $google_captcha_site_key ); ?>"
984 <?php } ?>
985 >
986 <div class="srfm-submit-wrap">
987 <?php echo esc_html( $button_text ); ?>
988 <div class="srfm-loader"></div>
989 </div>
990 </button>
991 <?php } ?>
992 <?php do_action( 'srfm_after_submit_button', $id ); ?>
993 </div>
994 </div>
995 <?php
996
997 echo wp_kses_post(
998 apply_filters(
999 'srfm_after_submit_button_content',
1000 '',
1001 [
1002 'id' => $id,
1003 'should_show_submit_button' => $should_show_submit_button,
1004 'button_text' => $button_text,
1005 'submit_button_alignment' => $submit_button_alignment,
1006 'full' => $full,
1007 'btn_from_theme' => $btn_from_theme,
1008 'is_page_break' => $is_page_break,
1009 'recaptcha_version' => $recaptcha_version,
1010 'google_captcha_site_key' => $google_captcha_site_key,
1011 'srfm_button_classes' => $srfm_button_classes,
1012 ]
1013 )
1014 );
1015 }
1016 self::common_error_message( 'footer' );
1017 ?>
1018 </form>
1019 <div class="srfm-single-form srfm-success-box in-page">
1020 <div aria-live="polite" aria-atomic="true" role="alert" id="srfm-success-message-page-<?php echo esc_attr( Helper::get_string_value( $id ) ); ?>" class="srfm-success-box-description"></div>
1021 </div>
1022 <?php
1023 // Add preview script for real-time styling updates from block editor.
1024 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a preview context, nonce not required.
1025 if ( isset( $_GET['form_preview'] ) && 'true' === $_GET['form_preview'] && isset( $container_id ) ) {
1026 self::enqueue_preview_styling_script( $container_id );
1027 }
1028 ?>
1029 </div>
1030 <?php
1031 self::$current_block_attrs = [];
1032 return ob_get_clean();
1033 }
1034
1035 /**
1036 * Generate HCaptcha script markup
1037 *
1038 * @param string $srfm_hcaptcha_site_key site key.
1039 * @since 1.7.0
1040 * @return void
1041 */
1042 public static function get_h_captcha_script( $srfm_hcaptcha_site_key ) {
1043 if ( ! empty( $srfm_hcaptcha_site_key ) ) {
1044 // hCaptcha script.
1045 wp_enqueue_script( 'hcaptcha', 'https://js.hcaptcha.com/1/api.js', [], null, [ 'strategy' => 'defer' ] ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
1046 ?>
1047 <div id="srfm-hcaptcha-sitekey" data-callback="onSuccess" data-error-callback="onHCaptchaError" class="h-captcha" data-sitekey="<?php echo esc_attr( $srfm_hcaptcha_site_key ); ?>"></div>
1048 <?php
1049 } else {
1050 Helper::render_missing_sitekey_error( 'HCaptcha' );
1051 }
1052 }
1053
1054 /**
1055 * Generate Google Recaptcha script markup
1056 *
1057 * @param string $recaptcha_version reCAPTCHA version.
1058 * @param string $google_captcha_site_key site key.
1059 * @since 1.7.0
1060 * @return void
1061 */
1062 public static function get_google_captcha_script( $recaptcha_version, $google_captcha_site_key ) {
1063
1064 if ( empty( $google_captcha_site_key ) ) {
1065 Helper::render_missing_sitekey_error( 'Google reCAPTCHA' );
1066 return;
1067 }
1068
1069 if ( 'v2-checkbox' === $recaptcha_version ) {
1070 ?>
1071 <?php
1072 wp_enqueue_script( 'google-recaptcha', 'https://www.google.com/recaptcha/api.js', [], SRFM_VER, true );
1073 ?>
1074 <div class='g-recaptcha' data-callback="onSuccess" data-error-callback="onGCaptchaV2CheckBoxError" recaptcha-type="<?php echo esc_attr( $recaptcha_version ); ?>" data-sitekey="<?php echo esc_attr( strval( $google_captcha_site_key ) ); ?>" ></div>
1075 <?php } ?>
1076
1077 <?php if ( 'v2-invisible' === $recaptcha_version ) { ?>
1078 <?php
1079 wp_enqueue_script( 'google-recaptcha-invisible', 'https://www.google.com/recaptcha/api.js?onload=recaptchaCallback&render=explicit', [ SRFM_SLUG . '-form-submit' ], SRFM_VER, true );
1080 ?>
1081 <div class='g-recaptcha' recaptcha-type="<?php echo esc_attr( $recaptcha_version ); ?>" data-sitekey="<?php echo esc_attr( $google_captcha_site_key ); ?>" data-size="invisible"></div>
1082 <?php } ?>
1083
1084 <?php if ( 'v3-reCAPTCHA' === $recaptcha_version ) { ?>
1085 <?php
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(
1088 'srfm-google-recaptchaV3',
1089 'https://www.google.com/recaptcha/api.js?render=' . $google_captcha_site_key,
1090 [],
1091 null,
1092 true
1093 );
1094 // phpcs:enable WordPress.WP.EnqueuedResourceParameters.MissingVersion, PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent
1095 ?>
1096 <?php
1097 }
1098 }
1099
1100 /**
1101 * Generate Cloudflare Turnstile script markup
1102 *
1103 * @param string $srfm_cf_appearance_mode appearance mode.
1104 * @param string $srfm_cf_turnstile_site_key site key.
1105 * @since 1.7.0
1106 * @return void
1107 */
1108 public static function get_cf_turnstile_script( $srfm_cf_appearance_mode, $srfm_cf_turnstile_site_key ) {
1109 if ( ! empty( $srfm_cf_turnstile_site_key ) ) {
1110 // Cloudflare Turnstile script.
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(
1113 SRFM_SLUG . '-cf-turnstile',
1114 'https://challenges.cloudflare.com/turnstile/v0/api.js',
1115 [],
1116 null,
1117 [
1118 'strategy' => 'defer',
1119 ]
1120 );
1121 // phpcs:enable WordPress.WP.EnqueuedResourceParameters.MissingVersion, PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent
1122 ?>
1123 <!-- The callback methods below are available on frontend.js. onTurnstileError displays and error in place of recaptcha dialog. -->
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>
1125 <?php
1126 } else {
1127 Helper::render_missing_sitekey_error( 'Turnstile' );
1128 }
1129 }
1130
1131 /**
1132 * Generate common error message markup
1133 *
1134 * @param string $position position of the error message.
1135 * @since 1.5.0
1136 * @return void
1137 */
1138 public static function common_error_message( $position = 'footer' ) {
1139 $icon = Helper::fetch_svg( 'info_circle', '', 'aria-hidden="true"' );
1140 $classes = "srfm-common-error-message srfm-error-message srfm-{$position}-error";
1141 ?>
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>
1143 <?php
1144 }
1145
1146 /**
1147 * Enqueue the preview styling script for real-time updates from block editor.
1148 *
1149 * @param string $container_id The form container ID selector.
1150 * @since 2.7.0
1151 * @return void
1152 */
1153 public static function enqueue_preview_styling_script( $container_id ) {
1154 $script_asset_path = SRFM_DIR . 'assets/build/previewStyling.asset.php';
1155 $script_asset = file_exists( $script_asset_path ) ? require $script_asset_path : [
1156 'dependencies' => [],
1157 'version' => SRFM_VER,
1158 ];
1159
1160 wp_enqueue_script(
1161 SRFM_SLUG . '-preview-styling',
1162 SRFM_URL . 'assets/build/previewStyling.js',
1163 $script_asset['dependencies'],
1164 $script_asset['version'],
1165 true
1166 );
1167
1168 wp_localize_script(
1169 SRFM_SLUG . '-preview-styling',
1170 'srfmPreviewStyling',
1171 [
1172 'containerId' => $container_id,
1173 'fieldSpacingVars' => Helper::get_css_vars(),
1174 ]
1175 );
1176
1177 /**
1178 * Action to allow Pro to enqueue additional preview styling scripts.
1179 *
1180 * @since 2.7.0
1181 */
1182 do_action( 'srfm_enqueue_preview_styling_scripts' );
1183 }
1184
1185 /**
1186 * Generate form confirmation markup
1187 *
1188 * @param array<mixed> $form_data contains form data.
1189 * @param array<mixed> $submission_data contains submission data.
1190 * @since 0.0.3
1191 * @return string|false
1192 */
1193 public static function get_confirmation_markup( $form_data = [], $submission_data = [] ) {
1194
1195 $confirmation_message = '';
1196
1197 if ( empty( $form_data ) ) {
1198 return $confirmation_message;
1199 }
1200
1201 $form_id = isset( $form_data['form-id'] ) ? Helper::get_integer_value( $form_data['form-id'] ) : 0;
1202 $form_confirmation = get_post_meta( $form_id, '_srfm_form_confirmation' );
1203
1204 /**
1205 * Filter the form confirmation data.
1206 * Allows conditional confirmations to override the default confirmation settings.
1207 *
1208 * @param mixed $form_confirmation The form confirmation data from post meta.
1209 * @param int $form_id The form ID.
1210 * @param array $submission_data The submission data.
1211 * @since 2.4.0
1212 */
1213 $form_confirmation = apply_filters( 'srfm_form_confirmation_data', $form_confirmation, $form_id, $submission_data );
1214
1215 if ( ! is_array( $form_confirmation ) ) {
1216 return $confirmation_message;
1217 }
1218
1219 $confirmation_data = is_array( $form_confirmation[0] ) && isset( $form_confirmation[0][0] ) ? $form_confirmation[0][0] : null;
1220
1221 if ( is_array( $form_confirmation ) && isset( $confirmation_data['message'] ) && is_string( $confirmation_data['message'] ) ) {
1222 $confirmation_message = $confirmation_data['message'];
1223 $confirmation_message = String_Translator::get_instance()->translate_confirmation_message( (int) $form_id, 0, $confirmation_message );
1224 }
1225 if ( empty( $submission_data ) ) {
1226 return $confirmation_message;
1227 }
1228 $smart_tags = new Smart_Tags();
1229 $confirmation_message = $smart_tags->process_smart_tags( $confirmation_message, $submission_data, $form_data );
1230
1231 /**
1232 * Filter whether confirmation message links should open in a new tab.
1233 *
1234 * @since 2.5.2
1235 *
1236 * @param bool $open_in_new_tab Whether links open in a new tab. Default true.
1237 */
1238 $open_in_new_tab = (bool) apply_filters( 'srfm_confirmation_links_open_in_new_tab', true );
1239
1240 $markup = Helper::strip_js_attributes(
1241 apply_filters( 'srfm_after_submit_confirmation_message', $confirmation_message, $form_data, $submission_data ),
1242 ! $open_in_new_tab
1243 );
1244
1245 if ( false !== strpos( $markup, 'src="image/svg+xml;base64' ) ) {
1246 // Handle Form Confirmation SVGs separately. We have planned to improve it in the future replacing it with image URL.
1247 $normalized_string = preg_replace( '/src="image\/svg\+xml;base64/', 'src="data:image/svg+xml;base64', $markup );
1248
1249 if ( is_string( $normalized_string ) ) {
1250 $markup = $normalized_string;
1251 }
1252 }
1253
1254 return $markup;
1255 }
1256
1257 /**
1258 * Get redirect url for form incase of different page or custom url is selected.
1259 *
1260 * @param array<mixed> $form_data contains form data.
1261 * @param array<mixed> $submission_data contains submission data.
1262 * @since 1.0.2
1263 * @return string|false
1264 */
1265 public static function get_redirect_url( $form_data = [], $submission_data = [] ) {
1266 $redirect_url = '';
1267
1268 if ( empty( $form_data ) ) {
1269 return $redirect_url;
1270 }
1271
1272 $form_id = isset( $form_data['form-id'] ) ? Helper::get_integer_value( $form_data['form-id'] ) : 0;
1273 $form_confirmation = get_post_meta( $form_id, '_srfm_form_confirmation' );
1274
1275 /**
1276 * Filter the form confirmation data.
1277 * Allows conditional confirmations to override the default confirmation settings.
1278 *
1279 * @param mixed $form_confirmation The form confirmation data from post meta.
1280 * @param int $form_id The form ID.
1281 * @param array $submission_data The submission data.
1282 * @since 2.4.0
1283 */
1284 $form_confirmation = apply_filters( 'srfm_form_confirmation_data', $form_confirmation, $form_id, $submission_data );
1285
1286 if ( ! is_array( $form_confirmation ) ) {
1287 return $redirect_url;
1288 }
1289
1290 $confirmation_data = is_array( $form_confirmation[0] ) && isset( $form_confirmation[0][0] ) ? $form_confirmation[0][0] : null;
1291
1292 $page_url = $confirmation_data['page_url'] ?? '';
1293 $custom_url = $confirmation_data['custom_url'] ?? '';
1294 $confirmation_type = $confirmation_data['confirmation_type'] ?? '';
1295
1296 if ( 'different page' === $confirmation_type ) {
1297 $redirect_url = esc_url_raw( $page_url );
1298 } elseif ( 'custom url' === $confirmation_type ) {
1299 $redirect_url = esc_url_raw( $custom_url );
1300 }
1301
1302 if ( empty( $redirect_url ) ) {
1303 return $redirect_url;
1304 }
1305
1306 if ( empty( $confirmation_data['enable_query_params'] ) || true !== $confirmation_data['enable_query_params'] ) {
1307 return $redirect_url;
1308 }
1309
1310 if ( empty( $confirmation_data['query_params'] ) && ! is_array( $confirmation_data['query_params'] ) ) {
1311 return $redirect_url;
1312 }
1313
1314 $query_params = [];
1315 foreach ( $confirmation_data['query_params'] as $params ) {
1316 if ( is_array( $params ) && ! empty( array_keys( $params ) ) && ! empty( array_values( $params ) ) ) {
1317 $query_params[ sanitize_text_field( array_keys( $params )[0] ) ] = sanitize_text_field( array_values( $params )[0] );
1318 }
1319 }
1320
1321 $redirect_url = add_query_arg( $query_params, $redirect_url );
1322
1323 if ( ! empty( $submission_data ) ) {
1324 $smart_tags = new Smart_Tags();
1325 // Adding upload_format_type = 'raw' to retrieve urls as comma separated values.
1326 $form_data['upload_format_type'] = 'raw';
1327 // Skip auto-linking URLs in smart tag values — redirect query params need raw values, not HTML.
1328 $form_data['smart_tag_context'] = 'redirect';
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 ) );
1353 }
1354
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 ';
1553 }
1554 }
1555