Settings for the active shared render pass. */ private $render_settings = []; /** @var int Form post ID for the active shared render pass (0 for an unsaved preview). */ private $render_form_id = 0; /** @var array{form_id:int,placement_key:string,dom_prefix:string,preview:bool} Active render context. */ private $render_context = []; /** @var array Legacy per-form placement occurrences for this request. */ private static $placement_occurrences = []; /** @var array Document-local DOM-prefix occurrences for this request. */ private static $dom_prefix_occurrences = []; /** @var array CAPTCHA providers required by rendered forms in this request. */ private static $captcha_providers = []; /** * Form IDs whose scoped normalize_placement_key( $placement_key ); if ( '' === $placement_key ) { $occurrence = ( self::$placement_occurrences[ $form_id ] ?? 0 ) + 1; self::$placement_occurrences[ $form_id ] = $occurrence; $placement_key = ( $is_preview ? 'preview' : 'legacy' ) . '-f' . $form_id . '-o' . $occurrence; } return $this->normalize_render_context( $form_id, [ 'placement_key' => $placement_key, 'preview' => (bool) $is_preview, ], (bool) $is_preview ); } /** Normalize an internal placement key; it is routing metadata, never authorization. */ public static function normalize_placement_key( $placement_key ) { $placement_key = strtolower( trim( (string) $placement_key ) ); $placement_key = preg_replace( '/[^a-z0-9_-]+/', '-', $placement_key ); $placement_key = is_string( $placement_key ) ? trim( $placement_key, '-' ) : ''; return substr( $placement_key, 0, 64 ); } /** @param array $context @return array{form_id:int,placement_key:string,dom_prefix:string,preview:bool} */ private function normalize_render_context( $form_id, array $context, $is_preview ) { $form_id = absint( $form_id ); $placement_key = self::normalize_placement_key( $context['placement_key'] ?? '' ); if ( '' === $placement_key ) { return $this->create_render_context( $form_id, '', $is_preview ); } if ( ! empty( $context['dom_prefix'] ) ) { return [ 'form_id' => $form_id, 'placement_key' => $placement_key, 'dom_prefix' => sanitize_html_class( (string) $context['dom_prefix'] ), 'preview' => (bool) $is_preview, ]; } $base_prefix = 'aofb-' . $placement_key; $occurrence = ( self::$dom_prefix_occurrences[ $base_prefix ] ?? 0 ) + 1; self::$dom_prefix_occurrences[ $base_prefix ] = $occurrence; return [ 'form_id' => $form_id, 'placement_key' => $placement_key, 'dom_prefix' => $base_prefix . ( $occurrence > 1 ? '-' . $occurrence : '' ), 'preview' => (bool) $is_preview, ]; } private function get_dom_id( $suffix ) { return (string) ( $this->render_context['dom_prefix'] ?? 'aofb' ) . '-' . sanitize_html_class( (string) $suffix ); } /** * Build the combined scoped breakout before reaching this filter). * * @param string $css Additional CSS to output (default ''). * @param array $settings Form settings. * @param int $form_id Form post ID (0 for an unsaved preview). * @param bool $is_preview Whether this is an unsaved builder preview. */ $extra_css = (string) apply_filters( 'adminoptim_form_builder_output_css', '', $settings, $form_id, $is_preview ); $blocks = array_filter( [ trim( $generated_css ), trim( $extra_css ) ], static function ( $block ) { return '' !== $block; } ); return implode( "\n", $blocks ); } /** * Generate Free's Form Styling CSS for one form: a set of scoped CSS * custom properties plus the rules that consume them, reproducing the * exact visual defaults that used to live statically in * form-builder-frontend.scss (so an existing form looks unchanged after * upgrading with no resave needed) layered with this form's own * configured colors/radius/message styling. Always returns a non-empty * string — callers gate calling this on `style_enabled`. * * @param array $settings Form settings. * @param int $form_id Form post ID (0 for an unsaved preview). * * @return string */ private function build_generated_style_css( array $settings, $form_id ) { $selector = '.aofb-frontend-wrap[data-form-id="' . absint( $form_id ) . '"]'; $hex = static function ( $value, $fallback ) { if ( empty( $value ) ) { return $fallback; } $sanitized = sanitize_hex_color( $value ); return $sanitized ? $sanitized : $fallback; }; $primary_color = $hex( $settings['style_primary_color'] ?? '', '#0d6efd' ); // Matches the pre-existing behavior exactly: a custom primary color // has no separate "hover" shade computed for it (just reuses the // same color), while the built-in default keeps its own darker hover. $primary_hover = ! empty( $settings['style_primary_color'] ) ? $primary_color : '#0b5ed7'; $text_color = $hex( $settings['style_text_color'] ?? '', '#212529' ); $input_bg = $hex( $settings['style_bg_color'] ?? '', '#fff' ); $border_color = $hex( $settings['style_border_color'] ?? '', '#ced4da' ); $border_radius = isset( $settings['style_border_radius'] ) && '' !== $settings['style_border_radius'] ? absint( $settings['style_border_radius'] ) : 6; $rgb = sscanf( $primary_color, '#%02x%02x%02x' ); if ( ! is_array( $rgb ) || 3 !== count( array_filter( $rgb, 'is_int' ) ) ) { $rgb = [ 13, 110, 253 ]; } $focus_ring = 'rgba(' . absint( $rgb[0] ) . ', ' . absint( $rgb[1] ) . ', ' . absint( $rgb[2] ) . ', 0.25)'; // Submit Button: independent of Primary color/Fields border radius // above, but defaults to them unset so an existing form looks // unchanged with no resave needed (this used to be the only way to // color the button at all). Same "no separate computed hover shade // for a custom color" rule as $primary_hover above — only the // built-in default gets one. $btn_bg = $hex( $settings['style_btn_bg'] ?? '', $primary_color ); $btn_bg_hover = ! empty( $settings['style_btn_bg'] ) ? $btn_bg : $primary_hover; $btn_text = $hex( $settings['style_btn_text'] ?? '', '#fff' ); $btn_border = $hex( $settings['style_btn_border'] ?? '', $btn_bg ); $btn_radius = isset( $settings['style_btn_radius'] ) && '' !== $settings['style_btn_radius'] ? absint( $settings['style_btn_radius'] ) : $border_radius; $success_bg = $hex( $settings['style_msg_success_bg'] ?? '', '#d1e7dd' ); $success_text = $hex( $settings['style_msg_success_text'] ?? '', '#0f5132' ); $success_border = $hex( $settings['style_msg_success_border'] ?? '', '#badbcc' ); $error_bg = $hex( $settings['style_msg_error_bg'] ?? '', '#f8d7da' ); $error_text = $hex( $settings['style_msg_error_text'] ?? '', '#842029' ); $error_border = $hex( $settings['style_msg_error_border'] ?? '', '#f5c2c7' ); $msg_radius = isset( $settings['style_msg_border_radius'] ) && '' !== $settings['style_msg_border_radius'] ? absint( $settings['style_msg_border_radius'] ) : $border_radius; $msg_padding = isset( $settings['style_msg_padding'] ) && '' !== $settings['style_msg_padding'] ? absint( $settings['style_msg_padding'] ) : 16; $vars = [ '--aofb-primary-color' => $primary_color, '--aofb-primary-hover' => $primary_hover, '--aofb-primary-focus-ring' => $focus_ring, '--aofb-text-color' => $text_color, '--aofb-input-bg' => $input_bg, '--aofb-border-color' => $border_color, '--aofb-border-radius' => $border_radius . 'px', '--aofb-msg-radius' => $msg_radius . 'px', '--aofb-msg-padding' => $msg_padding . 'px', '--aofb-btn-bg' => $btn_bg, '--aofb-btn-bg-hover' => $btn_bg_hover, '--aofb-btn-text' => $btn_text, '--aofb-btn-border' => $btn_border, '--aofb-btn-radius' => $btn_radius . 'px', ]; $var_lines = []; foreach ( $vars as $name => $value ) { $var_lines[] = esc_html( $name ) . ': ' . esc_html( $value ) . ';'; } $check_svg = "url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e\")"; $radio_svg = "url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e\")"; $select_svg = "url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e\")"; $css = $selector . " {\n\t" . implode( "\n\t", $var_lines ) . "\n}\n"; $css .= $selector . " .form-label { color: var(--aofb-text-color); }\n"; $css .= $selector . ' .form-control, ' . $selector . " .form-select { color: var(--aofb-text-color); background-color: var(--aofb-input-bg); background-clip: padding-box; border: 1px solid var(--aofb-border-color); appearance: none; border-radius: var(--aofb-border-radius); }\n"; $css .= $selector . ' .form-select { background-image: ' . $select_svg . "; background-repeat: no-repeat; background-position: right var(--aofb-padding-x, 0.75rem) center; background-size: 16px 12px; padding-right: calc(var(--aofb-padding-x, 0.75rem) + 1.5rem); }\n"; $css .= $selector . ' .form-control:focus, ' . $selector . " .form-select:focus { color: var(--aofb-text-color); background-color: var(--aofb-input-bg); border-color: var(--aofb-primary-hover); outline: 0; box-shadow: 0 0 0 0.25rem var(--aofb-primary-focus-ring); }\n"; $css .= $selector . ' .form-control.is-invalid, ' . $selector . " .form-select.is-invalid { border-color: #dc3545; }\n"; $css .= $selector . ' .form-control.is-invalid:focus, ' . $selector . " .form-select.is-invalid:focus { border-color: #dc3545; box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.25); }\n"; $css .= $selector . " .invalid-feedback { color: #dc3545; }\n"; $css .= $selector . " .text-danger { color: #dc3545 !important; }\n"; $css .= $selector . ' .form-text, ' . $selector . " .text-muted { color: #6c757d; }\n"; $css .= $selector . " .form-check-input { appearance: none; background-color: var(--aofb-input-bg); background-repeat: no-repeat; background-position: center; background-size: contain; border: 1px solid rgba(0, 0, 0, 0.25); border-radius: 0.25em; print-color-adjust: exact; }\n"; $css .= $selector . " .form-check-input:checked { background-color: var(--aofb-primary-color); border-color: var(--aofb-primary-color); }\n"; $css .= $selector . ' .form-check-input[type="checkbox"]:checked { background-image: ' . $check_svg . "; }\n"; $css .= $selector . ' .form-check-input[type="radio"]:checked { background-image: ' . $radio_svg . "; }\n"; $css .= $selector . " .aofb-rating-star { color: #adb5bd; }\n"; $css .= $selector . ' .aofb-rating-star:hover, ' . $selector . " .aofb-rating-star:focus { color: var(--aofb-primary-hover); outline: 0; }\n"; $css .= $selector . " .aofb-rating-star:focus-visible { border-radius: var(--aofb-border-radius); box-shadow: 0 0 0 0.25rem var(--aofb-primary-focus-ring); }\n"; $css .= $selector . " .aofb-rating-star.is-selected { color: var(--aofb-primary-color); }\n"; $css .= $selector . " .aofb-rating-star.is-half { color: #adb5bd; }\n"; $css .= $selector . " .aofb-rating-star.is-half::before { color: var(--aofb-primary-color); }\n"; $css .= $selector . " .aofb-rating-value { color: var(--aofb-text-color); }\n"; $css .= $selector . " .aofb-rating-input.is-invalid ~ .aofb-rating-stars .aofb-rating-star { color: #dc3545; }\n"; // Target the submit control itself, not only the default Bootstrap-style // classes. A form may intentionally clear or replace button_css_class. $submit_selector = $selector . ' .aofb-frontend-submit button[type="submit"]'; $css .= $selector . ' .btn, ' . $submit_selector . " { color: var(--aofb-btn-text); border-radius: var(--aofb-btn-radius); }\n"; $css .= $selector . ' .btn-primary, ' . $submit_selector . " { color: var(--aofb-btn-text); background-color: var(--aofb-btn-bg); border-color: var(--aofb-btn-border); }\n"; $css .= $selector . ' .btn-primary:hover, ' . $selector . ' .btn-primary:focus, ' . $submit_selector . ':hover, ' . $submit_selector . ":focus { color: var(--aofb-btn-text); background-color: var(--aofb-btn-bg-hover); border-color: var(--aofb-btn-bg-hover); }\n"; $css .= $selector . ' .btn-primary:focus, ' . $submit_selector . ":focus { outline: 0; box-shadow: 0 0 0 0.25rem var(--aofb-primary-focus-ring); }\n"; $css .= $selector . " .aofb-frontend-notice { border-radius: var(--aofb-msg-radius); padding: var(--aofb-msg-padding); }\n"; $css .= $selector . ' .aofb-frontend-notice-success { color: ' . esc_html( $success_text ) . '; background-color: ' . esc_html( $success_bg ) . '; border-color: ' . esc_html( $success_border ) . "; }\n"; $css .= $selector . ' .aofb-frontend-notice-error { color: ' . esc_html( $error_text ) . '; background-color: ' . esc_html( $error_bg ) . '; border-color: ' . esc_html( $error_border ) . "; }\n"; return $css; } /** Render the form-level success state used by POST and AJAX submissions. */ private function render_success_state( array $settings ) { echo '
' . wp_kses_post( $settings['success_message'] ?? '' ) . '
'; } /** * Emit the small JSON blob the frontend JS conditional-logic evaluator * needs: each field/Row's own `conditions` plus its parent node id (for * the ancestor-Row-AND-own-condition visibility rule), and the type of * only the fields actually referenced by some rule (not full field * definitions, to avoid leaking other fields' validation internals). * * @param array> $fields Full sanitized form field tree. * @param array> $condition_nodes Node map built during rendering (id => [parent, conditions, row]). * * @return void */ private function render_condition_payload( array $fields, array $condition_nodes ) { // Free's evaluator (adminoptim_form_builder_resolve_active_fields) is // an unconditional pass-through, so the frontend must match: emitting // this payload with Pro inactive would visually hide/disable fields // the backend still treats as fully active, reproducing exactly the // stuck-required-field problem the Pro-inactive banner exists to flag. if ( ! Module_Registry::is_pro_active() ) { return; } $has_active = false; foreach ( $condition_nodes as $node ) { if ( ! empty( $node['conditions']['enabled'] ) ) { $has_active = true; break; } } if ( ! $has_active ) { return; } $referenced_names = []; $this->collect_condition_target_names( $fields, $referenced_names ); $name_types = []; $this->collect_field_name_types( $fields, $name_types ); $payload = [ 'nodes' => $condition_nodes, 'fieldTypes' => array_intersect_key( $name_types, array_flip( $referenced_names ) ), ]; echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode() output embedded in a non-executable application/json script tag. } /** * Recursively collect field names referenced as a condition-rule target * anywhere in the tree. * * @param array> $fields Field tree. * @param array $names By-ref accumulator. * * @return void */ private function collect_condition_target_names( array $fields, array &$names ) { foreach ( $fields as $field ) { $conditions = isset( $field['conditions'] ) && is_array( $field['conditions'] ) ? $field['conditions'] : []; foreach ( (array) ( $conditions['rules'] ?? [] ) as $rule ) { if ( ! empty( $rule['field'] ) ) { $names[] = (string) $rule['field']; } } if ( 'row' === (string) ( $field['type'] ?? '' ) && ! empty( $field['children'] ) && is_array( $field['children'] ) ) { $this->collect_condition_target_names( $field['children'], $names ); } } } /** * Recursively collect name => type for every value-bearing field. * * @param array> $fields Field tree. * @param array $map By-ref accumulator. * * @return void */ private function collect_field_name_types( array $fields, array &$map ) { foreach ( $fields as $field ) { if ( 'row' === (string) ( $field['type'] ?? '' ) ) { if ( ! empty( $field['children'] ) && is_array( $field['children'] ) ) { $this->collect_field_name_types( $field['children'], $map ); } continue; } $name = (string) ( $field['name'] ?? '' ); if ( '' !== $name ) { $map[ $name ] = (string) ( $field['type'] ?? 'text' ); } } } /** * Get sanitized anti-spam settings. * * @return array */ private function get_antispam_settings() { $options = is_array( $this->options ) ? $this->options : []; return [ 'enable_recaptcha' => ! empty( $options['enable_recaptcha'] ) ? 1 : 0, 'recaptcha_site_key' => isset( $options['recaptcha_site_key'] ) ? sanitize_text_field( $options['recaptcha_site_key'] ) : '', 'enable_turnstile' => ! empty( $options['enable_turnstile'] ) ? 1 : 0, 'turnstile_site_key' => isset( $options['turnstile_site_key'] ) ? sanitize_text_field( $options['turnstile_site_key'] ) : '', 'turnstile_invisible_site_key' => isset( $options['turnstile_invisible_site_key'] ) ? sanitize_text_field( $options['turnstile_invisible_site_key'] ) : '', ]; } /** * Check whether a form includes a specific anti-spam field. * * @param array> $fields Form fields. * @param string $type Field type. * * @return bool */ private function has_antispam_field( array $fields, $type ) { foreach ( $fields as $field ) { if ( isset( $field['type'] ) && $type === (string) $field['type'] ) { return true; } if ( ! empty( $field['children'] ) && is_array( $field['children'] ) && $this->has_antispam_field( $field['children'], $type ) ) { return true; } } return false; } /** * Render one frontend field. * * @param array $field Field definition. * @param array $values Current values. * @param array $errors Current errors. * @param array $antispam Anti-spam settings. * @param array> $condition_nodes By-ref conditional-logic node accumulator * (id => [parent, conditions, row]), keyed * in render order; see render_condition_payload(). * @param string|null $parent_node_id Enclosing Row's node id, if any. * * @return void */ private function render_frontend_field( array $field, array $values, array $errors, array $antispam = [], array &$condition_nodes = [], $parent_node_id = null ) { $name = (string) $field['name']; $type = (string) $field['type']; $value = $values[ $name ] ?? ( $field['default'] ?? '' ); $choices = Fields::parse_choices( $field['choices'] ?? '' ); $is_required = ! empty( $field['required'] ); $required = $is_required ? ' required' : ''; $error = $errors[ $name ] ?? ''; if ( 'row' === $type ) { $this->render_frontend_row( $field, $values, $errors, $antispam, $condition_nodes, $parent_node_id ); return; } $width_class = null !== $parent_node_id ? ' aofb-layout-width-' . esc_attr( $field['width'] ?? 'half' ) : ''; $custom_class = $this->custom_class_attr( $field ); if ( 'submit' === $type ) { $button_label = '' !== trim( (string) ( $field['label'] ?? '' ) ) ? (string) $field['label'] : (string) ( $this->render_settings['submit_label'] ?? __( 'Submit', 'admin-optimizer' ) ); $css_class = isset( $this->render_settings['submit_css_class'] ) ? sanitize_html_class( (string) $this->render_settings['submit_css_class'] ) : ''; /** * Filters the base class applied to the submit button, in place of * its own `button_css_class` value (itself editable in the builder's * Advanced panel, defaulting to "btn btn-primary"). Runs before the * per-form `submit_css_class` setting, which is still appended after * it. * * A `adminoptim_form_builder_submit_button_class_{$form_id}` variant * fires as well, for a change scoped to one specific form (0 for an * unsaved preview, which never reaches a form-specific tag). * * @param string $button_class The field's own `button_css_class` value. * @param array $field The submit field's definition. * @param array $settings The current form's settings. * @param int $form_id The current form's post ID (0 for an unsaved preview). */ $base_button_class = Fields::sanitize_css_class( (string) ( $field['button_css_class'] ?? 'btn btn-primary' ) ); $button_class = (string) apply_filters( 'adminoptim_form_builder_submit_button_class', $base_button_class, $field, $this->render_settings, $this->render_form_id ); if ( $this->render_form_id > 0 ) { $button_class = (string) apply_filters( "adminoptim_form_builder_submit_button_class_{$this->render_form_id}", $button_class, $field, $this->render_settings ); } $button_class = Fields::sanitize_css_class( $button_class ); echo '
'; return; } if ( 'html_message' === $type ) { echo '
' . wp_kses_post( $field['html_message'] ) . '
'; return; } if ( in_array( $type, [ 'recaptcha', 'turnstile' ], true ) ) { $this->render_antispam_field( $field, $antispam ); return; } // Hidden fields are data fields, but intentionally have no visual // wrapper, label, spacing, or visible-input classes. if ( 'hidden' === $type ) { $hidden_value = ! empty( $field['allow_prepopulation'] ) && isset( $values[ $name ] ) && is_scalar( $values[ $name ] ) ? (string) $values[ $name ] : (string) ( $field['hidden_value'] ?? '' ); echo ''; return; } $node_id = 'n' . count( $condition_nodes ); $condition_nodes[ $node_id ] = [ 'parent' => $parent_node_id, 'conditions' => isset( $field['conditions'] ) && is_array( $field['conditions'] ) ? $field['conditions'] : [], 'row' => false, ]; // The custom class targets the control itself so it is predictable to // style; Star Rating has no real input (its control is a custom widget), // so it keeps the class on the wrapper. $class_on_input = 'rating' !== $type; $field_class = 'aofb-frontend-field form-group aofb-frontend-field-' . $type . $width_class . ( $error ? ' has-error' : '' ) . ( $class_on_input ? '' : $custom_class ) . ' mb-3'; echo '
'; $label_class = 'form-label' . ( ! empty( $field['hide_label'] ) ? ' aofb-visually-hidden' : '' ); echo ''; $input_class = ( $error ? ' is-invalid' : '' ) . ( $class_on_input ? $custom_class : '' ); switch ( $type ) { case 'textarea': $rows = ! empty( $field['rows'] ) ? max( 1, absint( $field['rows'] ) ) : 10; // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- build_frontend_attributes() escapes each attribute internally via esc_attr()/sanitize_key(). echo ''; // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped if ( ! empty( $field['show_counter'] ) ) { $current_length = is_scalar( $value ) ? strlen( (string) $value ) : 0; echo '
' . esc_html( $current_length . ( ! empty( $field['maxlength'] ) ? '/' . absint( $field['maxlength'] ) : '' ) ) . '
'; } break; case 'select': $is_multiple = ! empty( $field['multiple'] ); if ( ! isset( $values[ $name ] ) ) { $value = $is_multiple ? (array) ( $field['default_choices'] ?? [] ) : (string) ( $field['default_choice'] ?? '' ); } $current_values = $is_multiple ? array_map( 'strval', (array) $value ) : [ (string) $value ]; echo ''; break; case 'radio': if ( ! isset( $values[ $name ] ) && ! empty( $field['default_choice'] ) ) { $value = (string) $field['default_choice']; } echo '
'; foreach ( $choices as $choice_value => $choice_label ) { $choice_id = $this->get_dom_id( 'choice-' . $name . '-' . sanitize_title( $choice_value ) ); echo '
'; $required = ''; } echo '
'; break; case 'check': if ( ! isset( $values[ $name ] ) && ! empty( $field['default_choices'] ) ) { $value = (array) $field['default_choices']; } $current_values = is_array( $value ) ? array_map( 'strval', $value ) : []; echo '
'; foreach ( $choices as $choice_value => $choice_label ) { $choice_id = $this->get_dom_id( 'choice-' . $name . '-' . sanitize_title( $choice_value ) ); echo '
'; $required = ''; } echo '
'; break; case 'rating': $this->render_rating_field( $field, $name, $value, $input_class ); break; case 'number': // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- build_frontend_attributes() escapes each attribute internally via esc_attr()/sanitize_key(). echo 'build_frontend_attributes( [ 'min' => $field['number_min'] ?? '', 'max' => $field['number_max'] ?? '', 'step' => $field['number_step'] ?? '', ] ) . esc_attr( $required ) . '>'; // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped break; case 'email': echo ''; break; case 'phone': echo ''; break; case 'url': echo ''; break; case 'file': $accept = ''; if ( ! empty( $field['allowed_file_types'] ) ) { $accept_parts = []; foreach ( preg_split( '/\s*,\s*/', (string) $field['allowed_file_types'] ) as $file_type ) { $file_type = strtolower( trim( $file_type ) ); if ( '' === $file_type ) { continue; } $accept_parts[] = false !== strpos( $file_type, '/' ) || 0 === strpos( $file_type, '.' ) ? $file_type : '.' . ltrim( $file_type, '.' ); } $accept = $accept_parts ? ' accept="' . esc_attr( implode( ',', array_unique( $accept_parts ) ) ) . '"' : ''; } echo 'file_input_attributes( $field ) . ' class="form-control' . esc_attr( $input_class ) . '" id="' . esc_attr( $this->get_dom_id( 'field-' . $name ) ) . '" name="adminoptim_form_files[' . esc_attr( $name ) . ']"' . esc_attr( $required ) . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped break; case 'image': $accept = ' accept="image/*"'; if ( ! empty( $field['allowed_file_types'] ) ) { $accept_parts = []; foreach ( preg_split( '/\s*,\s*/', (string) $field['allowed_file_types'] ) as $file_type ) { $file_type = strtolower( trim( $file_type ) ); if ( '' === $file_type ) { continue; } $accept_parts[] = false !== strpos( $file_type, '/' ) || 0 === strpos( $file_type, '.' ) ? $file_type : '.' . ltrim( $file_type, '.' ); } $accept = $accept_parts ? ' accept="' . esc_attr( implode( ',', array_unique( $accept_parts ) ) ) . '"' : $accept; } echo 'file_input_attributes( $field ) . ' class="form-control' . esc_attr( $input_class ) . '" id="' . esc_attr( $this->get_dom_id( 'field-' . $name ) ) . '" name="adminoptim_form_files[' . esc_attr( $name ) . ']"' . esc_attr( $required ) . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped break; case 'datepicker': // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- build_frontend_attributes() escapes each attribute internally via esc_attr()/sanitize_key(). echo 'build_frontend_attributes( [ 'min' => $field['date_min'] ?? '', 'max' => $field['date_max'] ?? '', ] ) . esc_attr( $required ) . '>'; // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped break; default: // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- build_frontend_attributes() escapes each attribute internally via esc_attr()/sanitize_key(). echo 'build_frontend_attributes( [ 'minlength' => $field['minlength'] ?? '', 'maxlength' => $field['maxlength'] ?? '', ] ) . esc_attr( $required ) . '>'; // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped break; } if ( ! empty( $field['description'] ) && 'html_message' !== $type ) { echo '
' . esc_html( $field['description'] ) . '
'; } if ( $error ) { echo '
' . esc_html( $error ) . '
'; } echo '
'; } /** * Build a leading-space-prefixed, escaped class fragment for a field's * user-defined CSS class, for appending to its frontend wrapper's class * attribute. Returns an empty string when the field has none, so it is * always safe to concatenate directly into a class="..." attribute. * * @param array $field Field definition. * * @return string */ private function custom_class_attr( array $field ) { $css_class = Fields::sanitize_css_class( $field['css_class'] ?? '' ); return '' !== $css_class ? ' ' . esc_attr( $css_class ) : ''; } /** * Build escaped frontend input attributes from configured values. * * @param array $attributes Attribute map. * * @return string */ private function build_frontend_attributes( array $attributes ) { $html = ''; foreach ( $attributes as $name => $value ) { if ( '' === trim( (string) $value ) ) { continue; } $html .= ' ' . sanitize_key( $name ) . '="' . esc_attr( (string) $value ) . '"'; } return $html; } /** * Extra attributes for a file input. * * @param array $field Field definition. * * @return string */ private function file_input_attributes( array $field ) { /** * Filters the attributes added to a form's file input. Pro hooks this * to give the browser the field's upload limits to check as soon as a * file is chosen. Values are escaped on output. * * @param array $attributes Attribute map. * @param array $field Field definition. */ return $this->build_frontend_attributes( (array) apply_filters( 'adminoptim_form_builder_file_input_attributes', [], $field ) ); } /** * Render a star rating field. * * @param array $field Field definition. * @param string $name Field name. * @param mixed $value Current value. * @param string $input_class Invalid class when needed. * * @return void */ private function render_rating_field( array $field, $name, $value, $input_class ) { // Star Rating is Pro-only; guard rendering directly in case a rating // field reaches this dispatcher from stored data the sanitize-on-read // gate in Form_Builder_Forms hasn't stripped yet (e.g. stale caches). if ( ! Module_Registry::is_pro_active() ) { return; } $max = isset( $field['rating_max'] ) && in_array( (string) absint( $field['rating_max'] ), [ '5', '10' ], true ) ? absint( $field['rating_max'] ) : 5; $allow_half = ! empty( $field['rating_allow_half'] ); $value = is_scalar( $value ) && is_numeric( $value ) ? (string) $value : ''; $step = $allow_half ? '0.5' : '1'; $label = ! empty( $field['label'] ) ? (string) $field['label'] : (string) $name; echo '
'; echo ''; echo '
'; for ( $i = 1; $i <= $max; $i++ ) { echo ''; } echo '
'; echo '' . esc_html( '' !== $value ? sprintf( '%1$s/%2$s', $value, $max ) : '' ) . ''; echo ''; echo '
'; } /** * Render a layout-only row field. * * @param array $field Row field definition. * @param array $values Current values. * @param array $errors Current errors. * @param array $antispam Anti-spam settings. * @param array> $condition_nodes By-ref conditional-logic node accumulator; see render_condition_payload(). * @param string|null $parent_node_id Enclosing Row's node id, if any (Row nesting is otherwise disallowed). * * @return void */ private function render_frontend_row( array $field, array $values, array $errors, array $antispam = [], array &$condition_nodes = [], $parent_node_id = null ) { $settings = isset( $field['settings'] ) && is_array( $field['settings'] ) ? $field['settings'] : []; $gap = isset( $settings['gap'] ) ? (string) $settings['gap'] : 'md'; if ( ! in_array( $gap, [ 'sm', 'md', 'lg' ], true ) ) { $gap = 'md'; } $classes = [ 'ao-form-row', 'ao-form-row--gap-' . $gap, ]; if ( ! empty( $settings['stack_mobile'] ) ) { $classes[] = 'ao-form-row--stack-mobile'; } $row_css_class = Fields::sanitize_css_class( $field['css_class'] ?? '' ); if ( '' !== $row_css_class ) { $classes[] = $row_css_class; } $node_id = 'n' . count( $condition_nodes ); $condition_nodes[ $node_id ] = [ 'parent' => $parent_node_id, 'conditions' => isset( $field['conditions'] ) && is_array( $field['conditions'] ) ? $field['conditions'] : [], 'row' => true, ]; echo '
'; foreach ( (array) ( $field['children'] ?? [] ) as $child ) { if ( is_array( $child ) ) { $this->render_frontend_field( $child, $values, $errors, $antispam, $condition_nodes, $node_id ); } } echo '
'; } /** * Render the hidden honeypot field. * * @return void */ private function render_honeypot_field() { $options = [ [ 'label' => __( 'Company Website', 'admin-optimizer' ), 'name' => 'aofb_company_website', ], [ 'label' => __( 'Business URL', 'admin-optimizer' ), 'name' => 'aofb_business_url', ], [ 'label' => __( 'Email Confirm', 'admin-optimizer' ), 'name' => 'aofb_email_confirm', ], [ 'label' => __( 'Profile Link', 'admin-optimizer' ), 'name' => 'aofb_profile_link', ], [ 'label' => __( 'Organization Site', 'admin-optimizer' ), 'name' => 'aofb_organization_site', ], ]; $field = $options[ wp_rand( 0, count( $options ) - 1 ) ]; $field_id = $this->get_dom_id( 'honeypot-' . sanitize_html_class( str_replace( '_', '-', $field['name'] ) ) ); ?> $field Anti-spam field definition. * @param array $antispam Anti-spam settings. * * @return void */ private function render_antispam_field( array $field, array $antispam ) { $type = (string) $field['type']; $mode = 'invisible' === ( $field['display_mode'] ?? '' ) ? 'invisible' : 'managed'; $antispam_class = 'aofb-frontend-field form-group aofb-frontend-field-antispam aofb-frontend-field-' . $type . $this->custom_class_attr( $field ) . ( 'turnstile' === $type && 'invisible' === $mode ? '' : ' mb-3' ); echo '
'; if ( 'recaptcha' === $type ) { if ( empty( $antispam['recaptcha_site_key'] ) ) { echo '
' . esc_html__( 'reCAPTCHA site key is not set. Add it in Form Builder Settings.', 'admin-optimizer' ) . '
'; } else { echo '
'; } } if ( 'turnstile' === $type ) { $site_key = $antispam[ 'invisible' === $mode ? 'turnstile_invisible_site_key' : 'turnstile_site_key' ]; if ( empty( $site_key ) ) { echo '
' . esc_html__( 'Turnstile site key is not set. Add it in Form Builder Settings.', 'admin-optimizer' ) . '
'; } else { echo '
'; } } echo '
'; } /** * Render the review-request banner when viewing the Form Builder's * custom post type list screen. * * @return void */ public function maybe_render_banner() { $screen = get_current_screen(); if ( ! $screen || 'adminoptim-form' !== $screen->post_type ) { return; } /* translators: 1: Opening strong tag, 2: Closing strong tag. */ Notification::request_banner( 'form-builder', sprintf( __( '%1$sHas the Form Builder saved you time?%2$s We\'d really appreciate a ★★★★★ review on WordPress.org.', 'admin-optimizer' ), '', '' ) ); } }