PluginProbe ʕ •ᴥ•ʔ
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI / 3.5.3
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI v3.5.3
3.6.0 3.5.3 3.5.2 3.5.1 3.5.0 3.4.8 3.4.7 3.4.6 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.5.1 1.1.6 1.1.7 1.1.8 1.1.9 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.5.0 1.5.1 1.5.10 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.6.1 1.6.7 1.7.0 1.7.0.1 1.7.0.2 1.7.0.3 1.7.1 1.7.2 1.7.2.1 1.7.2.2 1.7.3 1.7.4 1.7.5 1.7.5.1 1.7.5.2 1.7.6 1.7.7 1.7.7.1 1.7.7.2 1.7.8 1.7.9 1.8.0 1.8.0.1 1.8.1 1.8.2 1.8.2.1 1.8.2.2 1.8.2.3 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 1.8.9 1.9.0 1.9.0.1 1.9.1 1.9.2 1.9.3 1.9.4 1.9.4.1 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.0.1 2.0.1 2.0.2 2.0.3 2.0.3.1 2.0.4 2.0.4.1 2.0.5 2.0.6 2.0.7 2.0.8 2.0.8.1 2.0.9 3.0.0 3.0.0.1 3.0.1 3.0.2 3.0.3 3.0.3.1 3.0.4 3.0.4.1 3.0.4.2 3.0.5 3.0.5.1 3.0.5.2 3.0.6 3.0.6.1 3.0.7.1 3.0.8 3.0.8.1 3.0.9 3.0.9.1 3.0.9.2 3.0.9.3 3.0.9.4 3.0.9.5 3.1.0 3.1.1 3.1.2 3.2.0 3.2.1 3.2.2 3.2.3 3.2.4 3.2.5 3.2.6 3.3.0 3.4.0 3.4.1 3.4.2 3.4.2.1 3.4.3 3.4.4 3.4.5 trunk 1.0 1.0.1 1.0.2 1.0.3
everest-forms / includes / Integrations / AI / class-evf-ai-form-builder.php
everest-forms / includes / Integrations / AI Last commit date
class-evf-ai-ajax.php 1 month ago class-evf-ai-api.php 1 month ago class-evf-ai-form-builder.php 1 month ago class-evf-ai-loader.php 1 month ago class-evf-ai-registration.php 1 week ago
class-evf-ai-form-builder.php
962 lines
1 <?php
2 /**
3 * EVF AI Form Builder — transforms gateway AI response into full EVF form structure
4 * and inserts it as a WordPress post.
5 *
6 * Gateway returns a clean intermediate format (type, label, options, etc.).
7 * This class handles ALL EVF-specific boilerplate so the gateway stays simple.
8 */
9
10 defined( 'ABSPATH' ) || exit;
11
12 class EVF_AI_Form_Builder {
13
14 /** Pro-only field types — visible in builder but locked for free users. */
15 public static $pro_fields = [
16 'password', 'color', 'range-slider', 'signature', 'repeater-fields',
17 'lookup', 'progress',
18 'payment-single', 'payment-checkbox', 'payment-multiple',
19 'payment-quantity', 'payment-subtotal', 'payment-total',
20 'payment-coupon', 'credit-card', 'payment-square',
21 'payment-authorize-net', 'payment-subscription-plan',
22 'payment-gateway-selector',
23 ];
24
25 /** Set to true when a file-upload field was dropped because the free-tier limit (1) was reached. */
26 public static $file_upload_limited = false;
27
28 /**
29 * Create a new EVF form from the AI gateway response.
30 * Saved as DRAFT — user must click "Use This Form" to publish.
31 *
32 * @param array $ai_response Decoded JSON from /evf-ai/v1/generate
33 * @return int|WP_Error New form post ID on success, WP_Error on failure.
34 */
35 public static function create_form( array $ai_response ) {
36 $title = sanitize_text_field( $ai_response['form_title'] ?? __( 'AI Generated Form', 'everest-forms' ) );
37 $fields = $ai_response['fields'] ?? [];
38
39 remove_all_filters( 'content_save_pre' );
40
41 // Saved as DRAFT — becomes active only when user clicks "Use This Form"
42 $post_id = wp_insert_post( [
43 'post_title' => $title,
44 'post_type' => 'everest_form',
45 'post_status' => 'draft',
46 'post_content' => '{}',
47 ] );
48
49 if ( is_wp_error( $post_id ) ) {
50 return $post_id;
51 }
52
53 $form_data = self::build_form_data( $post_id, $ai_response );
54
55 wp_update_post( [
56 'ID' => $post_id,
57 'post_content' => evf_encode( $form_data ),
58 ] );
59
60 return $post_id;
61 }
62
63 /**
64 * Rebuild an existing (draft) AI form in place from a refined AI response.
65 * Keeps the same form id/status so the preview and builder stay in sync.
66 *
67 * @param int $form_id Existing draft form id.
68 * @param array $ai_response Refined AI form schema.
69 * @return int|WP_Error The form id on success.
70 */
71 public static function update_form( int $form_id, array $ai_response ) {
72 $post = get_post( $form_id );
73 if ( ! $post || 'everest_form' !== $post->post_type ) {
74 return new WP_Error( 'invalid_form', __( 'Form not found.', 'everest-forms' ) );
75 }
76
77 remove_all_filters( 'content_save_pre' );
78
79 // Preserve per-field settings (e.g. label_hide) that were applied by a prior AI
80 // request but may be absent from this AI response. Merge before building form data.
81 $existing_data = evf_decode( $post->post_content );
82 $existing_index = self::index_fields_by_label_type( $existing_data['form_fields'] ?? array() );
83 $ai_response = self::merge_field_settings( $ai_response, $existing_index );
84
85 $title = sanitize_text_field( $ai_response['form_title'] ?? get_the_title( $form_id ) );
86 $form_data = self::build_form_data( $form_id, $ai_response );
87
88 wp_update_post( [
89 'ID' => $form_id,
90 'post_title' => $title,
91 'post_content' => evf_encode( $form_data ),
92 ] );
93
94 return $form_id;
95 }
96
97 /**
98 * Publish a draft AI form — called when user clicks "Use This Form".
99 *
100 * @param int $form_id
101 * @return bool
102 */
103 public static function activate_form( int $form_id ): bool {
104 $post = get_post( $form_id );
105 if ( ! $post || 'everest_form' !== $post->post_type ) {
106 return false;
107 }
108
109 return (bool) wp_update_post( [
110 'ID' => $form_id,
111 'post_status' => 'publish',
112 ] );
113 }
114
115 /**
116 * Return summary of fields in a form for the preview modal.
117 *
118 * @param int $form_id
119 * @return array [ [ label, type, is_pro ], ... ]
120 */
121 public static function get_field_summary( int $form_id ): array {
122 $post = get_post( $form_id );
123 if ( ! $post ) {
124 return [];
125 }
126
127 $data = evf_decode( $post->post_content );
128 $fields = $data['form_fields'] ?? [];
129 $summary = [];
130
131 foreach ( $fields as $field ) {
132 $type = $field['type'] ?? '';
133 if ( in_array( $type, [ 'html', 'title', 'divider', 'hidden' ], true ) ) {
134 continue; // skip non-input fields from preview list
135 }
136 $summary[] = [
137 'label' => $field['label'] ?? ucfirst( $type ),
138 'type' => $type,
139 'is_pro' => in_array( $type, self::$pro_fields, true ),
140 ];
141 }
142
143 return $summary;
144 }
145
146 // ── Form data builder ─────────────────────────────────────────────────────
147
148 /**
149 * Field types that are narrow enough to share a row (2-column layout).
150 * Everything else gets a full-width row.
151 */
152 private static $narrow_types = [
153 'text', 'first-name', 'last-name', 'email', 'phone',
154 'number', 'url', 'date-time', 'select', 'country',
155 'hidden', 'yes-no', 'rating', 'color', 'range-slider',
156 ];
157
158 /**
159 * Forced pairs — if the current field type is a key and the NEXT field
160 * type is the value, always put them on the same row regardless of position.
161 */
162 private static $forced_pairs = [
163 'first-name' => 'last-name',
164 'last-name' => 'first-name',
165 ];
166
167 private static function build_form_data( int $form_id, array $ai ): array {
168 self::$file_upload_limited = false;
169 $built_fields = [];
170 $email_field_id = null;
171
172 // Pre-compute which step each field index belongs to (for multipart pairing)
173 $field_step_map = [];
174 foreach ( ( $ai['multipart_steps'] ?? [] ) as $step_idx => $step ) {
175 foreach ( ( $step['field_indices'] ?? [] ) as $fi ) {
176 $field_step_map[ $fi ] = $step_idx;
177 }
178 }
179
180 // Promote explicit recaptcha/hcaptcha/turnstile field requests to the form-level
181 // recaptcha_support setting. The AI sometimes returns these as a field type rather
182 // than setting enable_recaptcha, so we detect and convert them here.
183 foreach ( ( $ai['fields'] ?? [] ) as $ai_field ) {
184 $t = strtolower( sanitize_key( $ai_field['type'] ?? '' ) );
185 $l = strtolower( $ai_field['label'] ?? '' );
186 if ( in_array( $t, [ 'recaptcha', 'hcaptcha', 'turnstile' ], true )
187 || false !== strpos( $l, 'recaptcha' )
188 || false !== strpos( $l, 'hcaptcha' )
189 || false !== strpos( $l, 'turnstile' ) ) {
190 $ai['enable_recaptcha'] = true;
191 break;
192 }
193 }
194
195 // Build all field objects first so we can look ahead for smart pairing
196 $field_list = [];
197 $field_index = 0;
198 $file_upload_count = 0;
199 $is_pro_active = defined( 'EVF_PRO_VERSION' ) || class_exists( 'EVF_Pro' );
200 foreach ( ( $ai['fields'] ?? [] ) as $ai_field ) {
201 // Free tier: only one file-upload field is allowed per form.
202 if ( ! $is_pro_active && 'file-upload' === ( $ai_field['type'] ?? '' ) ) {
203 if ( $file_upload_count >= 1 ) {
204 self::$file_upload_limited = true;
205 $field_index++;
206 continue;
207 }
208 $file_upload_count++;
209 }
210
211 $field_id = self::generate_field_id();
212 $evf_field = self::build_field( $field_id, $ai_field );
213 if ( ! $evf_field ) {
214 $field_index++;
215 continue;
216 }
217 $built_fields[ $field_id ] = $evf_field;
218 if ( 'email' === $evf_field['type'] && null === $email_field_id ) {
219 $email_field_id = $field_id;
220 }
221 $field_list[] = [
222 'id' => $field_id,
223 'type' => $evf_field['type'],
224 'width' => sanitize_key( $ai_field['width'] ?? '' ), // 'full'|'half'|''
225 'step' => $field_step_map[ $field_index ] ?? -1, // -1 = no multipart
226 ];
227 $field_index++;
228 }
229
230 // Ensure every field has a unique meta-key within this form.
231 $used_keys = [];
232 foreach ( $built_fields as &$field ) {
233 if ( ! isset( $field['meta-key'] ) ) {
234 continue;
235 }
236 $base = $field['meta-key'];
237 if ( ! in_array( $base, $used_keys, true ) ) {
238 $used_keys[] = $base;
239 continue;
240 }
241 $n = 2;
242 while ( in_array( $base . '_' . $n, $used_keys, true ) ) {
243 $n++;
244 }
245 $field['meta-key'] = $base . '_' . $n;
246 $used_keys[] = $field['meta-key'];
247 }
248 unset( $field );
249
250 $structure = self::build_structure( $field_list );
251 $form_data = [
252 'id' => $form_id,
253 'form_field_id' => (string) count( $built_fields ),
254 'form_enabled' => '1',
255 'form_fields' => $built_fields,
256 'settings' => self::build_settings( $ai, $email_field_id ),
257 'structure' => $structure,
258 ];
259
260 // Multipart — inject step data mapping field indices → row IDs from structure
261 if ( 'multipart' === ( $ai['form_type'] ?? '' ) && ! empty( $ai['multipart_steps'] ) ) {
262 $form_data['multi_part'] = self::build_multipart_data(
263 $ai['multipart_steps'],
264 array_keys( $built_fields ),
265 $structure
266 );
267 }
268
269 return $form_data;
270 }
271
272 /**
273 * Build the structure object with smart 2-column grouping.
274 *
275 * Rules (in priority order):
276 * 1. first-name + last-name → always same row
277 * 2. Two consecutive narrow fields → same row
278 * 3. Everything else (textarea, address, file-upload, etc.) → full-width row
279 */
280 private static function build_structure( array $field_list ): array {
281 $structure = [];
282 $row = 1;
283 $i = 0;
284 $total = count( $field_list );
285
286 while ( $i < $total ) {
287 $current = $field_list[ $i ];
288 $next = $field_list[ $i + 1 ] ?? null;
289
290 // Explicit AI width override takes priority over auto-pairing logic
291 $current_width = $current['width'] ?? '';
292 $next_width = $next['width'] ?? '';
293 $force_full = 'full' === $current_width;
294 $force_half = 'half' === $current_width && $next && 'half' === $next_width;
295
296 $is_narrow = in_array( $current['type'], self::$narrow_types, true );
297 $next_is_narrow = $next && in_array( $next['type'], self::$narrow_types, true );
298
299 // Forced pair (first-name ↔ last-name) by type
300 $forced_next_type = self::$forced_pairs[ $current['type'] ] ?? null;
301 $is_forced_pair = $forced_next_type && $next && $next['type'] === $forced_next_type;
302
303 // Never pair fields from different multipart steps (would share a row across parts)
304 $same_step = ( -1 === ( $current['step'] ?? -1 ) )
305 || ! isset( $next['step'] )
306 || $current['step'] === $next['step'];
307
308 // Two-column: explicit half+half OR auto-pair (unless force_full or cross-step)
309 if ( ! $force_full && $same_step && ( $force_half || $is_forced_pair || ( $is_narrow && $next_is_narrow ) ) ) {
310 // Two columns
311 $structure[ 'row_' . $row ] = [
312 'grid_1' => [ $current['id'] ],
313 'grid_2' => [ $next['id'] ],
314 ];
315 $i += 2;
316 } else {
317 // Full width
318 $structure[ 'row_' . $row ] = [
319 'grid_1' => [ $current['id'] ],
320 ];
321 $i++;
322 }
323
324 $row++;
325 }
326
327 return $structure;
328 }
329
330 // ── Field builder ─────────────────────────────────────────────────────────
331
332 private static function build_field( string $field_id, array $ai_field ): ?array {
333 $type = sanitize_key( $ai_field['type'] ?? '' );
334 $label = sanitize_text_field( $ai_field['label'] ?? ucfirst( $type ) );
335
336 if ( ! $type ) {
337 return null;
338 }
339
340 // Normalize: gateway may return `html` with reset-button HTML when user
341 // asks for a reset button. Convert to the proper EVF `reset` type and
342 // clear the raw description so it doesn't leak as display text.
343 if ( 'html' === $type ) {
344 $raw_desc = $ai_field['description'] ?? '';
345 if ( preg_match( '/<button[^>]+type=["\']?reset["\']?/i', $raw_desc ) ) {
346 $type = 'reset';
347 $label = $label ?: __( 'Reset', 'everest-forms' );
348 $ai_field['description'] = '';
349 }
350 }
351
352 // reCAPTCHA, hCaptcha, and Turnstile are form-level settings, not draggable
353 // fields. build_form_data() already promoted the request to enable_recaptcha
354 // via its pre-scan; skip field creation here.
355 $lc_label = strtolower( $label );
356 if ( in_array( $type, [ 'recaptcha', 'hcaptcha', 'turnstile' ], true )
357 || false !== strpos( $lc_label, 'recaptcha' )
358 || false !== strpos( $lc_label, 'hcaptcha' )
359 || false !== strpos( $lc_label, 'turnstile' ) ) {
360 return null;
361 }
362
363 // Normalize: gateway may return `text` (or a non-existent `math` type)
364 // for math-captcha requests — convert to the proper EVF `captcha` type.
365 if ( 'captcha' !== $type ) {
366 $combined = strtolower( $label . ' ' . ( $ai_field['description'] ?? '' ) );
367 if ( 'math' === $type || false !== strpos( $combined, 'captcha' ) ) {
368 $type = 'captcha';
369 $label = $label ?: __( 'Math Captcha', 'everest-forms' );
370 }
371 }
372
373 // Normalize: EVF's payment-radio field is registered internally as
374 // `payment-multiple`. Map the intuitive gateway name to the real type.
375 if ( 'payment-radio' === $type ) {
376 $type = 'payment-multiple';
377 }
378
379 // Normalize: `credit-card` (and the per-gateway square/authorize-net fields)
380 // are legacy types now unified into the single Payment Gateway field. The
381 // gateway prompt no longer emits them, but remap defensively so any cached
382 // or older AI response still produces the modern, frontend-rendering field.
383 if ( in_array( $type, array( 'credit-card', 'payment-square', 'payment-authorize-net' ), true ) ) {
384 $type = 'payment-gateway-selector';
385 $label = $label ?: __( 'Payment Gateway', 'everest-forms' );
386 }
387
388 // Normalize: the repeater field is registered as `repeater-fields` internally.
389 if ( 'repeater' === $type ) {
390 $type = 'repeater-fields';
391 }
392
393 // Base keys every field has
394 $field = [
395 'id' => $field_id,
396 'type' => $type,
397 'label' => $label,
398 'meta-key' => self::generate_meta_key( $label, $field_id ),
399 'required' => ! empty( $ai_field['required'] ) ? '1' : '',
400 'required_field_message_setting' => 'global',
401 'required-field-message' => '',
402 'label_hide' => ! empty( $ai_field['label_hide'] ) ? '1' : '0',
403 'description' => sanitize_text_field( $ai_field['description'] ?? '' ),
404 'css' => sanitize_text_field( $ai_field['css'] ?? '' ),
405 ];
406
407 // Placeholder (not all field types use it)
408 if ( ! empty( $ai_field['placeholder'] ) ) {
409 $field['placeholder'] = sanitize_text_field( $ai_field['placeholder'] );
410 }
411
412 // Type-specific additions
413 switch ( $type ) {
414 case 'text':
415 case 'first-name':
416 case 'last-name':
417 case 'url':
418 case 'number':
419 $field['default_value'] = sanitize_text_field( $ai_field['default_value'] ?? '' );
420 $field['limit_enabled'] = '0';
421 $field['limit_count'] = '100';
422 $field['limit_mode'] = 'characters';
423 $field['min_length_enabled']= '0';
424 $field['min_length_count'] = '1';
425 $field['min_length_mode'] = 'characters';
426 $field['input_mask'] = '';
427 break;
428
429 case 'textarea':
430 $field['default_value'] = sanitize_textarea_field( $ai_field['default_value'] ?? '' );
431 $field['limit_enabled'] = '0';
432 $field['limit_count'] = '500';
433 $field['limit_mode'] = 'characters';
434 break;
435
436 case 'email':
437 $field['default_value'] = sanitize_text_field( $ai_field['default_value'] ?? '' );
438 $field['confirmation_placeholder'] = '';
439 $field['sublabel_hide'] = ! empty( $ai_field['sublabel_hide'] ) ? '1' : '';
440 break;
441
442 case 'phone':
443 $field['default_value'] = sanitize_text_field( $ai_field['default_value'] ?? '' );
444 $field['phone_format'] = 'smart';
445 $field['input_mask'] = '';
446 break;
447
448 case 'date-time':
449 $field['datetime_format'] = 'mm/dd/yyyy';
450 $field['datetime_style'] = 'picker';
451 $field['date_format'] = 'mm/dd/yyyy';
452 $field['date_localization']= 'en';
453 $field['date_mode'] = 'single';
454 $field['time_format'] = '12';
455 $field['time_interval'] = '1';
456 break;
457
458 case 'checkbox':
459 case 'radio':
460 $field['choices'] = self::build_choices( $ai_field['options'] ?? [ 'Option 1', 'Option 2' ] );
461 $field['input_columns'] = '';
462 $field['randomize'] = '0';
463 $field['show_values'] = '0';
464 $field['choices_images']= '0';
465 if ( 'checkbox' === $type ) {
466 $field['select_all'] = '0';
467 $field['choice_limit']= '';
468 }
469 break;
470
471 case 'select':
472 $field['choices'] = self::build_choices( $ai_field['options'] ?? [ 'Option 1', 'Option 2' ] );
473 $field['placeholder'] = $ai_field['placeholder'] ?? __( 'Select an option', 'everest-forms' );
474 $field['enhanced_select'] = '0';
475 $field['multiple_choices']= '0';
476 $field['show_values'] = '0';
477 break;
478
479 case 'address':
480 $field += self::address_sublabels();
481 if ( ! empty( $ai_field['sublabel_hide'] ) ) {
482 $field['sublabel_hide'] = '1';
483 }
484 break;
485
486 case 'file-upload':
487 $field['extensions'] = 'jpg,jpeg,png,gif,pdf,doc,docx,xls,xlsx';
488 $field['max_size'] = '10';
489 $field['max_file_number'] = '1';
490 $field['upload_message'] = __( 'Drop files here or click to upload', 'everest-forms' );
491 $field['media_library'] = '0';
492 break;
493
494 case 'image-upload':
495 $field['extensions'] = 'jpg,jpeg,png,gif';
496 $field['max_size'] = '5';
497 $field['max_file_number'] = '1';
498 break;
499
500 case 'rating':
501 $field['number_of_stars'] = '5';
502 $field['icon'] = 'star';
503 $field['icon_size'] = 'medium';
504 $field['icon_color'] = '#f4b942';
505 break;
506
507 case 'hidden':
508 $field['default_value'] = sanitize_text_field( $ai_field['default_value'] ?? '' );
509 break;
510
511 case 'html':
512 case 'title':
513 case 'divider':
514 unset( $field['meta-key'], $field['required'], $field['required_field_message_setting'], $field['required-field-message'] );
515 break;
516
517 case 'privacy-policy':
518 $field['choices'] = self::build_choices( [ __( 'I agree to the privacy policy', 'everest-forms' ) ] );
519 $field['show_values']= '0';
520 break;
521
522 case 'payment-multiple':
523 $field['choices'] = self::build_payment_choices( $ai_field['options'] ?? [] );
524 $field['input_columns'] = '';
525 $field['choices_images'] = '0';
526 break;
527
528 case 'payment-checkbox':
529 $field['choices'] = self::build_payment_choices( $ai_field['options'] ?? [] );
530 $field['input_columns'] = '';
531 $field['choices_images'] = '0';
532 $field['select_all'] = '0';
533 break;
534
535 case 'repeater-fields':
536 $field['repeater_field_hide'] = '0';
537 $field['repeater_repeat_limit'] = '';
538 $field['repeater_button_add_new_label']= 'Add';
539 $field['repeater_button_remove_label'] = 'Remove';
540 break;
541
542 case 'captcha':
543 $field['format'] = 'math';
544 $field['required'] = '1';
545 break;
546
547 case 'reset':
548 $field['button_text'] = $label ?: __( 'Reset', 'everest-forms' );
549 break;
550 }
551
552 return $field;
553 }
554
555 // ── Choices ───────────────────────────────────────────────────────────────
556
557 private static function build_choices( array $options ): array {
558 $choices = [];
559 foreach ( $options as $i => $opt ) {
560 $label = sanitize_text_field( is_array( $opt ) ? ( $opt['label'] ?? '' ) : $opt );
561 $choices[] = [
562 'label' => $label,
563 'value' => $label,
564 'image' => '',
565 'default' => '',
566 ];
567 }
568 return $choices;
569 }
570
571 /**
572 * Build choices for payment-multiple / payment-checkbox fields.
573 * Value must be a numeric price string (e.g. "10.00"), not the label text.
574 * If the gateway supplies a price in the option (array with 'value', or a
575 * string like "VIP - $50"), extract it; otherwise assign ascending defaults.
576 */
577 private static function build_payment_choices( array $options ): array {
578 $defaults = [ '10.00', '20.00', '30.00', '40.00', '50.00' ];
579 $choices = [];
580
581 foreach ( $options as $i => $opt ) {
582 if ( is_array( $opt ) ) {
583 $label = sanitize_text_field( $opt['label'] ?? '' );
584 $price = self::extract_price( $opt['value'] ?? '' ) ?? $defaults[ $i ] ?? '10.00';
585 } else {
586 $raw = sanitize_text_field( $opt );
587 // Try to pull a dollar/numeric price out of strings like "VIP – $50" or "General ($20.00)"
588 $price = self::extract_price( $raw ) ?? $defaults[ $i ] ?? '10.00';
589 // Strip the price annotation from the label so it doesn't duplicate.
590 $label = trim( preg_replace( '/[\(\-–—]*\s*\$[\d,]+(\.\d{1,2})?\s*[\)]*/', '', $raw ) );
591 $label = $label ?: $raw;
592 }
593
594 $choices[] = [
595 'label' => $label,
596 'value' => $price,
597 'image' => '',
598 'default' => '',
599 ];
600 }
601
602 // If no options came from the gateway, use the field's own defaults.
603 if ( empty( $choices ) ) {
604 foreach ( [ 'Option 1', 'Option 2', 'Option 3' ] as $i => $lbl ) {
605 $choices[] = [
606 'label' => $lbl,
607 'value' => $defaults[ $i ],
608 'image' => '',
609 'default' => '',
610 ];
611 }
612 }
613
614 return $choices;
615 }
616
617 /** Extract a numeric price string from a value or annotated label. Returns null on failure. */
618 private static function extract_price( string $raw ): ?string {
619 // Accept plain numbers ("25", "25.00") or dollar-prefixed ("$25", "$25.00")
620 if ( preg_match( '/\$?([\d,]+(?:\.\d{1,2})?)/', $raw, $m ) ) {
621 $num = (float) str_replace( ',', '', $m[1] );
622 if ( $num > 0 ) {
623 return number_format( $num, 2, '.', '' );
624 }
625 }
626 return null;
627 }
628
629 // ── Address sublabels ─────────────────────────────────────────────────────
630
631 private static function address_sublabels(): array {
632 return [
633 'sublabel_hide' => '0',
634 'address1_label' => __( 'Address Line 1', 'everest-forms' ),
635 'address1_placeholder'=> '',
636 'address1_default' => '',
637 'address1_hide' => '0',
638 'address2_label' => __( 'Address Line 2', 'everest-forms' ),
639 'address2_placeholder'=> '',
640 'address2_default' => '',
641 'address2_hide' => '0',
642 'city_label' => __( 'City', 'everest-forms' ),
643 'city_placeholder' => '',
644 'city_default' => '',
645 'city_hide' => '0',
646 'state_label' => __( 'State / Province', 'everest-forms' ),
647 'state_placeholder' => '',
648 'state_default' => '',
649 'state_hide' => '0',
650 'postal_label' => __( 'Zip / Postal Code', 'everest-forms' ),
651 'postal_placeholder' => '',
652 'postal_default' => '',
653 'postal_hide' => '0',
654 'country_label' => __( 'Country', 'everest-forms' ),
655 'country_placeholder' => '',
656 'country_default' => '',
657 'country_hide' => '0',
658 ];
659 }
660
661 // ── Settings ──────────────────────────────────────────────────────────────
662
663 private static function build_settings( array $ai, ?string $email_field_id ): array {
664 $reply_to = $email_field_id
665 ? '{field_id="' . $email_field_id . '"}'
666 : '{admin_email}';
667
668 $is_multipart = 'multipart' === ( $ai['form_type'] ?? '' );
669 $is_conversational = 'conversational' === ( $ai['form_type'] ?? '' );
670
671 $settings = [
672 'form_title' => sanitize_text_field( $ai['form_title'] ?? '' ),
673 'form_desc' => sanitize_text_field( $ai['form_desc'] ?? '' ),
674 'submit_button_text' => sanitize_text_field( $ai['submit_button_text'] ?? __( 'Submit', 'everest-forms' ) ),
675 'submit_button_processing_text' => __( 'Processing...', 'everest-forms' ),
676 'successful_form_submission_message' => sanitize_text_field( $ai['success_message'] ?? __( 'Thanks for contacting us! We will be in touch shortly.', 'everest-forms' ) ),
677 'submission_message_scroll' => '1',
678 'redirect_to' => self::validate_redirect_to( $ai['redirect_to'] ?? 'same' ),
679 'custom_page' => absint( $ai['redirect_custom_page_id'] ?? 0 ),
680 'external_url' => esc_url_raw( $ai['redirect_external_url'] ?? '' ),
681 'layout_class' => 'default',
682 'form_class' => '',
683 'ajax_form_submission' => '1',
684 'disabled_entries' => '0',
685 // Anti-spam: AI always enables honeypot; reCAPTCHA only if AI says so AND it's configured.
686 'honeypot' => ! empty( $ai['enable_honeypot'] ) ? '1' : '1',
687 'recaptcha_support' => ! empty( $ai['enable_recaptcha'] ) && self::is_recaptcha_configured() ? '1' : '0',
688 // Multipart — enable_multi_part flag + indicator/nav settings read by builder
689 'enable_multi_part' => $is_multipart ? '1' : '0',
690 'multi_part' => $is_multipart ? array(
691 'indicator' => 'progress',
692 'indicator_color' => '#7e3bd0',
693 'nav_align' => 'center',
694 ) : array(),
695 // Conversational — enable flag + sub-settings read by conversational forms plugin
696 'enable_conversational_forms' => $is_conversational ? '1' : '0',
697 'conversational_forms' => $is_conversational ? array(
698 'conversational_forms_url' => sanitize_title( $ai['conversational_url'] ?? sanitize_title( $ai['form_title'] ?? '' ) ),
699 'enable_welcome_message' => 'no',
700 'enable_page_navigation' => '1',
701 'enable_branding' => '1',
702 'everest_forms_conversational_forms_color_picker' => '#7e3bd0',
703 'everest_forms_conversational_form_background_layout' => 'default',
704 'everest_forms_conversational_forms_background_image' => '',
705 'everest_forms_conversational_forms_opacity' => '',
706 'everest_forms_conversational_forms_themes' => '',
707 ) : array(),
708 ];
709
710 // Admin email notification
711 if ( ! empty( $ai['send_email_notification'] ) ) {
712 $email_subject = ! empty( $ai['notification_subject'] )
713 ? sanitize_text_field( $ai['notification_subject'] )
714 : sprintf( __( 'New submission: %s', 'everest-forms' ), $ai['form_title'] ?? 'Form' );
715
716 $notif_from_name = ! empty( $ai['notification_from_name'] )
717 ? sanitize_text_field( $ai['notification_from_name'] )
718 : '';
719 $notif_reply_to = self::resolve_reply_to( $ai['notification_reply_to'] ?? 'auto', $reply_to );
720 $notif_message = ! empty( $ai['notification_message'] )
721 ? wp_kses_post( $ai['notification_message'] )
722 : '{all_fields}';
723
724 $settings['email'] = [
725 'connection_1' => [
726 'enable_email_notification' => '1',
727 'connection_name' => __( 'Admin Notification', 'everest-forms' ),
728 'evf_to_email' => '{admin_email}',
729 'evf_from_name' => $notif_from_name,
730 'evf_from_email' => '{admin_email}',
731 'evf_reply_to' => $notif_reply_to,
732 'evf_email_subject' => $email_subject,
733 'evf_email_message' => $notif_message,
734 'evf_email_cc' => '',
735 'evf_email_bcc' => '',
736 ],
737 ];
738
739 // User confirmation email — when AI says to send one + email field found
740 if ( ! empty( $ai['send_user_confirmation'] ) && $email_field_id ) {
741 $confirm_subject = ! empty( $ai['user_confirmation_subject'] )
742 ? sanitize_text_field( $ai['user_confirmation_subject'] )
743 : sprintf( __( 'Thank you for your submission — %s', 'everest-forms' ), $ai['form_title'] ?? 'Form' );
744 $confirm_message = ! empty( $ai['user_confirmation_message'] )
745 ? sanitize_textarea_field( $ai['user_confirmation_message'] )
746 : __( 'Thank you! We have received your submission and will be in touch shortly.', 'everest-forms' );
747
748 $ucfm_from_name = ! empty( $ai['user_confirmation_from_name'] )
749 ? sanitize_text_field( $ai['user_confirmation_from_name'] )
750 : '';
751 $ucfm_reply_to = self::resolve_reply_to( $ai['user_confirmation_reply_to'] ?? 'auto', '{admin_email}' );
752
753 $settings['email']['connection_2'] = [
754 'enable_email_notification' => '1',
755 'connection_name' => __( 'User Confirmation', 'everest-forms' ),
756 'evf_to_email' => '{field_id="' . $email_field_id . '"}',
757 'evf_from_name' => $ucfm_from_name,
758 'evf_from_email' => '{admin_email}',
759 'evf_reply_to' => $ucfm_reply_to,
760 'evf_email_subject' => $confirm_subject,
761 'evf_email_message' => $confirm_message,
762 'evf_email_cc' => '',
763 'evf_email_bcc' => '',
764 ];
765 }
766 }
767
768 return $settings;
769 }
770
771 // ── Multipart ─────────────────────────────────────────────────────────────
772
773 /**
774 * Build EVF multi_part structure from AI step definitions.
775 *
776 * The multipart plugin splits the form at row boundaries — it renders rows
777 * sequentially and opens a new <div id="part_N"> when the last row of a part
778 * is reached (line 943 in class-everest-forms-multi-part.php). So each part
779 * needs `rows` (row IDs from the structure object), not just field IDs.
780 *
781 * @param array $steps AI multipart_steps: [{ title, field_indices[] }, ...]
782 * @param array $field_ids Ordered list of actual field IDs
783 * @param array $structure EVF structure object (row_X => [grid_1 => [field_id, ...]])
784 * @return array EVF multi_part format
785 */
786 private static function build_multipart_data( array $steps, array $field_ids, array $structure ): array {
787 // Build reverse map: field_id → row_key
788 $field_to_row = [];
789 foreach ( $structure as $row_key => $grids ) {
790 foreach ( $grids as $grid ) {
791 foreach ( (array) $grid as $fid ) {
792 $field_to_row[ $fid ] = $row_key;
793 }
794 }
795 }
796
797 $multi_part = [];
798 foreach ( $steps as $step_index => $step ) {
799 $part_id = $step_index + 1;
800 $part_key = 'part_' . $part_id;
801 $indices = $step['field_indices'] ?? [];
802
803 // Collect the row IDs for every field in this step (unique, preserving order)
804 $step_rows = [];
805 $step_fids = [];
806 foreach ( $indices as $idx ) {
807 if ( ! isset( $field_ids[ $idx ] ) ) {
808 continue;
809 }
810 $fid = $field_ids[ $idx ];
811 $step_fids[] = $fid;
812 $row_key = $field_to_row[ $fid ] ?? null;
813 if ( $row_key && ! in_array( $row_key, $step_rows, true ) ) {
814 $step_rows[] = $row_key;
815 }
816 }
817
818 $step_title = sanitize_text_field( $step['title'] ?? sprintf( __( 'Part %d', 'everest-forms' ), $part_id ) );
819 $multi_part[ $part_key ] = [
820 'id' => (string) $part_id,
821 'name' => $step_title,
822 'next' => __( 'Next', 'everest-forms' ),
823 'prev' => __( 'Previous', 'everest-forms' ),
824 'rows' => $step_rows, // row IDs — plugin uses this to split the form
825 'fields' => $step_fids, // field IDs — used by builder admin UI
826 ];
827 }
828 return $multi_part;
829 }
830
831 // ── Helpers ───────────────────────────────────────────────────────────────
832
833 /**
834 * Build a lookup of existing form fields indexed by "type||label".
835 *
836 * @param array $form_fields Raw form_fields array from EVF form data.
837 * @return array
838 */
839 private static function index_fields_by_label_type( array $form_fields ): array {
840 $index = array();
841 foreach ( $form_fields as $field ) {
842 $key = ( $field['type'] ?? '' ) . '||' . ( $field['label'] ?? '' );
843 $index[ $key ] = $field;
844 }
845 return $index;
846 }
847
848 /**
849 * Merge per-field settings from the existing saved form into the AI response.
850 *
851 * When the AI returns a field matching an existing one (by type+label), settings
852 * from the existing field are restored to prevent subsequent AI requests from
853 * silently resetting them.
854 *
855 * Two strategies are used:
856 *
857 * - Sticky flags (label_hide, sublabel_hide): if the existing field has these
858 * enabled ('1'), always force them back — the AI frequently returns the default
859 * (false) for unchanged fields, which would otherwise reset them.
860 *
861 * - Scalar settings (description, placeholder, css, required): only copied when
862 * the AI omitted the key entirely, so explicit AI changes are still honoured.
863 *
864 * @param array $ai_response Decoded AI gateway response.
865 * @param array $existing_index Existing fields indexed by "type||label".
866 * @return array Modified $ai_response with merged field settings.
867 */
868 private static function merge_field_settings( array $ai_response, array $existing_index ): array {
869 // Sticky flags: if existing is active ('1'), always restore — the AI returns
870 // false by default for unchanged fields, which would silently reset them.
871 $sticky_flags = array( 'label_hide', 'sublabel_hide' );
872
873 // Scalar settings: only restore when AI omitted the key entirely.
874 $preservable = array( 'required', 'description', 'placeholder', 'css' );
875
876 foreach ( ( $ai_response['fields'] ?? array() ) as $i => $ai_field ) {
877 $key = ( $ai_field['type'] ?? '' ) . '||' . ( $ai_field['label'] ?? '' );
878 if ( ! isset( $existing_index[ $key ] ) ) {
879 continue;
880 }
881
882 $existing = $existing_index[ $key ];
883
884 foreach ( $sticky_flags as $flag ) {
885 if ( '1' === ( $existing[ $flag ] ?? '0' ) ) {
886 $ai_response['fields'][ $i ][ $flag ] = '1';
887 }
888 }
889
890 foreach ( $preservable as $prop ) {
891 if ( ! array_key_exists( $prop, $ai_field )
892 && ! empty( $existing[ $prop ] )
893 && '0' !== (string) $existing[ $prop ] ) {
894 $ai_response['fields'][ $i ][ $prop ] = $existing[ $prop ];
895 }
896 }
897 }
898
899 return $ai_response;
900 }
901
902 /**
903 * Validate redirect_to — only accept known values, fall back to 'same'.
904 */
905 private static function validate_redirect_to( string $val ): string {
906 return in_array( $val, array( 'same', 'custom_page', 'external_url' ), true ) ? $val : 'same';
907 }
908
909 /**
910 * Resolve AI reply_to value.
911 * "auto" or empty → use $default (auto-detected email field smart tag or admin email).
912 * Anything else → sanitize and use as-is (explicit email or smart tag).
913 */
914 private static function resolve_reply_to( string $val, string $default ): string {
915 if ( '' === $val || 'auto' === $val ) {
916 return $default;
917 }
918 return sanitize_text_field( $val );
919 }
920
921 /**
922 * Check whether reCAPTCHA (any type) has a site key configured in EVF settings.
923 * Used to avoid enabling recaptcha_support when no key is set up.
924 */
925 public static function is_recaptcha_configured(): bool {
926 $type = get_option( 'everest_forms_recaptcha_type', 'v2' );
927 switch ( $type ) {
928 case 'v2':
929 return (bool) get_option( 'everest_forms_recaptcha_v2_site_key' );
930 case 'v2_invisible':
931 return (bool) get_option( 'everest_forms_recaptcha_v2_invisible_site_key' );
932 case 'v3':
933 return (bool) get_option( 'everest_forms_recaptcha_v3_site_key' );
934 case 'hcaptcha':
935 return (bool) get_option( 'everest_forms_recaptcha_hcaptcha_site_key' );
936 case 'turnstile':
937 return (bool) get_option( 'everest_forms_recaptcha_turnstile_site_key' );
938 }
939 return false;
940 }
941
942 /**
943 * Generate a unique field ID in EVF format: 8 random alphanumeric chars.
944 * E.g. "a3f9b2c1"
945 */
946 private static function generate_field_id(): string {
947 return substr( md5( uniqid( '', true ) ), 0, 8 );
948 }
949
950 /**
951 * Generate meta-key from label. Falls back to field_id suffix if label is empty.
952 * EVF convention: lowercase, underscored, no special chars.
953 * E.g. "Email Address" → "email_address"
954 */
955 private static function generate_meta_key( string $label, string $field_id ): string {
956 $key = strtolower( trim( $label ) );
957 $key = preg_replace( '/[^a-z0-9]+/', '_', $key );
958 $key = trim( $key, '_' );
959 return $key ?: 'field_' . substr( $field_id, 0, 4 );
960 }
961 }
962