form = $form; $this->post_id = absint( $post_id ); $this->is_edit = ! empty( $this->post_id ); $this->field_manager = AFFCF_Field_Manager::get_instance(); } /** * Render the form * * @param int $draft_id Optional draft ID to load. * @return string HTML output. */ public function render( $draft_id = 0 ) { // Load draft data if draft_id provided. if ( $draft_id ) { $this->load_draft( $draft_id ); } ob_start(); // Form wrapper. echo '
'; // Show draft notice if loading draft. if ( $this->is_draft ) { echo '
'; echo '

' . esc_html__( 'You are editing a draft. Continue editing or submit when ready.', 'airy-frontend-forms' ) . '

'; echo '
'; } // Display messages. $this->display_messages(); // Form element. echo '
'; // Nonce field. wp_nonce_field( 'affcf_submit_form_' . $this->form->id, 'affcf_nonce' ); // Hidden fields. echo ''; echo ''; if ( $this->is_draft ) { echo ''; } if ( $this->is_edit ) { echo ''; } // Render default WordPress fields. $this->render_default_fields(); // Render ACF/SCF field groups. $this->render_field_groups(); // Render taxonomies. $this->render_taxonomies(); // Submit button. $this->render_submit_button(); echo '
'; echo '
'; return ob_get_clean(); } /** * Load draft data from database * * @param int $draft_id Draft ID. * @return void */ private function load_draft( $draft_id ) { global $wpdb; $drafts_table = $wpdb->prefix . 'affcf_drafts'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, plugin-controlled $draft = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$drafts_table} WHERE id = %d AND user_id = %d AND form_id = %d", $draft_id, get_current_user_id(), $this->form->id ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( $draft ) { $this->draft_id = $draft_id; $this->is_draft = true; // Decode JSON draft data. $this->draft_data = json_decode( $draft->draft_data, true ); if ( ! is_array( $this->draft_data ) ) { $this->draft_data = array(); } } } /** * Render default WordPress fields * * @return void */ private function render_default_fields() { if ( empty( $this->form->default_fields ) ) { return; } foreach ( $this->form->default_fields as $field_name => $config ) { if ( empty( $config['enabled'] ) ) { continue; } $this->render_default_field( $field_name, $config ); } } /** * Render a single default field * * @param string $field_name Field name (post_title, post_content, etc.). * @param array $config Field configuration. * @return void */ private function render_default_field( $field_name, $config ) { $value = ''; // Priority: 1. Draft data, 2. Post data (edit mode). if ( $this->is_draft && isset( $this->draft_data[ $field_name ] ) ) { // Use draft data. $value = $this->draft_data[ $field_name ]; } elseif ( $this->is_edit ) { // Get existing value from post if editing. $post = get_post( $this->post_id ); switch ( $field_name ) { case 'post_title': $value = $post->post_title; break; case 'post_content': $value = $post->post_content; break; case 'post_excerpt': $value = $post->post_excerpt; break; case 'featured_image': $value = get_post_thumbnail_id( $this->post_id ); break; } } // Get field configuration. $label = ! empty( $config['label'] ) ? $config['label'] : $this->get_default_label( $field_name ); $placeholder = ! empty( $config['placeholder'] ) ? $config['placeholder'] : ''; $required = ! empty( $config['required'] ); $help_text = ! empty( $config['help_text'] ) ? $config['help_text'] : ''; echo '
'; // Label. echo ''; // Render field based on type. switch ( $field_name ) { case 'post_title': echo ''; break; case 'post_excerpt': echo ''; break; case 'post_content': $editor_type = ! empty( $config['editor_type'] ) ? $config['editor_type'] : 'textarea'; if ( 'wysiwyg' === $editor_type ) { wp_editor( $value, 'affcf_' . $field_name, array( 'textarea_name' => $field_name, 'textarea_rows' => 10, 'media_buttons' => false, 'teeny' => true, 'quicktags' => true, ) ); } else { echo ''; } break; case 'featured_image': $feat_attachment_id = absint( $value ); echo '
'; // Hidden input to store attachment ID. echo ''; // Preview. if ( $feat_attachment_id ) { $image_url = wp_get_attachment_image_url( $feat_attachment_id, 'medium' ); if ( $image_url ) { echo '
'; echo ''; echo ''; echo '
'; } } echo ''; echo ''; echo '
'; break; } // Help text. if ( $help_text ) { echo '

' . esc_html( $help_text ) . '

'; } echo '
'; } /** * Get default label for field * * @param string $field_name Field name. * @return string Label. */ private function get_default_label( $field_name ) { $labels = array( 'post_title' => __( 'Title', 'airy-frontend-forms' ), 'post_content' => __( 'Content', 'airy-frontend-forms' ), 'post_excerpt' => __( 'Excerpt', 'airy-frontend-forms' ), 'featured_image' => __( 'Featured Image', 'airy-frontend-forms' ), ); return isset( $labels[ $field_name ] ) ? $labels[ $field_name ] : ucwords( str_replace( '_', ' ', $field_name ) ); } /** * Render field groups * * @return void */ private function render_field_groups() { if ( empty( $this->form->field_groups ) ) { return; } foreach ( $this->form->field_groups as $group_config ) { if ( empty( $group_config['enabled_fields'] ) ) { continue; } $this->render_field_group( $group_config ); } } /** * Render a single field group * * @param array $group_config Group configuration. * @return void */ private function render_field_group( $group_config ) { $group_id = $group_config['group_id']; // Get all field groups for this post type to find the fields. $all_groups = $this->field_manager->get_field_groups( $this->form->post_type ); // Find the matching group. $matching_group = null; foreach ( $all_groups as $group ) { if ( $group['id'] === $group_id ) { $matching_group = $group; break; } } if ( ! $matching_group || empty( $matching_group['fields'] ) ) { return; } $all_fields = $matching_group['fields']; if ( empty( $all_fields ) ) { return; } // First, prepare all fields to be rendered. $fields_to_render = array(); foreach ( $group_config['enabled_fields'] as $field_key ) { // Find the field. $field = $this->find_field( $all_fields, $field_key ); if ( ! $field ) { continue; } // Get field configuration from form. $field_settings = isset( $group_config['field_settings'][ $field_key ] ) ? $group_config['field_settings'][ $field_key ] : array(); // Merge with field settings. $field = array_merge( $field, $field_settings ); // For group/repeater fields, add enabled sub-fields info. if ( in_array( $field['type'], array( 'group', 'repeater' ), true ) ) { if ( isset( $group_config['sub_fields_data'][ $field_key ] ) ) { $field['enabled_sub_fields'] = $group_config['sub_fields_data'][ $field_key ]; } } // For relationship fields, ensure we have fresh ACF data with all settings. if ( in_array( $field['type'], array( 'relationship', 'post_object', 'page_link' ), true ) ) { $field = $this->enhance_relationship_field( $field ); } // Get field value - Priority: 1. Draft data, 2. Post data (edit mode). if ( $this->is_draft && isset( $this->draft_data[ $field_key ] ) ) { // Use draft data. $field['value'] = $this->draft_data[ $field_key ]; } elseif ( $this->is_edit ) { // Get value from post if editing. $edit_value = $this->field_manager->get_field_value( $field_key, $this->post_id ); // Clone sub-fields stored as individual meta rows. if ( 'clone' === $field['type'] && ! empty( $field['sub_fields'] ) ) { // Clone field with nested sub-fields: assemble from post_meta // using each sub-field's prefixed name as meta key. $clone_data = array(); foreach ( $field['sub_fields'] as $sub_field ) { $sub_name = isset( $sub_field['name'] ) ? $sub_field['name'] : ''; if ( ! empty( $sub_name ) ) { $meta_value = get_post_meta( $this->post_id, $sub_name, true ); if ( '' !== $meta_value ) { $clone_data[ $sub_name ] = $meta_value; } } } if ( ! empty( $clone_data ) ) { $edit_value = $clone_data; } } // Clone sub-field as individual top-level field (compound key): // The form may expand clone sub-fields into enabled_fields using // clone-modified keys (e.g. "field_PARENT_field_CHILD"). Data is // saved under the prefixed NAME via ACF's native pipeline. // Use acf_get_value() instead of get_post_meta() so that complex // sub-field types (group, repeater) trigger their load_value() // filters to assemble nested data from individual meta entries. if ( empty( $edit_value ) && function_exists( 'acf_get_field' ) ) { $sep = strpos( $field_key, '_field_', 6 ); if ( false !== $sep ) { $parent_key = substr( $field_key, 0, $sep ); if ( strpos( $parent_key, 'field_' ) === 0 ) { $parent_field = acf_get_field( $parent_key ); if ( $parent_field && 'clone' === $parent_field['type'] && ! empty( $parent_field['sub_fields'] ) ) { foreach ( $parent_field['sub_fields'] as $sf ) { if ( isset( $sf['key'] ) && $sf['key'] === $field_key && ! empty( $sf['name'] ) ) { // Use acf_get_value for complex types, get_post_meta for simple. if ( function_exists( 'acf_get_value' ) && in_array( $sf['type'], array( 'group', 'repeater', 'flexible_content', 'clone' ), true ) ) { $edit_value = acf_get_value( $this->post_id, $sf ); } else { $edit_value = get_post_meta( $this->post_id, $sf['name'], true ); } break; } } } } } } $field['value'] = $edit_value; } $fields_to_render[] = $field; } // Render fields with accordion/tab grouping support. $this->render_fields_with_layout_support( $fields_to_render ); } /** * Render fields with accordion and tab layout support * * @param array $fields Fields to render. * @return void */ private function render_fields_with_layout_support( $fields ) { $count = count( $fields ); $i = 0; $accordion_open = false; while ( $i < $count ) { $field = $fields[ $i ]; $field_type = $field['type']; // Handle accordion fields. if ( 'accordion' === $field_type ) { $is_endpoint = ! empty( $field['endpoint'] ); if ( $is_endpoint ) { // Close the current accordion if open. if ( $accordion_open ) { echo ''; // Close accordion-content and accordion-field. $accordion_open = false; } } else { // Close previous accordion if open (new accordion starts). if ( $accordion_open ) { echo ''; } // Start new accordion. $label = isset( $field['label'] ) ? $field['label'] : ''; $open = isset( $field['open'] ) ? $field['open'] : false; $open_class = $open ? ' affcf-accordion-open' : ''; echo '
'; echo '
'; echo '' . esc_html( $label ) . ''; echo '▼'; echo '
'; echo '
'; } } /** * Render a group of tabs as a tabbed interface (WP admin style) * * @param array $tabs Array of tab data, each with 'tab' and 'fields' keys. * @return void */ private function render_tab_group( $tabs ) { if ( empty( $tabs ) ) { return; } $group_id = 'affcf-tabgroup-' . wp_unique_id(); echo '
'; // Tab navigation bar. echo ''; // Tab content panels. foreach ( $tabs as $index => $tab_data ) { $active_class = ( 0 === $index ) ? ' active' : ''; echo ''; } echo '
'; } /** * Enhance relationship field with fresh ACF data * * @param array $field Field array. * @return array Enhanced field array. */ private function enhance_relationship_field( $field ) { // If ACF is active, fetch fresh field data to ensure we have post_type settings. if ( function_exists( 'acf_get_field' ) && ! empty( $field['key'] ) ) { $fresh_field = acf_get_field( $field['key'] ); if ( $fresh_field ) { // Merge fresh data, prioritizing post_type from fresh data. if ( isset( $fresh_field['post_type'] ) ) { $field['post_type'] = $fresh_field['post_type']; } // Handle 'multiple' setting intelligently. if ( 'relationship' === $fresh_field['type'] ) { // Relationship fields: ALWAYS default to multiple unless ACF explicitly sets it to 0. // This ensures multi-select behavior for relationship fields. $field['multiple'] = isset( $fresh_field['multiple'] ) && 0 === $fresh_field['multiple'] ? 0 : 1; } else { // For post_object and page_link, respect ACF setting exactly. $field['multiple'] = isset( $fresh_field['multiple'] ) ? $fresh_field['multiple'] : 0; } if ( isset( $fresh_field['allow_null'] ) ) { $field['allow_null'] = $fresh_field['allow_null']; } } } return $field; } /** * Find field in array by key * * @param array $fields Fields array. * @param string $field_key Field key to find. * @return array|null Field data or null. */ private function find_field( $fields, $field_key ) { foreach ( $fields as $field ) { if ( $field['key'] === $field_key ) { return $field; } } return null; } /** * Render a single field * * @param array $field Field configuration. * @return void */ private function render_field( $field ) { $field_type = $field['type']; $field_key = $field['key']; $label = ! empty( $field['custom_label'] ) ? $field['custom_label'] : $field['label']; $required = ! empty( $field['required'] ); $help_text = ! empty( $field['help_text'] ) ? $field['help_text'] : ''; $placeholder = ! empty( $field['placeholder'] ) ? $field['placeholder'] : ''; $value = isset( $field['value'] ) ? $field['value'] : ''; echo '
'; // Label (except for certain types). if ( ! in_array( $field_type, array( 'true_false', 'message' ), true ) ) { echo ''; } // Render field based on type. switch ( $field_type ) { case 'text': case 'email': case 'url': case 'number': case 'range': case 'password': $this->render_text_field( $field, $value, $placeholder, $required ); break; case 'textarea': $this->render_textarea_field( $field, $value, $placeholder, $required ); break; case 'select': $this->render_select_field( $field, $value, $required ); break; case 'checkbox': $this->render_checkbox_field( $field, $value ); break; case 'radio': case 'button_group': $this->render_radio_field( $field, $value, $required ); break; case 'true_false': $this->render_true_false_field( $field, $value ); break; case 'wysiwyg': $this->render_wysiwyg_field( $field, $value, $placeholder, $required ); break; case 'file': $this->render_file_field( $field, $value, $required ); break; case 'image': $this->render_image_field( $field, $value, $required ); break; case 'gallery': $this->render_gallery_field( $field, $value, $required ); break; case 'date_picker': case 'date_time_picker': case 'time_picker': $this->render_date_field( $field, $value, $placeholder, $required ); break; case 'color_picker': $this->render_color_field( $field, $value, $required ); break; case 'repeater': $this->render_repeater_field( $field, $value ); break; case 'group': $this->render_group_field( $field, $value ); break; case 'post_object': case 'relationship': case 'page_link': $this->render_relationship_field( $field, $value, $required ); break; case 'taxonomy': $this->render_taxonomy_field( $field, $value, $required ); break; case 'link': $this->render_link_field( $field, $value, $required ); break; case 'user': $this->render_user_field( $field, $value, $required ); break; case 'oembed': $this->render_oembed_field( $field, $value, $placeholder, $required ); break; case 'message': $this->render_message_field( $field ); break; case 'tab': $this->render_tab_field( $field ); break; case 'accordion': // Accordions are handled by render_fields_with_layout_support. // If called directly, just render as a visual element. $acc_label = isset( $field['label'] ) ? $field['label'] : ''; echo '
'; echo '' . esc_html( $acc_label ) . ''; echo '
'; break; case 'flexible_content': $this->render_flexible_content_field( $field, $value ); break; case 'nav_menu': $this->render_nav_menu_field( $field, $value, $required ); break; case 'icon_picker': $this->render_icon_picker_field( $field, $value, $required ); break; case 'google_map': $this->render_google_map_field( $field, $value, $required ); break; case 'clone': $this->render_clone_field( $field, $value ); break; default: // Unsupported field type message. /* translators: %s: field type name */ echo '

' . sprintf( esc_html__( 'Field type "%s" is not yet supported.', 'airy-frontend-forms' ), esc_html( $field_type ) ) . '

'; break; } // Help text. if ( $help_text ) { echo '

' . esc_html( $help_text ) . '

'; } echo '
'; } /** * Render text field * * @param array $field Field configuration. * @param mixed $value Field value. * @param string $placeholder Placeholder text. * @param bool $required Whether field is required. * @return void */ private function render_text_field( $field, $value, $placeholder, $required ) { $field_type = $field['type']; // Number field with WordPress admin-like spinner. if ( 'number' === $field_type ) { $min_val = isset( $field['min'] ) ? $field['min'] : ''; $max_val = isset( $field['max'] ) ? $field['max'] : ''; $step_val = isset( $field['step'] ) ? $field['step'] : '1'; echo '
'; echo ''; echo '
'; echo ''; echo ''; echo '
'; echo '
'; return; } // URL field with WordPress admin-like globe icon. if ( 'url' === $field_type ) { echo '
'; echo ''; echo ''; echo '
'; return; } // Range field with WordPress admin-like slider. if ( 'range' === $field_type ) { $min_val = isset( $field['min'] ) ? $field['min'] : 0; $max_val = isset( $field['max'] ) ? $field['max'] : 100; $step_val = isset( $field['step'] ) ? $field['step'] : 1; $cur_val = '' !== $value ? $value : $min_val; echo '
'; echo ''; echo '
'; echo ''; echo '
'; echo '
'; return; } // Password field with WordPress admin-like styling. if ( 'password' === $field_type ) { echo '
'; echo ''; echo ''; echo '
'; return; } // Email field with WordPress admin-like styling. if ( 'email' === $field_type ) { echo '
'; echo ''; echo '
'; return; } // Default text field with WordPress admin-like styling. echo '
'; echo ''; echo '
'; } /** * Render textarea field * * @param array $field Field configuration. * @param mixed $value Field value. * @param string $placeholder Placeholder text. * @param bool $required Whether field is required. * @return void */ private function render_textarea_field( $field, $value, $placeholder, $required ) { $rows = isset( $field['rows'] ) ? absint( $field['rows'] ) : 5; // WordPress admin-like textarea. echo '
'; echo ''; echo '
'; } /** * Render select field * * @param array $field Field configuration. * @param mixed $value Field value. * @param bool $required Whether field is required. * @return void */ private function render_select_field( $field, $value, $required ) { $multiple = ! empty( $field['multiple'] ); $choices = isset( $field['choices'] ) ? $field['choices'] : array(); // WordPress admin-like select field. if ( $multiple ) { // Multi-select with WordPress admin-like styling. echo ''; } else { // Single select with WordPress admin-like styling. // Normalize array value to string (can happen for selects inside clone groups). if ( is_array( $value ) ) { $value = ! empty( $value ) ? reset( $value ) : ''; } echo ''; } } /** * Render checkbox field * * @param array $field Field configuration. * @param mixed $value Field value. * @return void */ private function render_checkbox_field( $field, $value ) { $choices = isset( $field['choices'] ) ? $field['choices'] : array(); $value = is_array( $value ) ? $value : array(); // WordPress admin-like checkbox group. echo '
'; foreach ( $choices as $choice_value => $choice_label ) { $checked = in_array( $choice_value, $value, true ); echo ''; } echo '
'; } /** * Render radio field * * @param array $field Field configuration. * @param mixed $value Field value. * @param bool $required Whether field is required. * @return void */ private function render_radio_field( $field, $value, $required ) { $choices = isset( $field['choices'] ) ? $field['choices'] : array(); // Button group field with WordPress admin-like toggle buttons. if ( 'button_group' === $field['type'] ) { echo '
'; foreach ( $choices as $choice_value => $choice_label ) { $checked = ( (string) $value === (string) $choice_value ); echo ''; } echo '
'; return; } // Regular radio field with WordPress admin-like styling. echo '
'; foreach ( $choices as $choice_value => $choice_label ) { $checked = ( (string) $value === (string) $choice_value ); echo ''; } echo '
'; } /** * Render true/false field * * @param array $field Field configuration. * @param mixed $value Field value. * @return void */ private function render_true_false_field( $field, $value ) { $checked = ! empty( $value ); $message = isset( $field['message'] ) ? $field['message'] : ''; // WordPress admin-like true/false field. echo '
'; echo ''; if ( $message ) { echo '' . esc_html( $message ) . ''; } echo '
'; } /** * Render WYSIWYG field * * @param array $field Field configuration. * @param mixed $value Field value. * @param string $placeholder Placeholder text. * @param bool $required Whether field is required. * @return void */ private function render_wysiwyg_field( $field, $value, $placeholder, $required ) { $editor_id = 'affcf_' . str_replace( array( '-', '.' ), '_', $field['key'] ); wp_editor( $value, $editor_id, array( 'textarea_name' => $field['key'], 'textarea_rows' => isset( $field['rows'] ) ? absint( $field['rows'] ) : 10, 'media_buttons' => false, 'teeny' => true, 'quicktags' => true, 'tinymce' => array( 'toolbar1' => 'bold,italic,underline,link,unlink,bullist,numlist', ), ) ); if ( $required ) { echo ''; } } /** * Render file field * * @param array $field Field configuration. * @param mixed $value Field value. * @param bool $required Whether field is required. * @return void */ private function render_file_field( $field, $value, $required ) { // Convert to float to prevent "string * int" error. // Use 2MB default if not set or 0. $max_size = ( isset( $field['max_size'] ) && $field['max_size'] > 0 ) ? floatval( $field['max_size'] ) : 2; // MB. $mime_types = isset( $field['mime_types'] ) ? $field['mime_types'] : ''; // Normalize value to attachment ID. $attachment_id = 0; if ( ! empty( $value ) ) { if ( is_numeric( $value ) ) { $attachment_id = absint( $value ); } elseif ( is_array( $value ) && isset( $value['ID'] ) ) { $attachment_id = absint( $value['ID'] ); } elseif ( is_object( $value ) && isset( $value->ID ) ) { $attachment_id = absint( $value->ID ); } } // WordPress admin-like file field. echo '
'; // Hidden input for attachment ID. echo ''; // Hidden file input. echo ''; // File preview (if exists). if ( $attachment_id ) { $file_url = wp_get_attachment_url( $attachment_id ); $file_name = $file_url ? basename( $file_url ) : ''; if ( $file_name ) { echo '
'; echo ''; echo '' . esc_html( $file_name ) . ''; echo ''; echo '
'; } } // "No file selected" text and Add File button. echo ''; echo ''; echo '
'; } /** * Render image field * * @param array $field Field configuration. * @param mixed $value Field value. * @param bool $required Whether field is required. * @return void */ private function render_image_field( $field, $value, $required ) { // Convert to float to prevent "string * int" error. // Use 2MB default if not set or 0. $max_size = ( isset( $field['max_size'] ) && $field['max_size'] > 0 ) ? floatval( $field['max_size'] ) : 2; // MB. // Normalize value to attachment ID. $attachment_id = 0; if ( ! empty( $value ) ) { // Handle string numbers (from draft data JSON). if ( is_string( $value ) && is_numeric( $value ) ) { $attachment_id = absint( $value ); } elseif ( is_numeric( $value ) ) { // Return format: Image ID. $attachment_id = absint( $value ); } elseif ( is_array( $value ) && isset( $value['ID'] ) ) { // Return format: Image Array. $attachment_id = absint( $value['ID'] ); } elseif ( is_object( $value ) && isset( $value->ID ) ) { // Return format: Image Object. $attachment_id = absint( $value->ID ); } elseif ( is_string( $value ) && filter_var( $value, FILTER_VALIDATE_URL ) ) { // Return format: Image URL - try to get ID from URL. $attachment_id = attachment_url_to_postid( $value ); } } // WordPress admin-like image field (uses WP Media Library). echo '
'; // Hidden input for attachment ID. echo ''; // Image preview (if exists). if ( $attachment_id ) { $image_url = wp_get_attachment_image_url( $attachment_id, 'medium' ); if ( $image_url ) { echo '
'; echo ''; echo ''; echo '
'; } } // "No image selected" text and Add Image button (opens WP Media Library). echo ''; echo ''; echo '
'; } /** * Render gallery field * * @param array $field Field configuration. * @param mixed $value Field value. * @param bool $required Whether field is required. * @return void */ private function render_gallery_field( $field, $value, $required ) { $max_files = isset( $field['max'] ) ? absint( $field['max'] ) : 10; // Convert to float to prevent "string * int" error. // Use 2MB default if not set or 0. $max_size = ( isset( $field['max_size'] ) && $field['max_size'] > 0 ) ? floatval( $field['max_size'] ) : 2; // MB per file. // Normalize value to array of attachment IDs. $attachment_ids = array(); if ( ! empty( $value ) ) { if ( is_array( $value ) ) { foreach ( $value as $item ) { // Handle different ACF return formats AND string numbers from draft. if ( is_string( $item ) && is_numeric( $item ) ) { // String number from draft JSON. $id = absint( $item ); $attachment_ids[] = $id; } elseif ( is_numeric( $item ) ) { // Return format: Image ID. $id = absint( $item ); $attachment_ids[] = $id; } elseif ( is_array( $item ) && isset( $item['ID'] ) ) { // Return format: Image Array. $id = absint( $item['ID'] ); $attachment_ids[] = $id; } elseif ( is_object( $item ) && isset( $item->ID ) ) { // Return format: Image Object. $id = absint( $item->ID ); $attachment_ids[] = $id; } } } elseif ( is_string( $value ) && is_numeric( $value ) ) { // Single string number. $attachment_ids[] = absint( $value ); } elseif ( is_numeric( $value ) ) { // Single ID. $attachment_ids[] = absint( $value ); } } // WordPress admin-like gallery field. echo ''; // .affcf-gallery-field-wp } /** * Render date field * * @param array $field Field configuration. * @param mixed $value Field value. * @param string $placeholder Placeholder text. * @param bool $required Whether field is required. * @return void */ private function render_date_field( $field, $value, $placeholder, $required ) { $input_type = 'text'; $data_type = ''; switch ( $field['type'] ) { case 'date_picker': $input_type = 'date'; $data_type = 'date'; // Convert ACF date value to HTML5 date format (Y-m-d). // ACF may store as Ymd (20250219) or return formatted (19/02/2025). $value = $this->convert_to_html5_date( $value, 'Y-m-d' ); break; case 'date_time_picker': $input_type = 'datetime-local'; $data_type = 'datetime'; // Convert to HTML5 datetime-local format (Y-m-d\TH:i). $value = $this->convert_to_html5_date( $value, 'Y-m-d\TH:i' ); break; case 'time_picker': $input_type = 'time'; $data_type = 'time'; // Convert to HTML5 time format (H:i). $value = $this->convert_to_html5_date( $value, 'H:i' ); break; } echo ''; } /** * Convert a date/time value from any ACF format to the target HTML5 format. * * @param string $value The date/time value in any format. * @param string $target The target format (e.g. 'Y-m-d', 'Y-m-d\TH:i', 'H:i'). * @return string Converted value, or original if conversion fails. */ private function convert_to_html5_date( $value, $target ) { if ( empty( $value ) || ! is_string( $value ) ) { return ''; } // Try common ACF date/time formats. $formats = array( 'Ymd', // ACF save format for date_picker (20250219). 'Y-m-d H:i:s', // ACF save format for date_time_picker. 'Y-m-d H:i', // Datetime without seconds. 'Y-m-d\TH:i:s', // ISO 8601 with seconds. 'Y-m-d\TH:i', // ISO 8601 without seconds. 'Y-m-d', // HTML5 date format. 'H:i:s', // ACF save format for time_picker. 'H:i', // HTML5 time format. 'g:i a', // 12-hour format (2:30 pm). 'g:i A', // 12-hour format (2:30 PM). 'd/m/Y', // Common display format. 'd/m/Y g:i a', // Display format with time. 'm/d/Y', // US date format. 'F j, Y', // Long date format (February 19, 2025). 'd-m-Y', // Dashed European format. ); foreach ( $formats as $format ) { $date = DateTime::createFromFormat( $format, $value ); if ( $date && $date->format( $format ) === $value ) { return $date->format( $target ); } } // Fallback: try strtotime() for flexible parsing. $timestamp = strtotime( $value ); if ( false !== $timestamp && $timestamp > 0 ) { return gmdate( $target, $timestamp ); } return $value; } /** * Render color picker field * * @param array $field Field configuration. * @param mixed $value Field value. * @param bool $required Whether field is required. * @return void */ private function render_color_field( $field, $value, $required ) { $default_value = isset( $field['default_value'] ) ? $field['default_value'] : '#000000'; $value = ! empty( $value ) ? $value : $default_value; echo ''; } /** * Render repeater field * * @param array $field Field configuration. * @param mixed $value Field value. * @return void */ private function render_repeater_field( $field, $value ) { $min = isset( $field['min'] ) ? absint( $field['min'] ) : 0; $max = isset( $field['max'] ) ? absint( $field['max'] ) : 0; $button_label = isset( $field['button_label'] ) ? $field['button_label'] : __( 'Add Row', 'airy-frontend-forms' ); // Get sub-fields from field array (already populated by Field Manager). $sub_fields = isset( $field['sub_fields'] ) ? $field['sub_fields'] : array(); // Filter sub-fields based on enabled_sub_fields if set. if ( isset( $field['enabled_sub_fields'] ) && ! empty( $field['enabled_sub_fields'] ) ) { $enabled_keys = $field['enabled_sub_fields']; $sub_fields = array_filter( $sub_fields, function ( $sub_field ) use ( $enabled_keys ) { $key = isset( $sub_field['key'] ) ? $sub_field['key'] : $sub_field['name']; return in_array( $key, $enabled_keys, true ); } ); } $value = is_array( $value ) ? $value : array(); $row_count = max( count( $value ), $min > 0 ? $min : 1 ); echo '
'; echo '
'; // Render existing rows. for ( $i = 0; $i < $row_count; $i++ ) { $row_value = isset( $value[ $i ] ) ? $value[ $i ] : array(); $this->render_repeater_row( $field, $sub_fields, $row_value, $i ); } echo '
'; // Add row button. echo ''; // Template row (hidden). echo ''; echo '
'; } /** * Render repeater row * * @param array $field Parent field configuration. * @param array $sub_fields Sub-fields array. * @param array $row_value Row value. * @param int|string $index Row index. * @return void */ private function render_repeater_row( $field, $sub_fields, $row_value, $index ) { echo '
'; // Row header. echo '
'; /* translators: %s: row number with HTML span wrapper */ echo '' . sprintf( esc_html__( 'Row %s', 'airy-frontend-forms' ), '' . esc_html( '{INDEX}' === $index ? '{INDEX}' : $index + 1 ) . '' ) . ''; echo ''; echo '
'; // Sub-fields. echo '
'; foreach ( $sub_fields as $sub_field ) { $sub_field_key = $sub_field['key']; $sub_field_name = $field['key'] . '[' . $index . '][' . $sub_field_key . ']'; // ACF's get_field() returns repeater rows keyed by sub-field name // (not key), so try key first, then name as fallback. $sub_field_value = isset( $row_value[ $sub_field_key ] ) ? $row_value[ $sub_field_key ] : ''; if ( '' === $sub_field_value && ! empty( $sub_field['name'] ) && isset( $row_value[ $sub_field['name'] ] ) ) { $sub_field_value = $row_value[ $sub_field['name'] ]; } // Render sub-field. echo '
'; // Label. $label = ! empty( $sub_field['custom_label'] ) ? $sub_field['custom_label'] : $sub_field['label']; $required = ! empty( $sub_field['required'] ); echo ''; // Render based on type. $this->render_sub_field( $sub_field, $sub_field_name, $sub_field_value, $required ); echo '
'; } echo '
'; echo '
'; } /** * Render sub-field (for repeater/group) * * @param array $field Sub-field configuration. * @param string $field_name Field name attribute. * @param mixed $value Field value. * @param bool $required Whether field is required. * @return void */ private function render_sub_field( $field, $field_name, $value, $required ) { $placeholder = ! empty( $field['placeholder'] ) ? $field['placeholder'] : ''; // For file/image fields in nested structures, construct proper file input name. // Convert: group[sub_field] -> group[sub_field_file]. // Instead of: group[sub_field]_file (which PHP can't parse). $file_input_name = $field_name; if ( in_array( $field['type'], array( 'file', 'image' ), true ) && false !== strpos( $field_name, '[' ) ) { // Remove closing bracket, add _file, then add bracket back. $file_input_name = rtrim( $field_name, ']' ) . '_file]'; } else { $file_input_name = $field_name . '_file'; } switch ( $field['type'] ) { case 'text': case 'email': case 'url': case 'number': $input_type = ( 'number' === $field['type'] ) ? 'number' : 'text'; echo ''; break; case 'range': $min_val = isset( $field['min'] ) ? $field['min'] : 0; $max_val = isset( $field['max'] ) ? $field['max'] : 100; $step = isset( $field['step'] ) ? $field['step'] : 1; echo '
'; echo ''; echo '' . esc_html( $value ? $value : $min_val ) . ''; echo '
'; break; case 'password': echo ''; break; case 'textarea': echo ''; break; case 'select': $choices = isset( $field['choices'] ) ? $field['choices'] : array(); // Normalize array value to string for single select. if ( is_array( $value ) ) { $value = ! empty( $value ) ? reset( $value ) : ''; } echo ''; break; case 'checkbox': $choices = isset( $field['choices'] ) ? $field['choices'] : array(); $value_array = is_array( $value ) ? $value : array(); echo '
'; foreach ( $choices as $choice_value => $choice_label ) { $checked = in_array( $choice_value, $value_array, true ); echo ''; } echo '
'; break; case 'radio': $choices = isset( $field['choices'] ) ? $field['choices'] : array(); echo '
'; foreach ( $choices as $choice_value => $choice_label ) { $checked = ( (string) $value === (string) $choice_value ); echo ''; } echo '
'; break; case 'button_group': $choices = isset( $field['choices'] ) ? $field['choices'] : array(); echo '
'; foreach ( $choices as $choice_value => $choice_label ) { $checked = ( (string) $value === (string) $choice_value ); echo ''; } echo '
'; break; case 'file': // Normalize value to attachment ID. $attachment_id = 0; if ( ! empty( $value ) ) { if ( is_numeric( $value ) ) { $attachment_id = absint( $value ); } elseif ( is_array( $value ) && isset( $value['ID'] ) ) { $attachment_id = absint( $value['ID'] ); } elseif ( is_object( $value ) && isset( $value->ID ) ) { $attachment_id = absint( $value->ID ); } } echo '
'; // Hidden input for attachment ID. echo ''; // File input. echo ''; // Current file display. if ( $attachment_id ) { $file_url = wp_get_attachment_url( $attachment_id ); $file_name = basename( $file_url ); echo '
'; echo '' . esc_html( $file_name ) . ''; echo ''; echo '
'; } else { echo ''; } echo '
'; break; case 'image': // Normalize value to attachment ID. $attachment_id = 0; if ( ! empty( $value ) ) { if ( is_numeric( $value ) ) { $attachment_id = absint( $value ); } elseif ( is_array( $value ) && isset( $value['ID'] ) ) { $attachment_id = absint( $value['ID'] ); } elseif ( is_object( $value ) && isset( $value->ID ) ) { $attachment_id = absint( $value->ID ); } } echo '
'; // Hidden input for attachment ID. echo ''; // Image preview. if ( $attachment_id ) { $image_url = wp_get_attachment_image_url( $attachment_id, 'medium' ); if ( $image_url ) { echo '
'; echo ''; echo ''; echo '
'; } } echo ''; echo ''; echo '
'; break; case 'gallery': // Normalize value to array of attachment IDs. $attachment_ids = array(); if ( ! empty( $value ) && is_array( $value ) ) { foreach ( $value as $item ) { if ( is_numeric( $item ) ) { $attachment_ids[] = absint( $item ); } elseif ( is_array( $item ) && isset( $item['ID'] ) ) { $attachment_ids[] = absint( $item['ID'] ); } elseif ( is_object( $item ) && isset( $item->ID ) ) { $attachment_ids[] = absint( $item->ID ); } } } $max_files = isset( $field['max'] ) ? absint( $field['max'] ) : 10; echo ''; break; case 'wysiwyg': // For sub-fields, use a simple textarea instead of full wp_editor. // This avoids issues with multiple TinyMCE instances in repeaters. $editor_id = str_replace( array( '[', ']', '-', '.' ), '_', $field_name ); echo '
'; echo ''; echo '
'; break; case 'date_picker': case 'date': $value = $this->convert_to_html5_date( $value, 'Y-m-d' ); echo ''; break; case 'date_time_picker': $value = $this->convert_to_html5_date( $value, 'Y-m-d\TH:i' ); echo ''; break; case 'time_picker': $value = $this->convert_to_html5_date( $value, 'H:i' ); echo ''; break; case 'true_false': $checked = ! empty( $value ); $message = isset( $field['message'] ) ? $field['message'] : ''; echo ''; break; case 'color_picker': echo ''; break; case 'group': // Render nested group fields. $sub_fields = isset( $field['sub_fields'] ) ? $field['sub_fields'] : array(); if ( empty( $sub_fields ) ) { echo '

' . esc_html__( 'No sub-fields in this group.', 'airy-frontend-forms' ) . '

'; break; } $group_value = is_array( $value ) ? $value : array(); echo '
'; foreach ( $sub_fields as $sub_field ) { $nested_name = $field_name . '[' . $sub_field['name'] . ']'; $nested_value = isset( $group_value[ $sub_field['name'] ] ) ? $group_value[ $sub_field['name'] ] : ''; $nested_req = ! empty( $sub_field['required'] ); echo '
'; $nested_label = isset( $sub_field['label'] ) ? $sub_field['label'] : $sub_field['name']; echo ''; $this->render_sub_field( $sub_field, $nested_name, $nested_value, $nested_req ); echo '
'; } echo '
'; break; case 'repeater': // Render nested repeater. $sub_fields = isset( $field['sub_fields'] ) ? $field['sub_fields'] : array(); $button_label = isset( $field['button_label'] ) ? $field['button_label'] : __( 'Add Row', 'airy-frontend-forms' ); $min = isset( $field['min'] ) ? absint( $field['min'] ) : 0; $max = isset( $field['max'] ) ? absint( $field['max'] ) : 0; if ( empty( $sub_fields ) ) { echo '

' . esc_html__( 'No sub-fields in this repeater.', 'airy-frontend-forms' ) . '

'; break; } $repeater_value = is_array( $value ) ? $value : array(); $row_count = max( count( $repeater_value ), $min > 0 ? $min : 1 ); echo '
'; echo '
'; for ( $i = 0; $i < $row_count; $i++ ) { $row_value = isset( $repeater_value[ $i ] ) ? $repeater_value[ $i ] : array(); echo '
'; echo '
'; /* translators: %s: row number */ echo '' . sprintf( esc_html__( 'Row %s', 'airy-frontend-forms' ), '' . esc_html( $i + 1 ) . '' ) . ''; echo ''; echo '
'; echo '
'; foreach ( $sub_fields as $sub_field ) { $sub_name = $field_name . '[' . $i . '][' . $sub_field['name'] . ']'; $sub_value = isset( $row_value[ $sub_field['name'] ] ) ? $row_value[ $sub_field['name'] ] : ''; $sub_req = ! empty( $sub_field['required'] ); echo '
'; $sub_label = isset( $sub_field['label'] ) ? $sub_field['label'] : $sub_field['name']; echo ''; $this->render_sub_field( $sub_field, $sub_name, $sub_value, $sub_req ); echo '
'; } echo '
'; echo '
'; } echo '
'; echo ''; // Template for new rows. echo ''; echo '
'; break; case 'flexible_content': // Render nested flexible content. $layouts = isset( $field['layouts'] ) ? $field['layouts'] : array(); $button_label = isset( $field['button_label'] ) ? $field['button_label'] : __( 'Add Row', 'airy-frontend-forms' ); $min = isset( $field['min'] ) ? absint( $field['min'] ) : 0; $max = isset( $field['max'] ) ? absint( $field['max'] ) : 0; if ( empty( $layouts ) ) { echo '

' . esc_html__( 'No layouts configured.', 'airy-frontend-forms' ) . '

'; break; } $flex_value = is_array( $value ) ? $value : array(); echo '
'; echo '
'; if ( ! empty( $flex_value ) ) { foreach ( $flex_value as $index => $row ) { $layout_name = isset( $row['acf_fc_layout'] ) ? $row['acf_fc_layout'] : ''; $layout = $this->get_flexible_layout( $layouts, $layout_name ); if ( $layout ) { $this->render_nested_flexible_row( $field_name, $layout, $row, $index ); } } } echo '
'; // Add layout buttons. echo '
'; echo '
' . esc_html( $button_label ) . '
'; echo '
'; foreach ( $layouts as $layout ) { $layout_label = isset( $layout['label'] ) ? $layout['label'] : $layout['name']; echo ''; } echo '
'; echo '
'; // Layout templates. echo ''; echo '
'; break; case 'link': // Normalize link value. $link_url = ''; $link_title = ''; $link_target = ''; if ( is_array( $value ) ) { $link_url = isset( $value['url'] ) ? $value['url'] : ''; $link_title = isset( $value['title'] ) ? $value['title'] : ''; $link_target = isset( $value['target'] ) ? $value['target'] : ''; } echo ''; break; case 'user': $role = isset( $field['role'] ) ? $field['role'] : ''; $allow_null = isset( $field['allow_null'] ) ? $field['allow_null'] : true; $multiple = isset( $field['multiple'] ) ? $field['multiple'] : false; $user_args = array( 'orderby' => 'display_name', 'order' => 'ASC', ); if ( ! empty( $role ) ) { $user_args['role'] = is_array( $role ) ? '' : $role; if ( is_array( $role ) ) { $user_args['role__in'] = $role; } } $users = get_users( $user_args ); // Normalize selected IDs. $selected_ids = array(); if ( ! empty( $value ) ) { if ( is_array( $value ) ) { foreach ( $value as $item ) { $selected_ids[] = is_numeric( $item ) ? absint( $item ) : ( isset( $item->ID ) ? absint( $item->ID ) : 0 ); } } else { $selected_ids[] = is_numeric( $value ) ? absint( $value ) : 0; } } if ( $multiple ) { echo ''; if ( $allow_null ) { echo ''; } } foreach ( $users as $user ) { $selected = in_array( $user->ID, $selected_ids, true ); echo ''; } echo ''; break; case 'taxonomy': $taxonomy = isset( $field['taxonomy'] ) ? $field['taxonomy'] : 'category'; $field_type = isset( $field['field_type'] ) ? $field['field_type'] : 'select'; $allow_null = isset( $field['allow_null'] ) ? $field['allow_null'] : true; $terms = get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => false, ) ); if ( is_wp_error( $terms ) || empty( $terms ) ) { echo '

' . esc_html__( 'No terms available.', 'airy-frontend-forms' ) . '

'; break; } // Normalize selected IDs. $selected_ids = array(); if ( ! empty( $value ) ) { if ( is_array( $value ) ) { foreach ( $value as $item ) { $selected_ids[] = is_numeric( $item ) ? absint( $item ) : ( isset( $item->term_id ) ? absint( $item->term_id ) : 0 ); } } else { $selected_ids[] = is_numeric( $value ) ? absint( $value ) : 0; } } if ( 'checkbox' === $field_type ) { echo '
'; foreach ( $terms as $term ) { $checked = in_array( $term->term_id, $selected_ids, true ); echo ''; } echo '
'; } elseif ( 'radio' === $field_type ) { echo '
'; foreach ( $terms as $term ) { $checked = in_array( $term->term_id, $selected_ids, true ); echo ''; } echo '
'; } elseif ( 'multi_select' === $field_type ) { echo ''; } else { // Default: select. echo ''; } break; case 'oembed': echo ''; break; case 'post_object': case 'relationship': case 'page_link': $post_type = isset( $field['post_type'] ) ? $field['post_type'] : array( 'post' ); $multiple = isset( $field['multiple'] ) ? $field['multiple'] : ( 'relationship' === $field['type'] ); $allow_null = isset( $field['allow_null'] ) ? $field['allow_null'] : true; $posts = get_posts( array( 'post_type' => $post_type, 'posts_per_page' => 100, 'orderby' => 'title', 'order' => 'ASC', 'post_status' => 'publish', ) ); // Normalize selected IDs. $selected_ids = array(); if ( ! empty( $value ) ) { if ( is_array( $value ) ) { foreach ( $value as $item ) { $selected_ids[] = is_numeric( $item ) ? absint( $item ) : ( isset( $item->ID ) ? absint( $item->ID ) : 0 ); } } else { $selected_ids[] = is_numeric( $value ) ? absint( $value ) : 0; } } if ( $multiple ) { echo ''; if ( $allow_null ) { echo ''; } } foreach ( $posts as $post ) { $selected = in_array( $post->ID, $selected_ids, true ); echo ''; } echo ''; break; case 'message': // Message fields are display-only. $message = isset( $field['message'] ) ? $field['message'] : ''; if ( ! empty( $message ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_kses_post() sanitizes HTML, message is admin-configured ACF field content echo '
' . wp_kses_post( wpautop( $message ) ) . '
'; } break; case 'tab': case 'accordion': // Layout elements - render as visual dividers in sub-fields. $label = isset( $field['label'] ) ? $field['label'] : ''; echo '
'; echo '' . esc_html( $label ) . ''; echo '
'; break; case 'nav_menu': $allow_null = isset( $field['allow_null'] ) ? $field['allow_null'] : true; $menus = wp_get_nav_menus(); if ( empty( $menus ) ) { echo '

' . esc_html__( 'No navigation menus available.', 'airy-frontend-forms' ) . '

'; break; } $selected_id = 0; if ( ! empty( $value ) ) { $selected_id = is_numeric( $value ) ? absint( $value ) : ( isset( $value->term_id ) ? absint( $value->term_id ) : 0 ); } echo ''; break; case 'icon_picker': // Icon picker as text input with icon class. $icon_value = is_array( $value ) && isset( $value['value'] ) ? $value['value'] : ( is_string( $value ) ? $value : '' ); echo '
'; echo ''; if ( ! empty( $icon_value ) ) { echo ''; } echo '
'; break; case 'google_map': // Google Map as address text input (simplified for frontend). $address = ''; $lat = ''; $lng = ''; if ( is_array( $value ) ) { $address = isset( $value['address'] ) ? $value['address'] : ''; $lat = isset( $value['lat'] ) ? $value['lat'] : ''; $lng = isset( $value['lng'] ) ? $value['lng'] : ''; } echo '
'; echo ''; echo ''; echo ''; echo '
'; break; case 'clone': // Clone fields should render the cloned field's sub-fields. $sub_fields = isset( $field['sub_fields'] ) ? $field['sub_fields'] : array(); if ( empty( $sub_fields ) ) { echo '

' . esc_html__( 'No cloned fields configured.', 'airy-frontend-forms' ) . '

'; break; } $clone_value = is_array( $value ) ? $value : array(); echo '
'; foreach ( $sub_fields as $sub_field ) { $clone_name = $field_name . '[' . $sub_field['name'] . ']'; $clone_val = isset( $clone_value[ $sub_field['name'] ] ) ? $clone_value[ $sub_field['name'] ] : ''; $clone_req = ! empty( $sub_field['required'] ); echo '
'; $clone_label = isset( $sub_field['label'] ) ? $sub_field['label'] : $sub_field['name']; echo ''; $this->render_sub_field( $sub_field, $clone_name, $clone_val, $clone_req ); echo '
'; } echo '
'; break; default: /* translators: %s: sub-field type name */ echo '

' . sprintf( esc_html__( 'Sub-field type "%s" not supported in groups/repeaters.', 'airy-frontend-forms' ), esc_html( $field['type'] ) ) . '

'; break; } } /** * Render group field * * @param array $field Field configuration. * @param mixed $value Field value. * @return void */ private function render_group_field( $field, $value ) { // Get sub-fields - try multiple sources. $sub_fields = array(); // First, try from field array (from form configuration). if ( isset( $field['sub_fields'] ) && ! empty( $field['sub_fields'] ) ) { $sub_fields = $field['sub_fields']; } // If empty and ACF is available, get directly from ACF. if ( empty( $sub_fields ) && function_exists( 'acf_get_field' ) ) { $fresh_field = acf_get_field( $field['key'] ); if ( $fresh_field && isset( $fresh_field['sub_fields'] ) ) { $sub_fields = $fresh_field['sub_fields']; } } $value = is_array( $value ) ? $value : array(); if ( empty( $sub_fields ) ) { echo '

'; echo esc_html__( 'No sub-fields found for this group. Please check your field group configuration.', 'airy-frontend-forms' ); echo '

'; return; } // Filter sub-fields based on enabled_sub_fields if set. if ( isset( $field['enabled_sub_fields'] ) && ! empty( $field['enabled_sub_fields'] ) ) { $enabled_keys = $field['enabled_sub_fields']; $sub_fields = array_filter( $sub_fields, function ( $sub_field ) use ( $enabled_keys ) { return in_array( $sub_field['key'], $enabled_keys, true ); } ); } // Check again after filtering. if ( empty( $sub_fields ) ) { echo '

'; echo esc_html__( 'No sub-fields have been selected for this group in the form configuration.', 'airy-frontend-forms' ); echo '

'; return; } // WordPress admin-like group field with border. echo '
'; echo '
'; foreach ( $sub_fields as $sub_field ) { $sub_field_key = $sub_field['key']; $sub_field_name = $field['key'] . '[' . $sub_field_key . ']'; // Try multiple ways to get the value. $sub_field_value = ''; if ( isset( $value[ $sub_field_key ] ) ) { $sub_field_value = $value[ $sub_field_key ]; } elseif ( isset( $value[ $sub_field['name'] ] ) ) { // Sometimes ACF uses name instead of key. $sub_field_value = $value[ $sub_field['name'] ]; } // Normalize image/gallery values (ACF returns arrays/objects, we need IDs). if ( in_array( $sub_field['type'], array( 'image', 'gallery' ), true ) && ! empty( $sub_field_value ) ) { if ( 'image' === $sub_field['type'] ) { // Single image - normalize to attachment ID. if ( is_array( $sub_field_value ) && isset( $sub_field_value['ID'] ) ) { $sub_field_value = absint( $sub_field_value['ID'] ); } elseif ( is_object( $sub_field_value ) && isset( $sub_field_value->ID ) ) { $sub_field_value = absint( $sub_field_value->ID ); } elseif ( is_string( $sub_field_value ) && filter_var( $sub_field_value, FILTER_VALIDATE_URL ) ) { $sub_field_value = attachment_url_to_postid( $sub_field_value ); } elseif ( is_numeric( $sub_field_value ) ) { $sub_field_value = absint( $sub_field_value ); } } elseif ( 'gallery' === $sub_field['type'] ) { // Gallery - normalize array to array of IDs. if ( is_array( $sub_field_value ) ) { $normalized_ids = array(); foreach ( $sub_field_value as $item ) { if ( is_numeric( $item ) ) { $normalized_ids[] = absint( $item ); } elseif ( is_array( $item ) && isset( $item['ID'] ) ) { $normalized_ids[] = absint( $item['ID'] ); } elseif ( is_object( $item ) && isset( $item->ID ) ) { $normalized_ids[] = absint( $item->ID ); } } $sub_field_value = $normalized_ids; } } } // Render sub-field with WordPress admin-like styling. echo '
'; // Label. $label = ! empty( $sub_field['custom_label'] ) ? $sub_field['custom_label'] : $sub_field['label']; $required = ! empty( $sub_field['required'] ); echo ''; // Render based on type. $this->render_sub_field( $sub_field, $sub_field_name, $sub_field_value, $required ); // Help text. if ( ! empty( $sub_field['help_text'] ) ) { echo '

' . esc_html( $sub_field['help_text'] ) . '

'; } echo '
'; } echo '
'; // .affcf-group-inner echo '
'; // .affcf-group-wp } /** * Render relationship field (post_object, relationship, page_link) * * @param array $field Field configuration. * @param mixed $value Current value. * @param bool $required Is required. * @return void */ private function render_relationship_field( $field, $value, $required ) { // Get post types this field can select from. $post_types = isset( $field['post_type'] ) ? (array) $field['post_type'] : array( 'post' ); // Determine if field supports multiple selection. if ( 'relationship' === $field['type'] ) { // Relationship fields: ALWAYS default to multiple=true unless explicitly set to 0. $multiple = isset( $field['multiple'] ) && 0 === $field['multiple'] ? false : true; } else { // For post_object and page_link, only multiple if explicitly enabled. $multiple = ! empty( $field['multiple'] ); } $allow_null = isset( $field['allow_null'] ) ? $field['allow_null'] : true; // Normalize value to array of post IDs. $selected_ids = array(); if ( ! empty( $value ) ) { if ( is_array( $value ) ) { foreach ( $value as $item ) { // Handle different ACF return formats. if ( is_numeric( $item ) ) { // Return format: Post ID. $selected_ids[] = absint( $item ); } elseif ( is_array( $item ) && isset( $item['ID'] ) ) { // Return format: Post Array. $selected_ids[] = absint( $item['ID'] ); } elseif ( is_object( $item ) && isset( $item->ID ) ) { // Return format: Post Object (most common for relationship fields). $selected_ids[] = absint( $item->ID ); } } } elseif ( is_numeric( $value ) ) { // Single ID. $selected_ids[] = absint( $value ); } elseif ( is_object( $value ) && isset( $value->ID ) ) { // Single Post Object. $selected_ids[] = absint( $value->ID ); } elseif ( is_array( $value ) && isset( $value['ID'] ) ) { // Single Post Array. $selected_ids[] = absint( $value['ID'] ); } } // Get available posts. $posts = get_posts( array( 'post_type' => $post_types, 'posts_per_page' => -1, 'post_status' => 'publish', 'orderby' => 'title', 'order' => 'ASC', ) ); // Render based on multiple setting. if ( $multiple ) { // Multiple selection - Use CHECKBOXES for better UX. echo '
'; // Add hidden input to ensure field is submitted even if nothing selected. echo ''; foreach ( $posts as $post ) { $checked = in_array( $post->ID, $selected_ids, true ) ? 'checked' : ''; $checkbox_id = esc_attr( $field['key'] ) . '_' . esc_attr( $post->ID ); echo '
'; echo ''; echo '
'; } echo '
'; // Add validation script for required checkboxes. if ( $required ) { $field_key_js = esc_js( $field['key'] ); $validation_script = "(function() { const checkboxes = document.querySelectorAll('input[name=\"{$field_key_js}[]\"]'); const container = document.querySelector('.affcf-relationship-checkboxes'); function validateCheckboxes() { let checked = false; checkboxes.forEach(function(cb) { if (cb.checked && cb.value !== '') { checked = true; } }); checkboxes.forEach(function(cb) { if (checked) { cb.removeAttribute('required'); } else if (cb.value !== '') { cb.setAttribute('required', 'required'); } }); } checkboxes.forEach(function(cb) { cb.addEventListener('change', validateCheckboxes); }); validateCheckboxes(); })();"; wp_add_inline_script( 'affcf-form-handler', $validation_script ); } } else { // Single select - Use DROPDOWN. echo ''; } } /** * Render ACF taxonomy field * * @param array $field Field configuration. * @param mixed $value Field value. * @param bool $required Whether field is required. * @return void */ private function render_taxonomy_field( $field, $value, $required ) { $taxonomy = isset( $field['taxonomy'] ) ? $field['taxonomy'] : 'category'; $field_type = isset( $field['field_type'] ) ? $field['field_type'] : 'checkbox'; $allow_null = isset( $field['allow_null'] ) ? $field['allow_null'] : true; $add_term = isset( $field['add_term'] ) ? $field['add_term'] : false; $load_terms = isset( $field['load_terms'] ) ? $field['load_terms'] : false; $save_terms = isset( $field['save_terms'] ) ? $field['save_terms'] : false; $return_format = isset( $field['return_format'] ) ? $field['return_format'] : 'id'; // Get terms. $terms = get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => false, ) ); if ( is_wp_error( $terms ) || empty( $terms ) ) { echo '

' . esc_html__( 'No terms available.', 'airy-frontend-forms' ) . '

'; return; } // Normalize value to array of IDs. $selected_ids = array(); if ( ! empty( $value ) ) { if ( is_array( $value ) ) { foreach ( $value as $item ) { if ( is_numeric( $item ) ) { $selected_ids[] = absint( $item ); } elseif ( is_object( $item ) && isset( $item->term_id ) ) { $selected_ids[] = absint( $item->term_id ); } elseif ( is_array( $item ) && isset( $item['term_id'] ) ) { $selected_ids[] = absint( $item['term_id'] ); } } } elseif ( is_numeric( $value ) ) { $selected_ids[] = absint( $value ); } elseif ( is_object( $value ) && isset( $value->term_id ) ) { $selected_ids[] = absint( $value->term_id ); } } // Render based on field type. switch ( $field_type ) { case 'checkbox': echo '
'; foreach ( $terms as $term ) { $checked = in_array( $term->term_id, $selected_ids, true ); echo '
'; echo ''; echo '
'; } echo '
'; break; case 'radio': echo '
'; foreach ( $terms as $term ) { $checked = in_array( $term->term_id, $selected_ids, true ); echo '
'; echo ''; echo '
'; } echo '
'; break; case 'select': echo ''; break; case 'multi_select': echo ''; break; default: // Default to checkbox. echo '
'; foreach ( $terms as $term ) { $checked = in_array( $term->term_id, $selected_ids, true ); echo '
'; echo ''; echo '
'; } echo '
'; break; } } /** * Render link field * * @param array $field Field configuration. * @param mixed $value Field value. * @param bool $required Whether field is required. * @return void */ private function render_link_field( $field, $value, $required ) { // Normalize value. $link_url = ''; $link_title = ''; $link_target = ''; if ( is_array( $value ) ) { $link_url = isset( $value['url'] ) ? $value['url'] : ''; $link_title = isset( $value['title'] ) ? $value['title'] : ''; $link_target = isset( $value['target'] ) ? $value['target'] : ''; } echo ''; } /** * Render user field * * @param array $field Field configuration. * @param mixed $value Field value. * @param bool $required Whether field is required. * @return void */ private function render_user_field( $field, $value, $required ) { $role = isset( $field['role'] ) ? $field['role'] : ''; $allow_null = isset( $field['allow_null'] ) ? $field['allow_null'] : true; $multiple = isset( $field['multiple'] ) ? $field['multiple'] : false; // Build user query args. $user_args = array( 'orderby' => 'display_name', 'order' => 'ASC', ); if ( ! empty( $role ) ) { if ( is_array( $role ) ) { $user_args['role__in'] = $role; } else { $user_args['role'] = $role; } } $users = get_users( $user_args ); // Normalize value to array of user IDs. $selected_ids = array(); if ( ! empty( $value ) ) { if ( is_array( $value ) ) { foreach ( $value as $item ) { if ( is_numeric( $item ) ) { $selected_ids[] = absint( $item ); } elseif ( is_object( $item ) && isset( $item->ID ) ) { $selected_ids[] = absint( $item->ID ); } elseif ( is_array( $item ) && isset( $item['ID'] ) ) { $selected_ids[] = absint( $item['ID'] ); } } } elseif ( is_numeric( $value ) ) { $selected_ids[] = absint( $value ); } elseif ( is_object( $value ) && isset( $value->ID ) ) { $selected_ids[] = absint( $value->ID ); } } if ( $multiple ) { echo ''; if ( $allow_null ) { echo ''; } } foreach ( $users as $user ) { $selected = in_array( $user->ID, $selected_ids, true ); echo ''; } echo ''; } /** * Render oEmbed field * * @param array $field Field configuration. * @param mixed $value Field value. * @param string $placeholder Placeholder text. * @param bool $required Whether field is required. * @return void */ private function render_oembed_field( $field, $value, $placeholder, $required ) { $default_placeholder = __( 'Enter URL', 'airy-frontend-forms' ); // WordPress admin-like oEmbed field. echo '
'; // URL input wrapper. echo '
'; echo ''; echo '
'; // Preview area. echo '
'; if ( ! empty( $value ) ) { $embed = wp_oembed_get( $value ); if ( $embed ) { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_oembed_get returns safe HTML from trusted oEmbed providers echo $embed; } else { echo '
'; echo ''; echo '
'; } } else { echo '
'; echo ''; echo '
'; } echo '
'; echo '
'; } /** * Render message field (display only) * * @param array $field Field configuration. * @return void */ private function render_message_field( $field ) { $message = isset( $field['message'] ) ? $field['message'] : ''; $new_lines = isset( $field['new_lines'] ) ? $field['new_lines'] : 'wpautop'; $esc_html = isset( $field['esc_html'] ) ? $field['esc_html'] : false; if ( empty( $message ) ) { return; } echo '
'; if ( $esc_html ) { $message = esc_html( $message ); } // Handle new lines. if ( 'wpautop' === $new_lines ) { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Content is conditionally escaped above based on field settings echo wp_kses_post( wpautop( $message ) ); } elseif ( 'br' === $new_lines ) { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Content is conditionally escaped above based on field settings echo wp_kses_post( nl2br( $message ) ); } else { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Content is conditionally escaped above based on field settings echo wp_kses_post( $message ); } echo '
'; } /** * Render tab field (UI layout element) * * @param array $field Field configuration. * @return void */ private function render_tab_field( $field ) { $label = isset( $field['label'] ) ? $field['label'] : ''; $placement = isset( $field['placement'] ) ? $field['placement'] : 'top'; echo '
'; echo '
' . esc_html( $label ) . '
'; echo '
'; } /** * Render accordion field (UI layout element) * * @param array $field Field configuration. * @return void */ private function render_accordion_field( $field ) { $label = isset( $field['label'] ) ? $field['label'] : ''; $open = isset( $field['open'] ) ? $field['open'] : false; $endpoint = isset( $field['endpoint'] ) ? $field['endpoint'] : false; if ( $endpoint ) { // End of accordion section. echo ''; } else { // Start of accordion section. $open_class = $open ? ' affcf-accordion-open' : ''; echo '
'; echo '
'; echo '' . esc_html( $label ) . ''; echo '▼'; echo '
'; echo ''; } /** * Render taxonomy as checkboxes * * @param string $taxonomy Taxonomy name. * @param array $terms Array of term objects. * @param array $selected_terms Array of selected term IDs. * @return void */ private function render_taxonomy_checkboxes( $taxonomy, $terms, $selected_terms ) { echo '
'; foreach ( $terms as $term ) { $checked = in_array( $term->term_id, $selected_terms, true ); echo ''; } echo '
'; } /** * Render taxonomy as radio buttons * * @param string $taxonomy Taxonomy name. * @param array $terms Array of term objects. * @param array $selected_terms Array of selected term IDs. * @param bool $required Whether field is required. * @return void */ private function render_taxonomy_radios( $taxonomy, $terms, $selected_terms, $required ) { $selected = ! empty( $selected_terms ) ? $selected_terms[0] : ''; echo '
'; foreach ( $terms as $term ) { $checked = ( (int) $selected === (int) $term->term_id ); echo ''; } echo '
'; } /** * Render taxonomy as dropdown * * @param string $taxonomy Taxonomy name. * @param array $terms Array of term objects. * @param array $selected_terms Array of selected term IDs. * @param bool $required Whether field is required. * @return void */ private function render_taxonomy_dropdown( $taxonomy, $terms, $selected_terms, $required ) { echo ''; } /** * Render submit button * * @return void */ private function render_submit_button() { $button_text = $this->is_edit ? __( 'Update', 'airy-frontend-forms' ) : __( 'Submit', 'airy-frontend-forms' ); echo '
'; echo ''; // Render reCAPTCHA badge if enabled. AFFCF_Captcha::render_badge(); echo '
'; } /** * Display success/error messages * * @return void */ private function display_messages() { $user_id = get_current_user_id(); // Check for success message. $success_message = get_transient( 'affcf_submission_success_' . $user_id ); if ( $success_message ) { echo '
'; echo '

' . esc_html( $success_message ) . '

'; echo '
'; delete_transient( 'affcf_submission_success_' . $user_id ); delete_transient( 'affcf_submission_post_id_' . $user_id ); } // Check for error message. $error_message = get_transient( 'affcf_submission_error_' . $user_id ); if ( $error_message ) { echo '
'; echo '

' . wp_kses_post( $error_message ) . '

'; echo '
'; delete_transient( 'affcf_submission_error_' . $user_id ); } } }