PluginProbe ʕ •ᴥ•ʔ
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI / 3.6.0
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI v3.6.0
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 3 days ago class-evf-ai-api.php 3 days ago class-evf-ai-form-builder.php 3 days ago class-evf-ai-loader.php 2 months ago class-evf-ai-registration.php 3 days ago
class-evf-ai-form-builder.php
1103 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 /** Set to a human-readable notice when the AI's embedded `style` block (see
29 * maybe_apply_ai_style()) included Pro-only tokens/palette that got stripped because
30 * Pro isn't active on this site — empty string when nothing was stripped. */
31 public static $style_pro_locked_notice = '';
32
33 /** Set to a human-readable notice when the AI's embedded `style` block asked for
34 * something no site can ever fulfil (a background image, an invented font) — see
35 * {@see RestController::unsupported_capability_notice()}. Empty string when nothing was flagged. */
36 public static $style_capability_notice = '';
37
38 /**
39 * Create a new EVF form from the AI gateway response.
40 * Saved as DRAFT — user must click "Use This Form" to publish.
41 *
42 * @param array $ai_response Decoded JSON from /evf-ai/v1/generate
43 * @return int|WP_Error New form post ID on success, WP_Error on failure.
44 */
45 public static function create_form( array $ai_response ) {
46 $title = sanitize_text_field( $ai_response['form_title'] ?? __( 'AI Generated Form', 'everest-forms' ) );
47 $fields = $ai_response['fields'] ?? [];
48
49 remove_all_filters( 'content_save_pre' );
50
51 // Saved as DRAFT — becomes active only when user clicks "Use This Form"
52 $post_id = wp_insert_post( [
53 'post_title' => $title,
54 'post_type' => 'everest_form',
55 'post_status' => 'draft',
56 'post_content' => '{}',
57 ] );
58
59 if ( is_wp_error( $post_id ) ) {
60 return $post_id;
61 }
62
63 $form_data = self::build_form_data( $post_id, $ai_response );
64
65 wp_update_post( [
66 'ID' => $post_id,
67 'post_content' => evf_encode( $form_data ),
68 ] );
69
70 // The gateway may include a `style` block alongside the form when the prompt carried
71 // visual intent ("sleek dark contact form") — see everest_forms_style.py's STYLE_BLOCK.
72 // The new draft is born pre-styled: it opens in the v2 Style tab already showing the
73 // look, with nothing further for the user to do.
74 self::maybe_apply_ai_style( $post_id, $ai_response );
75
76 return $post_id;
77 }
78
79 /**
80 * Persist an AI-generated `style` block (from create_form()'s response) as this new form's
81 * v2 style record. A pure best-effort enhancement: silently does nothing when Style
82 * Customizer v2 isn't enabled, the response has no style intent, or the intent sanitizes to
83 * nothing — a form is never left half-created over a styling failure.
84 *
85 * Reuses the SAME authoritative gate {@see RestController::save_item()} does
86 * (Sanitizer::sanitize_record()), so an AI style can no more produce an unsafe or
87 * tier-violating record here than it can through the customizer's own AI launcher.
88 *
89 * Also mirrors that launcher's honest-notice behaviour ({@see RestController::pro_locked_notice()}):
90 * unlike the standalone Style AI chat, this path used to strip Pro-only tokens with no
91 * explanation at all, so a free-tier form born from "create a sleek dark contact form"
92 * could render nothing like what the AI's own summary described. Sets
93 * self::$style_pro_locked_notice so EVF_AI_Ajax::get_pro_feature_notice() can surface it.
94 *
95 * Overlays onto the form's EXISTING style record exactly like the Style Customizer's own
96 * AI chat client does ({@see StyleStore.applyAiRecord()} in store.ts) rather than replacing
97 * it wholesale: the AI's output contract is strictly `{tokens, palette}`, so on a refine
98 * (update_form()) a straight option overwrite would silently wipe a previously-picked
99 * template or custom CSS, and treating an empty/omitted palette as "clear the palette"
100 * would incorrectly detach an existing one even on a turn where the AI never touched it.
101 * `$check_contrast = true` also matches ai_style()'s own sanitize_record() call, so an
102 * AI-created form gets the same automatic light-on-light / dark-on-dark correction the
103 * standalone chat already has.
104 *
105 * @param int $post_id The newly created (draft) form.
106 * @param array $ai_response Full decoded gateway response (may contain a `style` key).
107 */
108 private static function maybe_apply_ai_style( int $post_id, array $ai_response ) {
109 self::$style_pro_locked_notice = '';
110 self::$style_capability_notice = '';
111
112 // Temporarily disabled for this release, unconditionally and locally — not just via the
113 // client_supports_style() flag sent upstream (see EVF_AI_API::client_supports_style()),
114 // which only asks the gateway not to send style data and can't guarantee it won't anyway.
115 // Matches RestController::ai_style()'s own hard block. Re-enable by deleting this block.
116 if ( true ) {
117 return;
118 }
119
120 if ( empty( $ai_response['style'] ) || ! is_array( $ai_response['style'] ) ) {
121 return;
122 }
123 if ( ! class_exists( '\EverestForms\Addons\StyleCustomizer\V2\Engine' )
124 || ! \EverestForms\Addons\StyleCustomizer\V2\Engine::enabled() ) {
125 return;
126 }
127
128 $style = $ai_response['style'];
129 $requested_tokens = isset( $style['tokens'] ) && is_array( $style['tokens'] ) ? $style['tokens'] : array();
130 $requested_palette = isset( $style['palette'] ) ? (string) $style['palette'] : '';
131
132 $clean = \EverestForms\Addons\StyleCustomizer\V2\Sanitizer::sanitize_record(
133 array(
134 'tokens' => $requested_tokens,
135 'palette' => $requested_palette,
136 ),
137 true
138 );
139
140 self::$style_pro_locked_notice = \EverestForms\Addons\StyleCustomizer\V2\RestController::pro_locked_notice(
141 $requested_tokens,
142 $clean['tokens'],
143 $requested_palette,
144 isset( $clean['palette'] ) ? $clean['palette'] : ''
145 );
146
147 self::$style_capability_notice = \EverestForms\Addons\StyleCustomizer\V2\RestController::unsupported_capability_notice(
148 $clean['tokens'],
149 '',
150 array()
151 );
152
153 if ( empty( $clean['tokens'] ) && empty( $clean['palette'] ) ) {
154 return; // Nothing survived sanitization — leave the existing/default style untouched.
155 }
156
157 $all = get_option( 'everest_forms_styles', array() );
158 $existing = isset( $all[ $post_id ] ) && is_array( $all[ $post_id ] ) ? $all[ $post_id ] : array();
159
160 // Per-key overlay, not a wholesale replace — a key the AI didn't return this turn
161 // keeps its existing value (matches applyAiRecord()'s `this.tokens[key] = clone(bag)`).
162 $merged_tokens = isset( $existing['tokens'] ) && is_array( $existing['tokens'] ) ? $existing['tokens'] : array();
163 foreach ( $clean['tokens'] as $key => $value ) {
164 $merged_tokens[ $key ] = $value;
165 }
166 $existing['tokens'] = $merged_tokens;
167
168 // Empty palette from the AI = "didn't touch it this turn", not "clear it" — matches
169 // applyAiRecord()'s `if ( paletteId ) { … this.palette = paletteId; }`.
170 if ( '' !== $clean['palette'] ) {
171 $existing['palette'] = $clean['palette'];
172 } elseif ( ! isset( $existing['palette'] ) ) {
173 $existing['palette'] = '';
174 }
175
176 // template/custom_css (and anything else already in $existing) are left as-is — the
177 // AI's output contract never includes them, so there is nothing to overlay for those keys.
178 $existing['schema_version'] = $clean['schema_version'];
179 $existing['_updated_at'] = $clean['_updated_at'];
180
181 $all[ $post_id ] = $existing;
182 update_option( 'everest_forms_styles', $all, false ); // autoload=no, matches RestController::save_item().
183 }
184
185 /**
186 * Rebuild an existing (draft) AI form in place from a refined AI response.
187 * Keeps the same form id/status so the preview and builder stay in sync.
188 *
189 * @param int $form_id Existing draft form id.
190 * @param array $ai_response Refined AI form schema.
191 * @return int|WP_Error The form id on success.
192 */
193 public static function update_form( int $form_id, array $ai_response ) {
194 $post = get_post( $form_id );
195 if ( ! $post || 'everest_form' !== $post->post_type ) {
196 return new WP_Error( 'invalid_form', __( 'Form not found.', 'everest-forms' ) );
197 }
198
199 remove_all_filters( 'content_save_pre' );
200
201 // Preserve per-field settings (e.g. label_hide) that were applied by a prior AI
202 // request but may be absent from this AI response. Merge before building form data.
203 $existing_data = evf_decode( $post->post_content );
204 $existing_index = self::index_fields_by_label_type( $existing_data['form_fields'] ?? array() );
205 $ai_response = self::merge_field_settings( $ai_response, $existing_index );
206
207 $title = sanitize_text_field( $ai_response['form_title'] ?? get_the_title( $form_id ) );
208 $form_data = self::build_form_data( $form_id, $ai_response );
209
210 wp_update_post( [
211 'ID' => $form_id,
212 'post_title' => $title,
213 'post_content' => evf_encode( $form_data ),
214 ] );
215
216 // The gateway may include a `style` block on a refine too (see everest_forms.py's
217 // build_update_prompt() style-context addition) — no-ops silently when absent.
218 self::maybe_apply_ai_style( $form_id, $ai_response );
219
220 return $form_id;
221 }
222
223 /**
224 * Publish a draft AI form — called when user clicks "Use This Form".
225 *
226 * @param int $form_id
227 * @return bool
228 */
229 public static function activate_form( int $form_id ): bool {
230 $post = get_post( $form_id );
231 if ( ! $post || 'everest_form' !== $post->post_type ) {
232 return false;
233 }
234
235 return (bool) wp_update_post( [
236 'ID' => $form_id,
237 'post_status' => 'publish',
238 ] );
239 }
240
241 /**
242 * Return summary of fields in a form for the preview modal.
243 *
244 * @param int $form_id
245 * @return array [ [ label, type, is_pro ], ... ]
246 */
247 public static function get_field_summary( int $form_id ): array {
248 $post = get_post( $form_id );
249 if ( ! $post ) {
250 return [];
251 }
252
253 $data = evf_decode( $post->post_content );
254 $fields = $data['form_fields'] ?? [];
255 $summary = [];
256
257 foreach ( $fields as $field ) {
258 $type = $field['type'] ?? '';
259 if ( in_array( $type, [ 'html', 'title', 'divider', 'hidden' ], true ) ) {
260 continue; // skip non-input fields from preview list
261 }
262 $summary[] = [
263 'label' => $field['label'] ?? ucfirst( $type ),
264 'type' => $type,
265 'is_pro' => in_array( $type, self::$pro_fields, true ),
266 ];
267 }
268
269 return $summary;
270 }
271
272 // ── Form data builder ─────────────────────────────────────────────────────
273
274 /**
275 * Field types that are narrow enough to share a row (2-column layout).
276 * Everything else gets a full-width row.
277 */
278 private static $narrow_types = [
279 'text', 'first-name', 'last-name', 'email', 'phone',
280 'number', 'url', 'date-time', 'select', 'country',
281 'hidden', 'yes-no', 'rating', 'color', 'range-slider',
282 ];
283
284 /**
285 * Forced pairs — if the current field type is a key and the NEXT field
286 * type is the value, always put them on the same row regardless of position.
287 */
288 private static $forced_pairs = [
289 'first-name' => 'last-name',
290 'last-name' => 'first-name',
291 ];
292
293 private static function build_form_data( int $form_id, array $ai ): array {
294 self::$file_upload_limited = false;
295 $built_fields = [];
296 $email_field_id = null;
297
298 // Pre-compute which step each field index belongs to (for multipart pairing)
299 $field_step_map = [];
300 foreach ( ( $ai['multipart_steps'] ?? [] ) as $step_idx => $step ) {
301 foreach ( ( $step['field_indices'] ?? [] ) as $fi ) {
302 $field_step_map[ $fi ] = $step_idx;
303 }
304 }
305
306 // Promote explicit recaptcha/hcaptcha/turnstile field requests to the form-level
307 // recaptcha_support setting. The AI sometimes returns these as a field type rather
308 // than setting enable_recaptcha, so we detect and convert them here.
309 foreach ( ( $ai['fields'] ?? [] ) as $ai_field ) {
310 $t = strtolower( sanitize_key( $ai_field['type'] ?? '' ) );
311 $l = strtolower( $ai_field['label'] ?? '' );
312 if ( in_array( $t, [ 'recaptcha', 'hcaptcha', 'turnstile' ], true )
313 || false !== strpos( $l, 'recaptcha' )
314 || false !== strpos( $l, 'hcaptcha' )
315 || false !== strpos( $l, 'turnstile' ) ) {
316 $ai['enable_recaptcha'] = true;
317 break;
318 }
319 }
320
321 // Build all field objects first so we can look ahead for smart pairing
322 $field_list = [];
323 $field_index = 0;
324 $file_upload_count = 0;
325 $is_pro_active = defined( 'EFP_PLUGIN_FILE' );
326 $logger = evf_get_logger();
327 foreach ( ( $ai['fields'] ?? [] ) as $ai_field ) {
328 // Free tier: only one file-upload field is allowed per form.
329 if ( ! $is_pro_active && 'file-upload' === ( $ai_field['type'] ?? '' ) ) {
330 if ( $file_upload_count >= 1 ) {
331 self::$file_upload_limited = true;
332 $logger->warning(
333 sprintf( 'AI Form Builder: dropped extra file-upload field "%s" — free tier allows only one.', $ai_field['label'] ?? '' ),
334 array( 'source' => 'evf-ai' )
335 );
336 $field_index++;
337 continue;
338 }
339 $file_upload_count++;
340 }
341
342 $field_id = self::generate_field_id();
343 $evf_field = self::build_field( $field_id, $ai_field );
344 if ( ! $evf_field ) {
345 $field_index++;
346 continue;
347 }
348 if ( ! $is_pro_active && in_array( $evf_field['type'], self::$pro_fields, true ) ) {
349 $logger->warning(
350 sprintf( 'AI Form Builder: added Pro-only field "%s" (%s) — it will not render on the frontend until Pro is active.', $evf_field['label'], $evf_field['type'] ),
351 array( 'source' => 'evf-ai' )
352 );
353 }
354 $built_fields[ $field_id ] = $evf_field;
355 if ( 'email' === $evf_field['type'] && null === $email_field_id ) {
356 $email_field_id = $field_id;
357 }
358 $field_list[] = [
359 'id' => $field_id,
360 'type' => $evf_field['type'],
361 'width' => sanitize_key( $ai_field['width'] ?? '' ), // 'full'|'half'|''
362 'step' => $field_step_map[ $field_index ] ?? -1, // -1 = no multipart
363 ];
364 $field_index++;
365 }
366
367 // Ensure every field has a unique meta-key within this form.
368 $used_keys = [];
369 foreach ( $built_fields as &$field ) {
370 if ( ! isset( $field['meta-key'] ) ) {
371 continue;
372 }
373 $base = $field['meta-key'];
374 if ( ! in_array( $base, $used_keys, true ) ) {
375 $used_keys[] = $base;
376 continue;
377 }
378 $n = 2;
379 while ( in_array( $base . '_' . $n, $used_keys, true ) ) {
380 $n++;
381 }
382 $field['meta-key'] = $base . '_' . $n;
383 $used_keys[] = $field['meta-key'];
384 }
385 unset( $field );
386
387 $structure = self::build_structure( $field_list );
388 $form_data = [
389 'id' => $form_id,
390 'form_field_id' => (string) count( $built_fields ),
391 'form_enabled' => '1',
392 'form_fields' => $built_fields,
393 'settings' => self::build_settings( $ai, $email_field_id ),
394 'structure' => $structure,
395 ];
396
397 // Multipart — inject step data mapping field indices → row IDs from structure
398 if ( 'multipart' === ( $ai['form_type'] ?? '' ) && ! empty( $ai['multipart_steps'] ) ) {
399 $form_data['multi_part'] = self::build_multipart_data(
400 $ai['multipart_steps'],
401 array_keys( $built_fields ),
402 $structure
403 );
404 }
405
406 return $form_data;
407 }
408
409 /**
410 * Build the structure object with smart 2-column grouping.
411 *
412 * Rules (in priority order):
413 * 1. first-name + last-name → always same row
414 * 2. Two consecutive narrow fields → same row
415 * 3. Everything else (textarea, address, file-upload, etc.) → full-width row
416 */
417 private static function build_structure( array $field_list ): array {
418 $structure = [];
419 $row = 1;
420 $i = 0;
421 $total = count( $field_list );
422
423 while ( $i < $total ) {
424 $current = $field_list[ $i ];
425 $next = $field_list[ $i + 1 ] ?? null;
426
427 // Explicit AI width override takes priority over auto-pairing logic
428 $current_width = $current['width'] ?? '';
429 $next_width = $next['width'] ?? '';
430 $force_full = 'full' === $current_width;
431 $force_half = 'half' === $current_width && $next && 'half' === $next_width;
432
433 $is_narrow = in_array( $current['type'], self::$narrow_types, true );
434 $next_is_narrow = $next && in_array( $next['type'], self::$narrow_types, true );
435
436 // Forced pair (first-name ↔ last-name) by type
437 $forced_next_type = self::$forced_pairs[ $current['type'] ] ?? null;
438 $is_forced_pair = $forced_next_type && $next && $next['type'] === $forced_next_type;
439
440 // Never pair fields from different multipart steps (would share a row across parts)
441 $same_step = ( -1 === ( $current['step'] ?? -1 ) )
442 || ! isset( $next['step'] )
443 || $current['step'] === $next['step'];
444
445 // Two-column: explicit half+half OR auto-pair (unless force_full or cross-step)
446 if ( ! $force_full && $same_step && ( $force_half || $is_forced_pair || ( $is_narrow && $next_is_narrow ) ) ) {
447 // Two columns
448 $structure[ 'row_' . $row ] = [
449 'grid_1' => [ $current['id'] ],
450 'grid_2' => [ $next['id'] ],
451 ];
452 $i += 2;
453 } else {
454 // Full width
455 $structure[ 'row_' . $row ] = [
456 'grid_1' => [ $current['id'] ],
457 ];
458 $i++;
459 }
460
461 $row++;
462 }
463
464 return $structure;
465 }
466
467 // ── Field builder ─────────────────────────────────────────────────────────
468
469 private static function build_field( string $field_id, array $ai_field ): ?array {
470 $type = sanitize_key( $ai_field['type'] ?? '' );
471 $label = sanitize_text_field( $ai_field['label'] ?? ucfirst( $type ) );
472
473 if ( ! $type ) {
474 evf_get_logger()->warning(
475 sprintf( 'AI Form Builder: dropped a field with no type — label was "%s".', $ai_field['label'] ?? '' ),
476 array( 'source' => 'evf-ai' )
477 );
478 return null;
479 }
480
481 // Normalize: gateway may return `html` with reset-button HTML when user
482 // asks for a reset button. Convert to the proper EVF `reset` type and
483 // clear the raw description so it doesn't leak as display text.
484 if ( 'html' === $type ) {
485 $raw_desc = $ai_field['description'] ?? '';
486 if ( preg_match( '/<button[^>]+type=["\']?reset["\']?/i', $raw_desc ) ) {
487 $type = 'reset';
488 $label = $label ?: __( 'Reset', 'everest-forms' );
489 $ai_field['description'] = '';
490 }
491 }
492
493 // reCAPTCHA, hCaptcha, and Turnstile are form-level settings, not draggable
494 // fields. build_form_data() already promoted the request to enable_recaptcha
495 // via its pre-scan; skip field creation here.
496 $lc_label = strtolower( $label );
497 if ( in_array( $type, [ 'recaptcha', 'hcaptcha', 'turnstile' ], true )
498 || false !== strpos( $lc_label, 'recaptcha' )
499 || false !== strpos( $lc_label, 'hcaptcha' )
500 || false !== strpos( $lc_label, 'turnstile' ) ) {
501 return null;
502 }
503
504 // Normalize: gateway may return `text` (or a non-existent `math` type)
505 // for math-captcha requests — convert to the proper EVF `captcha` type.
506 if ( 'captcha' !== $type ) {
507 $combined = strtolower( $label . ' ' . ( $ai_field['description'] ?? '' ) );
508 if ( 'math' === $type || false !== strpos( $combined, 'captcha' ) ) {
509 $type = 'captcha';
510 $label = $label ?: __( 'Math Captcha', 'everest-forms' );
511 }
512 }
513
514 // Normalize: EVF's payment-radio field is registered internally as
515 // `payment-multiple`. Map the intuitive gateway name to the real type.
516 if ( 'payment-radio' === $type ) {
517 $type = 'payment-multiple';
518 }
519
520 // Normalize: `credit-card` (and the per-gateway square/authorize-net fields)
521 // are legacy types now unified into the single Payment Gateway field. The
522 // gateway prompt no longer emits them, but remap defensively so any cached
523 // or older AI response still produces the modern, frontend-rendering field.
524 if ( in_array( $type, array( 'credit-card', 'payment-square', 'payment-authorize-net' ), true ) ) {
525 $type = 'payment-gateway-selector';
526 $label = $label ?: __( 'Payment Gateway', 'everest-forms' );
527 }
528
529 // Normalize: the repeater field is registered as `repeater-fields` internally.
530 if ( 'repeater' === $type ) {
531 $type = 'repeater-fields';
532 }
533
534 // Base keys every field has
535 $field = [
536 'id' => $field_id,
537 'type' => $type,
538 'label' => $label,
539 'meta-key' => self::generate_meta_key( $label, $field_id ),
540 'required' => ! empty( $ai_field['required'] ) ? '1' : '',
541 'required_field_message_setting' => 'global',
542 'required-field-message' => '',
543 'label_hide' => ! empty( $ai_field['label_hide'] ) ? '1' : '0',
544 'description' => sanitize_text_field( $ai_field['description'] ?? '' ),
545 'css' => sanitize_text_field( $ai_field['css'] ?? '' ),
546 ];
547
548 // Placeholder (not all field types use it)
549 if ( ! empty( $ai_field['placeholder'] ) ) {
550 $field['placeholder'] = sanitize_text_field( $ai_field['placeholder'] );
551 }
552
553 // Type-specific additions
554 switch ( $type ) {
555 case 'text':
556 case 'first-name':
557 case 'last-name':
558 case 'url':
559 case 'number':
560 $field['default_value'] = sanitize_text_field( $ai_field['default_value'] ?? '' );
561 $field['limit_enabled'] = '0';
562 $field['limit_count'] = '100';
563 $field['limit_mode'] = 'characters';
564 $field['min_length_enabled']= '0';
565 $field['min_length_count'] = '1';
566 $field['min_length_mode'] = 'characters';
567 $field['input_mask'] = '';
568 break;
569
570 case 'textarea':
571 $field['default_value'] = sanitize_textarea_field( $ai_field['default_value'] ?? '' );
572 $field['limit_enabled'] = '0';
573 $field['limit_count'] = '500';
574 $field['limit_mode'] = 'characters';
575 break;
576
577 case 'email':
578 $field['default_value'] = sanitize_text_field( $ai_field['default_value'] ?? '' );
579 $field['confirmation_placeholder'] = '';
580 $field['sublabel_hide'] = ! empty( $ai_field['sublabel_hide'] ) ? '1' : '';
581 break;
582
583 case 'phone':
584 $field['default_value'] = sanitize_text_field( $ai_field['default_value'] ?? '' );
585 $field['phone_format'] = 'smart';
586 $field['input_mask'] = '';
587 break;
588
589 case 'date-time':
590 $field['datetime_format'] = 'mm/dd/yyyy';
591 $field['datetime_style'] = 'picker';
592 $field['date_format'] = 'mm/dd/yyyy';
593 $field['date_localization']= 'en';
594 $field['date_mode'] = 'single';
595 $field['time_format'] = '12';
596 $field['time_interval'] = '1';
597 break;
598
599 case 'checkbox':
600 case 'radio':
601 $field['choices'] = self::build_choices( $ai_field['options'] ?? [ 'Option 1', 'Option 2' ] );
602 $field['input_columns'] = '';
603 $field['randomize'] = '0';
604 $field['show_values'] = '0';
605 $field['choices_images']= '0';
606 if ( 'checkbox' === $type ) {
607 $field['select_all'] = '0';
608 $field['choice_limit']= '';
609 }
610 break;
611
612 case 'select':
613 $field['choices'] = self::build_choices( $ai_field['options'] ?? [ 'Option 1', 'Option 2' ] );
614 $field['placeholder'] = $ai_field['placeholder'] ?? __( 'Select an option', 'everest-forms' );
615 $field['enhanced_select'] = '0';
616 $field['multiple_choices']= '0';
617 $field['show_values'] = '0';
618 break;
619
620 case 'address':
621 $field += self::address_sublabels();
622 if ( ! empty( $ai_field['sublabel_hide'] ) ) {
623 $field['sublabel_hide'] = '1';
624 }
625 break;
626
627 case 'file-upload':
628 $field['extensions'] = 'jpg,jpeg,png,gif,pdf,doc,docx,xls,xlsx';
629 $field['max_size'] = '10';
630 $field['max_file_number'] = '1';
631 $field['upload_message'] = __( 'Drop files here or click to upload', 'everest-forms' );
632 $field['media_library'] = '0';
633 break;
634
635 case 'image-upload':
636 $field['extensions'] = 'jpg,jpeg,png,gif';
637 $field['max_size'] = '5';
638 $field['max_file_number'] = '1';
639 break;
640
641 case 'rating':
642 $field['number_of_stars'] = '5';
643 $field['icon'] = 'star';
644 $field['icon_size'] = 'medium';
645 $field['icon_color'] = '#f4b942';
646 break;
647
648 case 'hidden':
649 $field['default_value'] = sanitize_text_field( $ai_field['default_value'] ?? '' );
650 break;
651
652 case 'html':
653 case 'title':
654 case 'divider':
655 unset( $field['meta-key'], $field['required'], $field['required_field_message_setting'], $field['required-field-message'] );
656 break;
657
658 case 'privacy-policy':
659 $field['choices'] = self::build_choices( [ __( 'I agree to the privacy policy', 'everest-forms' ) ] );
660 $field['show_values']= '0';
661 break;
662
663 case 'payment-multiple':
664 $field['choices'] = self::build_payment_choices( $ai_field['options'] ?? [] );
665 $field['input_columns'] = '';
666 $field['choices_images'] = '0';
667 break;
668
669 case 'payment-checkbox':
670 $field['choices'] = self::build_payment_choices( $ai_field['options'] ?? [] );
671 $field['input_columns'] = '';
672 $field['choices_images'] = '0';
673 $field['select_all'] = '0';
674 break;
675
676 case 'repeater-fields':
677 $field['repeater_field_hide'] = '0';
678 $field['repeater_repeat_limit'] = '';
679 $field['repeater_button_add_new_label']= 'Add';
680 $field['repeater_button_remove_label'] = 'Remove';
681 break;
682
683 case 'captcha':
684 $field['format'] = 'math';
685 $field['required'] = '1';
686 break;
687
688 case 'reset':
689 $field['button_text'] = $label ?: __( 'Reset', 'everest-forms' );
690 break;
691 }
692
693 return $field;
694 }
695
696 // ── Choices ───────────────────────────────────────────────────────────────
697
698 private static function build_choices( array $options ): array {
699 $choices = [];
700 foreach ( $options as $i => $opt ) {
701 $label = sanitize_text_field( is_array( $opt ) ? ( $opt['label'] ?? '' ) : $opt );
702 $choices[] = [
703 'label' => $label,
704 'value' => $label,
705 'image' => '',
706 'default' => '',
707 ];
708 }
709 return $choices;
710 }
711
712 /**
713 * Build choices for payment-multiple / payment-checkbox fields.
714 * Value must be a numeric price string (e.g. "10.00"), not the label text.
715 * If the gateway supplies a price in the option (array with 'value', or a
716 * string like "VIP - $50"), extract it; otherwise assign ascending defaults.
717 */
718 private static function build_payment_choices( array $options ): array {
719 $defaults = [ '10.00', '20.00', '30.00', '40.00', '50.00' ];
720 $choices = [];
721
722 foreach ( $options as $i => $opt ) {
723 if ( is_array( $opt ) ) {
724 $label = sanitize_text_field( $opt['label'] ?? '' );
725 $price = self::extract_price( $opt['value'] ?? '' ) ?? $defaults[ $i ] ?? '10.00';
726 } else {
727 $raw = sanitize_text_field( $opt );
728 // Try to pull a dollar/numeric price out of strings like "VIP – $50" or "General ($20.00)"
729 $price = self::extract_price( $raw ) ?? $defaults[ $i ] ?? '10.00';
730 // Strip the price annotation from the label so it doesn't duplicate.
731 $label = trim( preg_replace( '/[\(\-–—]*\s*\$[\d,]+(\.\d{1,2})?\s*[\)]*/', '', $raw ) );
732 $label = $label ?: $raw;
733 }
734
735 $choices[] = [
736 'label' => $label,
737 'value' => $price,
738 'image' => '',
739 'default' => '',
740 ];
741 }
742
743 // If no options came from the gateway, use the field's own defaults.
744 if ( empty( $choices ) ) {
745 foreach ( [ 'Option 1', 'Option 2', 'Option 3' ] as $i => $lbl ) {
746 $choices[] = [
747 'label' => $lbl,
748 'value' => $defaults[ $i ],
749 'image' => '',
750 'default' => '',
751 ];
752 }
753 }
754
755 return $choices;
756 }
757
758 /** Extract a numeric price string from a value or annotated label. Returns null on failure. */
759 private static function extract_price( string $raw ): ?string {
760 // Accept plain numbers ("25", "25.00") or dollar-prefixed ("$25", "$25.00")
761 if ( preg_match( '/\$?([\d,]+(?:\.\d{1,2})?)/', $raw, $m ) ) {
762 $num = (float) str_replace( ',', '', $m[1] );
763 if ( $num > 0 ) {
764 return number_format( $num, 2, '.', '' );
765 }
766 }
767 return null;
768 }
769
770 // ── Address sublabels ─────────────────────────────────────────────────────
771
772 private static function address_sublabels(): array {
773 return [
774 'sublabel_hide' => '0',
775 'address1_label' => __( 'Address Line 1', 'everest-forms' ),
776 'address1_placeholder'=> '',
777 'address1_default' => '',
778 'address1_hide' => '0',
779 'address2_label' => __( 'Address Line 2', 'everest-forms' ),
780 'address2_placeholder'=> '',
781 'address2_default' => '',
782 'address2_hide' => '0',
783 'city_label' => __( 'City', 'everest-forms' ),
784 'city_placeholder' => '',
785 'city_default' => '',
786 'city_hide' => '0',
787 'state_label' => __( 'State / Province', 'everest-forms' ),
788 'state_placeholder' => '',
789 'state_default' => '',
790 'state_hide' => '0',
791 'postal_label' => __( 'Zip / Postal Code', 'everest-forms' ),
792 'postal_placeholder' => '',
793 'postal_default' => '',
794 'postal_hide' => '0',
795 'country_label' => __( 'Country', 'everest-forms' ),
796 'country_placeholder' => '',
797 'country_default' => '',
798 'country_hide' => '0',
799 ];
800 }
801
802 // ── Settings ──────────────────────────────────────────────────────────────
803
804 private static function build_settings( array $ai, ?string $email_field_id ): array {
805 $reply_to = $email_field_id
806 ? '{field_id="' . $email_field_id . '"}'
807 : '{admin_email}';
808
809 $is_multipart = 'multipart' === ( $ai['form_type'] ?? '' );
810 $is_conversational = 'conversational' === ( $ai['form_type'] ?? '' );
811
812 $settings = [
813 'form_title' => sanitize_text_field( $ai['form_title'] ?? '' ),
814 'form_desc' => sanitize_text_field( $ai['form_desc'] ?? '' ),
815 'submit_button_text' => sanitize_text_field( $ai['submit_button_text'] ?? __( 'Submit', 'everest-forms' ) ),
816 'submit_button_processing_text' => __( 'Processing...', 'everest-forms' ),
817 'successful_form_submission_message' => sanitize_text_field( $ai['success_message'] ?? __( 'Thanks for contacting us! We will be in touch shortly.', 'everest-forms' ) ),
818 'submission_message_scroll' => '1',
819 'redirect_to' => self::validate_redirect_to( $ai['redirect_to'] ?? 'same' ),
820 'custom_page' => absint( $ai['redirect_custom_page_id'] ?? 0 ),
821 'external_url' => esc_url_raw( $ai['redirect_external_url'] ?? '' ),
822 'layout_class' => 'default',
823 'form_class' => '',
824 'ajax_form_submission' => '1',
825 'disabled_entries' => '0',
826 // Anti-spam: AI always enables honeypot; reCAPTCHA only if AI says so AND it's configured.
827 'honeypot' => ! empty( $ai['enable_honeypot'] ) ? '1' : '1',
828 'recaptcha_support' => ! empty( $ai['enable_recaptcha'] ) && self::is_recaptcha_configured() ? '1' : '0',
829 // Multipart — enable_multi_part flag + indicator/nav settings read by builder
830 'enable_multi_part' => $is_multipart ? '1' : '0',
831 'multi_part' => $is_multipart ? array(
832 'indicator' => 'progress',
833 'indicator_color' => '#7e3bd0',
834 'nav_align' => 'center',
835 ) : array(),
836 // Conversational — enable flag + sub-settings read by conversational forms plugin
837 'enable_conversational_forms' => $is_conversational ? '1' : '0',
838 'conversational_forms' => $is_conversational ? array(
839 'conversational_forms_url' => sanitize_title( $ai['conversational_url'] ?? sanitize_title( $ai['form_title'] ?? '' ) ),
840 'enable_welcome_message' => 'no',
841 'enable_page_navigation' => '1',
842 'enable_branding' => '1',
843 'everest_forms_conversational_forms_color_picker' => '#7e3bd0',
844 'everest_forms_conversational_form_background_layout' => 'default',
845 'everest_forms_conversational_forms_background_image' => '',
846 'everest_forms_conversational_forms_opacity' => '',
847 'everest_forms_conversational_forms_themes' => '',
848 ) : array(),
849 ];
850
851 // Admin email notification
852 if ( ! empty( $ai['send_email_notification'] ) ) {
853 $email_subject = ! empty( $ai['notification_subject'] )
854 ? sanitize_text_field( $ai['notification_subject'] )
855 : sprintf( __( 'New submission: %s', 'everest-forms' ), $ai['form_title'] ?? 'Form' );
856
857 $notif_from_name = ! empty( $ai['notification_from_name'] )
858 ? sanitize_text_field( $ai['notification_from_name'] )
859 : '';
860 $notif_reply_to = self::resolve_reply_to( $ai['notification_reply_to'] ?? 'auto', $reply_to );
861 $notif_message = ! empty( $ai['notification_message'] )
862 ? wp_kses_post( $ai['notification_message'] )
863 : '{all_fields}';
864
865 $settings['email'] = [
866 'connection_1' => [
867 'enable_email_notification' => '1',
868 'connection_name' => __( 'Admin Notification', 'everest-forms' ),
869 'evf_to_email' => '{admin_email}',
870 'evf_from_name' => $notif_from_name,
871 'evf_from_email' => '{admin_email}',
872 'evf_reply_to' => $notif_reply_to,
873 'evf_email_subject' => $email_subject,
874 'evf_email_message' => $notif_message,
875 'evf_email_cc' => '',
876 'evf_email_bcc' => '',
877 ],
878 ];
879
880 // User confirmation email — when AI says to send one + email field found
881 if ( ! empty( $ai['send_user_confirmation'] ) && $email_field_id ) {
882 $confirm_subject = ! empty( $ai['user_confirmation_subject'] )
883 ? sanitize_text_field( $ai['user_confirmation_subject'] )
884 : sprintf( __( 'Thank you for your submission — %s', 'everest-forms' ), $ai['form_title'] ?? 'Form' );
885 $confirm_message = ! empty( $ai['user_confirmation_message'] )
886 ? sanitize_textarea_field( $ai['user_confirmation_message'] )
887 : __( 'Thank you! We have received your submission and will be in touch shortly.', 'everest-forms' );
888
889 $ucfm_from_name = ! empty( $ai['user_confirmation_from_name'] )
890 ? sanitize_text_field( $ai['user_confirmation_from_name'] )
891 : '';
892 $ucfm_reply_to = self::resolve_reply_to( $ai['user_confirmation_reply_to'] ?? 'auto', '{admin_email}' );
893
894 $settings['email']['connection_2'] = [
895 'enable_email_notification' => '1',
896 'connection_name' => __( 'User Confirmation', 'everest-forms' ),
897 'evf_to_email' => '{field_id="' . $email_field_id . '"}',
898 'evf_from_name' => $ucfm_from_name,
899 'evf_from_email' => '{admin_email}',
900 'evf_reply_to' => $ucfm_reply_to,
901 'evf_email_subject' => $confirm_subject,
902 'evf_email_message' => $confirm_message,
903 'evf_email_cc' => '',
904 'evf_email_bcc' => '',
905 ];
906 }
907 }
908
909 return $settings;
910 }
911
912 // ── Multipart ─────────────────────────────────────────────────────────────
913
914 /**
915 * Build EVF multi_part structure from AI step definitions.
916 *
917 * The multipart plugin splits the form at row boundaries — it renders rows
918 * sequentially and opens a new <div id="part_N"> when the last row of a part
919 * is reached (line 943 in class-everest-forms-multi-part.php). So each part
920 * needs `rows` (row IDs from the structure object), not just field IDs.
921 *
922 * @param array $steps AI multipart_steps: [{ title, field_indices[] }, ...]
923 * @param array $field_ids Ordered list of actual field IDs
924 * @param array $structure EVF structure object (row_X => [grid_1 => [field_id, ...]])
925 * @return array EVF multi_part format
926 */
927 private static function build_multipart_data( array $steps, array $field_ids, array $structure ): array {
928 // Build reverse map: field_id → row_key
929 $field_to_row = [];
930 foreach ( $structure as $row_key => $grids ) {
931 foreach ( $grids as $grid ) {
932 foreach ( (array) $grid as $fid ) {
933 $field_to_row[ $fid ] = $row_key;
934 }
935 }
936 }
937
938 $multi_part = [];
939 foreach ( $steps as $step_index => $step ) {
940 $part_id = $step_index + 1;
941 $part_key = 'part_' . $part_id;
942 $indices = $step['field_indices'] ?? [];
943
944 // Collect the row IDs for every field in this step (unique, preserving order)
945 $step_rows = [];
946 $step_fids = [];
947 foreach ( $indices as $idx ) {
948 if ( ! isset( $field_ids[ $idx ] ) ) {
949 continue;
950 }
951 $fid = $field_ids[ $idx ];
952 $step_fids[] = $fid;
953 $row_key = $field_to_row[ $fid ] ?? null;
954 if ( $row_key && ! in_array( $row_key, $step_rows, true ) ) {
955 $step_rows[] = $row_key;
956 }
957 }
958
959 $step_title = sanitize_text_field( $step['title'] ?? sprintf( __( 'Part %d', 'everest-forms' ), $part_id ) );
960 $multi_part[ $part_key ] = [
961 'id' => (string) $part_id,
962 'name' => $step_title,
963 'next' => __( 'Next', 'everest-forms' ),
964 'prev' => __( 'Previous', 'everest-forms' ),
965 'rows' => $step_rows, // row IDs — plugin uses this to split the form
966 'fields' => $step_fids, // field IDs — used by builder admin UI
967 ];
968 }
969 return $multi_part;
970 }
971
972 // ── Helpers ───────────────────────────────────────────────────────────────
973
974 /**
975 * Build a lookup of existing form fields indexed by "type||label".
976 *
977 * @param array $form_fields Raw form_fields array from EVF form data.
978 * @return array
979 */
980 private static function index_fields_by_label_type( array $form_fields ): array {
981 $index = array();
982 foreach ( $form_fields as $field ) {
983 $key = ( $field['type'] ?? '' ) . '||' . ( $field['label'] ?? '' );
984 $index[ $key ] = $field;
985 }
986 return $index;
987 }
988
989 /**
990 * Merge per-field settings from the existing saved form into the AI response.
991 *
992 * When the AI returns a field matching an existing one (by type+label), settings
993 * from the existing field are restored to prevent subsequent AI requests from
994 * silently resetting them.
995 *
996 * Two strategies are used:
997 *
998 * - Sticky flags (label_hide, sublabel_hide): if the existing field has these
999 * enabled ('1'), always force them back — the AI frequently returns the default
1000 * (false) for unchanged fields, which would otherwise reset them.
1001 *
1002 * - Scalar settings (description, placeholder, css, required): only copied when
1003 * the AI omitted the key entirely, so explicit AI changes are still honoured.
1004 *
1005 * @param array $ai_response Decoded AI gateway response.
1006 * @param array $existing_index Existing fields indexed by "type||label".
1007 * @return array Modified $ai_response with merged field settings.
1008 */
1009 private static function merge_field_settings( array $ai_response, array $existing_index ): array {
1010 // Sticky flags: if existing is active ('1'), always restore — the AI returns
1011 // false by default for unchanged fields, which would silently reset them.
1012 $sticky_flags = array( 'label_hide', 'sublabel_hide' );
1013
1014 // Scalar settings: only restore when AI omitted the key entirely.
1015 $preservable = array( 'required', 'description', 'placeholder', 'css' );
1016
1017 foreach ( ( $ai_response['fields'] ?? array() ) as $i => $ai_field ) {
1018 $key = ( $ai_field['type'] ?? '' ) . '||' . ( $ai_field['label'] ?? '' );
1019 if ( ! isset( $existing_index[ $key ] ) ) {
1020 continue;
1021 }
1022
1023 $existing = $existing_index[ $key ];
1024
1025 foreach ( $sticky_flags as $flag ) {
1026 if ( '1' === ( $existing[ $flag ] ?? '0' ) ) {
1027 $ai_response['fields'][ $i ][ $flag ] = '1';
1028 }
1029 }
1030
1031 foreach ( $preservable as $prop ) {
1032 if ( ! array_key_exists( $prop, $ai_field )
1033 && ! empty( $existing[ $prop ] )
1034 && '0' !== (string) $existing[ $prop ] ) {
1035 $ai_response['fields'][ $i ][ $prop ] = $existing[ $prop ];
1036 }
1037 }
1038 }
1039
1040 return $ai_response;
1041 }
1042
1043 /**
1044 * Validate redirect_to — only accept known values, fall back to 'same'.
1045 */
1046 private static function validate_redirect_to( string $val ): string {
1047 return in_array( $val, array( 'same', 'custom_page', 'external_url' ), true ) ? $val : 'same';
1048 }
1049
1050 /**
1051 * Resolve AI reply_to value.
1052 * "auto" or empty → use $default (auto-detected email field smart tag or admin email).
1053 * Anything else → sanitize and use as-is (explicit email or smart tag).
1054 */
1055 private static function resolve_reply_to( string $val, string $default ): string {
1056 if ( '' === $val || 'auto' === $val ) {
1057 return $default;
1058 }
1059 return sanitize_text_field( $val );
1060 }
1061
1062 /**
1063 * Check whether reCAPTCHA (any type) has a site key configured in EVF settings.
1064 * Used to avoid enabling recaptcha_support when no key is set up.
1065 */
1066 public static function is_recaptcha_configured(): bool {
1067 $type = get_option( 'everest_forms_recaptcha_type', 'v2' );
1068 switch ( $type ) {
1069 case 'v2':
1070 return (bool) get_option( 'everest_forms_recaptcha_v2_site_key' );
1071 case 'v2_invisible':
1072 return (bool) get_option( 'everest_forms_recaptcha_v2_invisible_site_key' );
1073 case 'v3':
1074 return (bool) get_option( 'everest_forms_recaptcha_v3_site_key' );
1075 case 'hcaptcha':
1076 return (bool) get_option( 'everest_forms_recaptcha_hcaptcha_site_key' );
1077 case 'turnstile':
1078 return (bool) get_option( 'everest_forms_recaptcha_turnstile_site_key' );
1079 }
1080 return false;
1081 }
1082
1083 /**
1084 * Generate a unique field ID in EVF format: 8 random alphanumeric chars.
1085 * E.g. "a3f9b2c1"
1086 */
1087 private static function generate_field_id(): string {
1088 return substr( md5( uniqid( '', true ) ), 0, 8 );
1089 }
1090
1091 /**
1092 * Generate meta-key from label. Falls back to field_id suffix if label is empty.
1093 * EVF convention: lowercase, underscored, no special chars.
1094 * E.g. "Email Address" → "email_address"
1095 */
1096 private static function generate_meta_key( string $label, string $field_id ): string {
1097 $key = strtolower( trim( $label ) );
1098 $key = preg_replace( '/[^a-z0-9]+/', '_', $key );
1099 $key = trim( $key, '_' );
1100 return $key ?: 'field_' . substr( $field_id, 0, 4 );
1101 }
1102 }
1103