PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / jetpack_vendor / automattic / jetpack-forms / src / contact-form / class-feedback-field.php

class-feedback-field.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3, at jetpack_vendor/automattic/jetpack-forms/src/contact-form/class-feedback-field.php

1,330 lines 37.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Feedback_Field class.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8 namespace Automattic\Jetpack\Forms\ContactForm;
9
10 use Automattic\Jetpack\Forms\Jetpack_Forms;
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit( 0 );
14 }
15
16 /**
17 * Feedback field class.
18 *
19 * Represents the submitted form data of an individual field.
20 */
21 class Feedback_Field {
22 use Country_Code_Utils;
23
24 /**
25 * Maximum number of rating icons to render.
26 *
27 * The scale is visitor input, so every renderer that loops over it needs this bound.
28 * Mirrors `MAX_RATING_ICONS` in `blocks/field-rating/rating-icons.js`.
29 *
30 * @var int
31 */
32 const MAX_RATING_ICONS = 10;
33
34 /**
35 * Cached admin theme color.
36 *
37 * @var string|null
38 */
39 private static $admin_theme_color = null;
40
41 /**
42 * The key of the field.
43 *
44 * @var string
45 */
46 private $key;
47
48 /**
49 * The label of the field.
50 *
51 * @var string
52 */
53 private $label;
54
55 /**
56 * The value of the field.
57 *
58 * @var mixed
59 */
60 private $value;
61
62 /**
63 * The type of the field.
64 *
65 * @var string
66 */
67 private $type;
68
69 /**
70 * Additional metadata for the field.
71 *
72 * @var array
73 */
74 private $meta;
75
76 /**
77 * The original form field ID from the form schema.
78 *
79 * @since 5.5.0
80 *
81 * @var string
82 */
83 protected $form_field_id = '';
84
85 /**
86 * Constructor.
87 *
88 * @param string $key The key of the field.
89 * @param mixed $label The label of the field. Non-string values will be converted to empty string.
90 * @param mixed $value The value of the field.
91 * @param string $type The type of the field (default is 'basic').
92 * @param array $meta Additional metadata for the field (default is an empty array).
93 * @param string|null $form_field_id The original form field ID (default is null).
94 */
95 public function __construct( $key, $label, $value, $type = 'basic', $meta = array(), $form_field_id = null ) {
96 $this->key = $key;
97 $this->label = is_string( $label ) ? html_entity_decode( $label, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) : '';
98 $this->value = $value;
99 $this->type = $type;
100 $this->meta = $meta;
101 $this->form_field_id = is_string( $form_field_id ) ? $form_field_id : '';
102 }
103
104 /**
105 * Get the value of the field.
106 *
107 * @return string
108 */
109 public function get_key() {
110 return $this->key;
111 }
112
113 /**
114 * Get the label of the field.
115 *
116 * @param string $context The context in which the label is being rendered (default is 'default').
117 * @param int $count The count of the label occurrences (default is 1).
118 *
119 * @return string
120 */
121 public function get_label( $context = 'default', $count = 1 ) {
122
123 $postfix = $count > 1 ? " ({$count})" : '';
124
125 if ( in_array( $context, array( 'api', 'csv' ), true ) ) {
126 if ( empty( $this->label ) ) {
127 return __( 'Field', 'jetpack-forms' ) . $postfix;
128 }
129
130 return $this->label . $postfix;
131 }
132
133 return $this->label . $postfix;
134 }
135
136 /**
137 * Get the value of the field.
138 *
139 * @return mixed
140 */
141 public function get_value() {
142 return $this->value;
143 }
144
145 /**
146 * Whether a submitted checkbox/consent value means the box was ticked.
147 *
148 * An unticked box submits an empty value, and some stored responses use an
149 * explicit "No". The ticked value is a translated string ( "Yes" ), so this
150 * tests for emptiness and the "no" sentinel rather than matching "yes".
151 *
152 * Mirrored by `isCheckedValue()` in src/modules/form/helpers.js and in the
153 * dashboard's field-icons.tsx, which must agree with this.
154 *
155 * @param mixed $value The submitted value.
156 *
157 * @return bool True when the box was ticked.
158 */
159 public static function is_checked_value( $value ) {
160 if ( is_array( $value ) ) {
161 return ! empty( $value );
162 }
163
164 if ( ! is_scalar( $value ) ) {
165 return false;
166 }
167
168 // Normalize before testing so a whitespace-only value counts as unticked —
169 // `empty( ' ' )` is false, so trimming has to happen first.
170 $normalized = strtolower( trim( (string) $value ) );
171
172 return '' !== $normalized && '0' !== $normalized && 'no' !== $normalized;
173 }
174
175 /**
176 * Get the original form field ID.
177 *
178 * @since 5.5.0
179 *
180 * @return string
181 */
182 public function get_form_field_id() {
183 return $this->form_field_id;
184 }
185
186 /**
187 * Get the value of the field for rendering.
188 *
189 * @param string $context The context in which the value is being rendered (default is 'default').
190 *
191 * @return string
192 */
193 public function get_render_value( $context = 'default' ) {
194 switch ( $context ) {
195 case 'submit':
196 return $this->get_render_submit_value();
197 case 'api':
198 return $this->get_render_api_value();
199 case 'web': // For the post-submission page screen.
200 return $this->get_render_web_value();
201 case 'email':
202 return $this->get_render_email_value();
203 case 'email_html':
204 return $this->get_render_email_html_value();
205 case 'ajax':
206 return $this->get_render_web_value(); // For now, we use the same value for ajax and web.
207 case 'csv':
208 return $this->get_render_csv_value();
209 case 'default':
210 default:
211 return $this->get_render_default_value();
212 }
213 }
214
215 /**
216 * Get the value of the field for rendering the CSV.
217 *
218 * @return string
219 */
220 private function get_render_csv_value() {
221 // A CSV cell is plain text, so it can hold the text as typed.
222 return Feedback::decode_special_chars( $this->get_render_csv_text() );
223 }
224
225 /**
226 * Get the stored text of the field for the CSV.
227 *
228 * @return string
229 */
230 private function get_render_csv_text() {
231 if ( $this->is_of_type( 'image-select' ) ) {
232 return implode(
233 ', ',
234 array_map(
235 function ( $choice ) {
236 $value = $choice['selected'];
237
238 if ( ! empty( $choice['label'] ) ) {
239 $value .= ' - ' . $choice['label'];
240 }
241
242 return $value;
243 },
244 $this->value['choices']
245 )
246 );
247 }
248
249 if ( $this->value === null ) {
250 return '';
251 }
252
253 return $this->get_render_default_value();
254 }
255
256 /**
257 * Get the value of the field for rendering the post-submission page.
258 *
259 * @return string|array
260 */
261 private function get_render_web_value() {
262 if ( $this->is_of_type( 'image-select' ) ) {
263 return $this->value;
264 }
265
266 // For phone fields, add country flag before the number.
267 if ( $this->is_of_type( 'phone' ) || $this->is_of_type( 'telephone' ) ) {
268 return $this->get_phone_value_with_flag();
269 }
270
271 // For URL fields, return a structured array with the URL for proper link rendering.
272 // 'displayValue' preserves the original user input for display text.
273 // 'url' is used for the href and may have https:// prepended.
274 if ( $this->is_of_type( 'url' ) ) {
275 if ( ! empty( $this->value ) ) {
276 return array(
277 'type' => 'url',
278 'url' => $this->value,
279 'displayValue' => $this->value,
280 );
281 }
282 }
283
284 // For file fields, return a structured array with file metadata for proper rendering.
285 if ( $this->is_of_type( 'file' ) ) {
286 $files = array();
287 if ( isset( $this->value['files'] ) && is_array( $this->value['files'] ) ) {
288 foreach ( $this->value['files'] as $file ) {
289 if ( ! isset( $file['size'] ) || ! isset( $file['file_id'] ) ) {
290 continue;
291 }
292 $file_id = absint( $file['file_id'] );
293 $files[] = array(
294 'file_id' => $file_id,
295 'name' => $file['name'] ?? __( 'Attached file', 'jetpack-forms' ),
296 'size' => size_format( $file['size'] ),
297 'url' => apply_filters( 'jetpack_unauth_file_download_url', '', $file_id ),
298 );
299 }
300 }
301 return array(
302 'type' => 'file',
303 'files' => $files,
304 );
305 }
306
307 // For rating fields, return a structured array with rating data for star/heart display.
308 if ( $this->is_of_type( 'rating' ) ) {
309 return $this->get_rating_value();
310 }
311
312 return $this->get_render_default_value();
313 }
314
315 /**
316 * Get phone value with country flag emoji.
317 *
318 * @return string Phone number with country flag prefix.
319 */
320 private function get_phone_value_with_flag() {
321 // Field values arrive as `mixed` (per the constructor); short-circuit
322 // to an empty string for non-string values.
323 if ( ! is_string( $this->value ) ) {
324 return '';
325 }
326
327 if ( empty( $this->value ) ) {
328 return $this->value;
329 }
330
331 // Try to extract country code from phone number prefix.
332 $country_code = $this->get_country_code_from_phone( $this->value );
333
334 if ( ! empty( $country_code ) ) {
335 $flag = self::country_code_to_emoji_flag( $country_code );
336 if ( ! empty( $flag ) ) {
337 return $flag . ' ' . $this->value;
338 }
339 }
340
341 return $this->value;
342 }
343
344 /**
345 * Extract country code from phone number based on its prefix.
346 *
347 * @param string $phone_number The phone number with country prefix (e.g., "+49 123456789").
348 *
349 * @return string|null The ISO country code (e.g., "DE") or null if not found.
350 */
351 private function get_country_code_from_phone( $phone_number ) {
352 // Remove spaces and normalize the phone number.
353 $normalized = preg_replace( '/\s+/', '', $phone_number );
354
355 // Must start with + for international format.
356 if ( strpos( $normalized, '+' ) !== 0 ) {
357 return null;
358 }
359
360 $prefix_to_country = self::get_phone_prefix_to_country_map();
361
362 foreach ( $prefix_to_country as $prefix => $country ) {
363 if ( strpos( $normalized, $prefix ) === 0 ) {
364 return $country;
365 }
366 }
367
368 return null;
369 }
370
371 /**
372 * Get rating value as a structured array for web rendering.
373 *
374 * Parses the rating value (format: "rating/max" e.g., "3/5") and returns
375 * a structured array with the rating, max, and iconStyle for star/heart display.
376 *
377 * @return array|string Structured rating data or original value if parsing fails.
378 */
379 private function get_rating_value() {
380 // Field values arrive as `mixed` (per the constructor); short-circuit
381 // to an empty string for non-string values.
382 if ( ! is_string( $this->value ) ) {
383 return '';
384 }
385
386 if ( empty( $this->value ) ) {
387 return $this->value;
388 }
389
390 // Parse the rating value format: "rating/max" (e.g., "3/5").
391 $parts = explode( '/', $this->value );
392 if ( count( $parts ) !== 2 ) {
393 return $this->value;
394 }
395
396 $rating = (int) $parts[0];
397 $max = (int) $parts[1];
398
399 // Validate parsed values.
400 if ( $rating < 0 || $max <= 0 ) {
401 return $this->value;
402 }
403
404 if ( $rating > $max ) {
405 return $this->value;
406 }
407
408 $max = min( $max, self::MAX_RATING_ICONS );
409 $rating = min( $rating, $max );
410
411 // Get icon style from meta data (defaults to 'stars').
412 $icon_style = $this->get_meta_key_value( 'iconStyle' );
413 if ( empty( $icon_style ) ) {
414 $icon_style = 'stars';
415 }
416
417 return array(
418 'type' => 'rating',
419 'rating' => $rating,
420 'maxRating' => $max,
421 'iconStyle' => $icon_style,
422 'displayValue' => $this->value,
423 );
424 }
425
426 /**
427 * Get the value of the field for rendering the email.
428 *
429 * Returns structured data for type-aware rendering when possible,
430 * similar to get_render_web_value(). The escape_and_sanitize_field_value()
431 * method in Contact_Form already handles all these structured types.
432 *
433 * @return mixed
434 */
435 private function get_render_email_value() {
436 // Phone: string with country flag prefix.
437 if ( $this->is_of_type( 'phone' ) || $this->is_of_type( 'telephone' ) ) {
438 return $this->get_phone_value_with_flag();
439 }
440
441 // URL: structured array for link rendering.
442 if ( $this->is_of_type( 'url' ) && ! empty( $this->value ) ) {
443 return array(
444 'type' => 'url',
445 'url' => $this->value,
446 'displayValue' => $this->value,
447 );
448 }
449
450 // File: return raw value (has field_id + files keys).
451 if ( $this->is_of_type( 'file' ) ) {
452 return $this->value;
453 }
454
455 // Rating: structured array with rating data.
456 if ( $this->is_of_type( 'rating' ) ) {
457 return $this->get_rating_value();
458 }
459
460 // Image-select: keep current string format for backward compat.
461 if ( $this->is_of_type( 'image-select' ) ) {
462 $choices = array();
463
464 foreach ( $this->value['choices'] as $choice ) {
465 // On the email, we want to show the actual selected value, not the perceived value, as the options can be shuffled.
466 $value = $choice['selected'];
467
468 if ( ! empty( $choice['label'] ) ) {
469 $value .= ' - ' . $choice['label'];
470 }
471 $choices[] = $value;
472 }
473
474 return implode( ', ', $choices );
475 }
476
477 // Checkbox-multiple: preserve array for chip rendering.
478 if ( $this->is_of_type( 'checkbox-multiple' ) && is_array( $this->value ) ) {
479 return $this->value;
480 }
481
482 return $this->get_render_default_value();
483 }
484
485 /**
486 * Get the value of the field rendered as final HTML for the email template.
487 *
488 * Unlike get_render_email_value() which returns structured data for the
489 * backward-compat filter path, this returns ready-to-use HTML for the
490 * type-aware email rendering path.
491 *
492 * @return string HTML for the field value.
493 */
494 private function get_render_email_html_value() {
495 if ( $this->is_of_type( 'select' ) || $this->is_of_type( 'radio' ) || $this->is_of_type( 'checkbox-multiple' ) ) {
496 return $this->render_email_chips( $this->value );
497 }
498 if ( $this->is_of_type( 'checkbox' ) || $this->is_of_type( 'consent' ) ) {
499 return $this->render_email_consent();
500 }
501 if ( $this->is_of_type( 'phone' ) || $this->is_of_type( 'telephone' ) ) {
502 return $this->render_email_phone();
503 }
504 if ( $this->is_of_type( 'url' ) ) {
505 return $this->render_email_url();
506 }
507 if ( $this->is_of_type( 'rating' ) ) {
508 return $this->render_email_rating();
509 }
510 if ( $this->is_of_type( 'file' ) ) {
511 return $this->render_email_file();
512 }
513 if ( $this->is_of_type( 'image-select' ) ) {
514 return $this->render_email_image_select();
515 }
516 return $this->render_email_default();
517 }
518
519 /**
520 * Render an empty value HTML.
521 *
522 * @return string HTML for empty values.
523 */
524 private function render_empty_value_html() {
525 return '<span style="color: ' . Feedback_Email_Renderer::TEXT_SECONDARY_COLOR . ';">&mdash;</span>';
526 }
527
528 /**
529 * Render a default text value for email (text, name, email, textarea, date, time, etc).
530 *
531 * @return string Escaped and formatted HTML.
532 */
533 private function render_email_default() {
534 if ( empty( $this->value ) && $this->value !== '0' ) {
535 return $this->render_empty_value_html();
536 }
537
538 return Contact_Form::escape_and_sanitize_field_value( $this->value );
539 }
540
541 /**
542 * Render tag/chip values for select, radio, and checkbox-multiple fields.
543 *
544 * @param mixed $value The field value (string or array).
545 * @return string HTML with rounded chip elements.
546 */
547 private function render_email_chips( $value ) {
548 if ( empty( $value ) && $value !== '0' ) {
549 return $this->render_empty_value_html();
550 }
551
552 $values = is_array( $value ) ? $value : array( $value );
553 $chips = array();
554
555 foreach ( $values as $item ) {
556 $safe_item = esc_html( is_string( $item ) ? $item : (string) $item );
557 if ( $safe_item === '' ) {
558 continue;
559 }
560 $chips[] = sprintf(
561 '<div style="display: inline-block; height: 24px; padding: 0 8px; margin: 2px 4px 2px 0; background-color: #f0f0f0; border-radius: 2px; font-size: ' . Feedback_Email_Renderer::FONT_SIZE_FIELD_VALUE . '; line-height: 24px; color: %s;">%s</div>',
562 Feedback_Email_Renderer::TEXT_COLOR,
563 $safe_item
564 );
565 }
566
567 if ( empty( $chips ) ) {
568 return $this->render_empty_value_html();
569 }
570
571 return implode( '<br />', $chips );
572 }
573
574 /**
575 * Render a consent/checkbox field value as a Yes/No chip.
576 *
577 * @return string HTML with a colored chip.
578 */
579 private function render_email_consent() {
580 $is_yes = self::is_checked_value( $this->value );
581 $label = $is_yes ? __( 'Yes', 'jetpack-forms' ) : __( 'No', 'jetpack-forms' );
582
583 return sprintf(
584 '<span style="display: inline-block; padding: 0 8px; border-radius: 2px; font-size: ' . Feedback_Email_Renderer::FONT_SIZE_FIELD_VALUE . '; line-height: 1.4; background-color: #f0f0f0; color: %s;">%s</span>',
585 Feedback_Email_Renderer::TEXT_COLOR,
586 esc_html( $label )
587 );
588 }
589
590 /**
591 * Render a phone field value as a clickable tel: link.
592 *
593 * @return string HTML with tel: link.
594 */
595 private function render_email_phone() {
596 // Guard against non-string values for the same reason as
597 // get_phone_value_with_flag().
598 if ( ! is_string( $this->value ) || empty( $this->value ) ) {
599 return $this->render_empty_value_html();
600 }
601
602 $raw_phone = preg_replace( '/[^\d+]/', '', $this->value );
603 $country_code = $this->get_country_code_from_phone( $this->value );
604 $flag_prefix = '';
605
606 if ( ! empty( $country_code ) ) {
607 $flag = self::country_code_to_emoji_flag( $country_code );
608 if ( ! empty( $flag ) ) {
609 $flag_prefix = $flag . ' ';
610 }
611 }
612
613 return $flag_prefix . sprintf(
614 '<a href="tel:%1$s" style="color: %3$s; text-decoration: underline;">%2$s</a>',
615 esc_attr( $raw_phone ),
616 esc_html( $this->value ),
617 self::get_admin_theme_color()
618 );
619 }
620
621 /**
622 * Render a URL field value as a clickable link.
623 *
624 * @return string HTML with clickable link.
625 */
626 private function render_email_url() {
627 if ( ! is_string( $this->value ) || empty( $this->value ) ) {
628 return $this->render_empty_value_html();
629 }
630
631 $url = $this->value;
632
633 // Prepend scheme if missing so the href is valid, but display the original input.
634 if ( ! preg_match( '/^https?:\/\//i', $url ) ) {
635 $url = 'https://' . $url;
636 }
637
638 return sprintf(
639 '<a href="%1$s" style="color: %3$s; text-decoration: underline;" target="_blank">%2$s</a>',
640 esc_url( $url ),
641 esc_html( $this->value ),
642 self::get_admin_theme_color()
643 );
644 }
645
646 /**
647 * Render a rating field value as star characters.
648 *
649 * @return string HTML with gold/gray stars.
650 */
651 private function render_email_rating() {
652 if ( empty( $this->value ) || ! is_string( $this->value ) || strpos( $this->value, '/' ) === false ) {
653 return $this->render_email_default();
654 }
655
656 $parts = explode( '/', $this->value );
657 if ( count( $parts ) !== 2 ) {
658 return $this->render_email_default();
659 }
660
661 $rating = (int) $parts[0];
662 $max = (int) $parts[1];
663
664 if ( $max <= 0 ) {
665 return $this->render_email_default();
666 }
667
668 $max = min( $max, self::MAX_RATING_ICONS );
669 $rating = min( $rating, $max );
670
671 $stars = '';
672 for ( $i = 1; $i <= $max; $i++ ) {
673 if ( $i <= $rating ) {
674 $stars .= '<span style="color: #e6a117; font-size: 20px;">&#9733;</span>';
675 } else {
676 $stars .= '<span style="color: #cccccc; font-size: 20px;">&#9733;</span>';
677 }
678 }
679
680 return $stars;
681 }
682
683 /**
684 * Render a file field value with thumbnail, file name, size, and download icon.
685 *
686 * @return string HTML with file info.
687 */
688 private function render_email_file() {
689 // We already know the field is type 'file' (dispatched from get_render_email_html_value).
690 // The value may or may not contain 'field_id' depending on how it was loaded,
691 // so we only check for the 'files' array rather than using is_file_upload_field().
692 if ( ! is_array( $this->value ) || ! isset( $this->value['files'] ) || ! is_array( $this->value['files'] ) ) {
693 return $this->render_email_default();
694 }
695
696 $files = $this->value['files'];
697 if ( empty( $files ) ) {
698 return $this->render_empty_value_html();
699 }
700
701 $file_items = array();
702 foreach ( $files as $file ) {
703 if ( empty( $file['file_id'] ) ) {
704 continue;
705 }
706
707 $file_name = $file['name'] ?? __( 'Attached file', 'jetpack-forms' );
708 $file_size = isset( $file['size'] ) ? size_format( $file['size'] ) : '';
709 $file_url = apply_filters( 'jetpack_unauth_file_download_url', '', absint( $file['file_id'] ) );
710 $file_type = $file['type'] ?? '';
711
712 $file_items[] = $this->render_email_file_row( $file_name, $file_size, $file_url, $file_type );
713 }
714
715 if ( empty( $file_items ) ) {
716 return $this->render_empty_value_html();
717 }
718
719 return implode( '', $file_items );
720 }
721
722 /**
723 * Render a single file row with thumbnail, name/size, and download icon.
724 *
725 * @param string $file_name The file name.
726 * @param string $file_size The formatted file size.
727 * @param string $file_url The download URL.
728 * @param string $file_type The MIME type of the file.
729 * @return string HTML table for the file row.
730 */
731 private function render_email_file_row( $file_name, $file_size, $file_url, $file_type = '' ) {
732 $thumbnail_html = $this->get_file_thumbnail_html( $file_name, $file_type );
733
734 // File name — linked if download URL is available.
735 $name_html = esc_html( $file_name );
736 if ( ! empty( $file_url ) ) {
737 $name_html = sprintf(
738 '<a href="%1$s" style="color: %2$s; text-decoration: underline;" target="_blank">%3$s</a>',
739 esc_url( $file_url ),
740 Feedback_Email_Renderer::TEXT_COLOR,
741 $name_html
742 );
743 }
744
745 // File size on a second line.
746 $size_html = '';
747 if ( ! empty( $file_size ) ) {
748 $size_html = sprintf(
749 '<div style="font-size: 12px; color: %1$s; line-height: 1.4;">%2$s</div>',
750 Feedback_Email_Renderer::TEXT_SECONDARY_COLOR,
751 esc_html( $file_size )
752 );
753 }
754
755 // Download icon (rasterized from @wordpress/icons 'download').
756 $download_icon = '';
757 if ( ! empty( $file_url ) ) {
758 $download_icon_url = Jetpack_Forms::plugin_url() . 'contact-form/images/file-icons/[email protected]';
759 $download_icon = sprintf(
760 '<a href="%1$s" target="_blank" style="text-decoration: none;"><img src="%2$s" width="20" height="20" alt="%3$s" style="display: block; width: 20px; height: 20px; -webkit-user-select: none; user-select: none;" /></a>',
761 esc_url( $file_url ),
762 esc_url( $download_icon_url ),
763 esc_attr__( 'Download', 'jetpack-forms' )
764 );
765 }
766
767 // Build the file row as a table: [thumbnail] [name + size] [download icon].
768 $html = '<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-top: 4px;">';
769 $html .= '<tr>';
770
771 // Thumbnail cell.
772 $html .= '<td width="40" valign="middle" style="padding-right: 12px; width: 40px; vertical-align: middle; text-align: center;">';
773 $html .= $thumbnail_html;
774 $html .= '</td>';
775
776 // Name and size cell.
777 $html .= '<td valign="middle" style="font-size: 13px; line-height: 1.4;">';
778 $html .= '<div>' . $name_html . '</div>';
779 $html .= $size_html;
780 $html .= '</td>';
781
782 // Download icon cell.
783 if ( ! empty( $download_icon ) ) {
784 $html .= '<td width="20" valign="middle" align="right" style="padding-left: 12px; width: 20px;">';
785 $html .= $download_icon;
786 $html .= '</td>';
787 }
788
789 $html .= '</tr>';
790 $html .= '</table>';
791
792 return $html;
793 }
794
795 /**
796 * Get the thumbnail HTML for a file attachment.
797 *
798 * For previewable files (images: jpg, jpeg, png, gif, webp), uses the actual
799 * file URL as the thumbnail when available. For other file types, falls back
800 * to a file-type icon from the file-icons directory.
801 *
802 * @param string $file_name The original file name (used for extension-based icon lookup).
803 * @param string $file_type The MIME type of the file.
804 * @return string HTML for the thumbnail.
805 */
806 private function get_file_thumbnail_html( $file_name = '', $file_type = '' ) {
807 $icon_name = self::get_file_icon_name( $file_name, $file_type );
808 $icon_url = Jetpack_Forms::plugin_url() . 'contact-form/images/file-icons/' . $icon_name . '@2x.png';
809
810 return sprintf(
811 '<img src="%1$s" width="24" height="24" alt=""
812 style="padding: 8px; border-radius: 50%%; width: 24px; height: 24px; background-color: #f0f0f0; -webkit-user-select: none; user-select: none;" />',
813 esc_url( $icon_url )
814 );
815 }
816
817 /**
818 * Map a file to its icon name based on extension then MIME type category.
819 *
820 * Mirrors the JS logic in modules/file-field/view.js getFileIcon().
821 *
822 * @param string $file_name The file name.
823 * @param string $file_type The MIME type.
824 * @return string The icon filename without extension.
825 */
826 private static function get_file_icon_name( $file_name, $file_type ) {
827 $extension = strtolower( pathinfo( $file_name, PATHINFO_EXTENSION ) );
828
829 $extension_map = array(
830 'pdf' => 'pdf',
831 'doc' => 'txt',
832 'docx' => 'txt',
833 'txt' => 'txt',
834 'ppt' => 'ppt',
835 'pptx' => 'ppt',
836 'xls' => 'xls',
837 'xlsx' => 'xls',
838 'csv' => 'xls',
839 'zip' => 'zip',
840 'sql' => 'sql',
841 'cal' => 'cal',
842 'html' => 'html',
843 'mp3' => 'mp3',
844 'mp4' => 'mp4',
845 'png' => 'png',
846 'jpg' => 'png',
847 'jpeg' => 'png',
848 'gif' => 'png',
849 'webp' => 'png',
850 );
851
852 if ( isset( $extension_map[ $extension ] ) ) {
853 return $extension_map[ $extension ];
854 }
855
856 // Fall back to MIME type category.
857 $category = explode( '/', $file_type )[0] ?? '';
858 $category_map = array(
859 'image' => 'png',
860 'video' => 'mp4',
861 'audio' => 'mp3',
862 );
863
864 return $category_map[ $category ] ?? 'txt';
865 }
866
867 /**
868 * Render an image-select field for email.
869 *
870 * Renders each selected choice as a card with an image thumbnail,
871 * letter code, and label arranged horizontally.
872 *
873 * @return string HTML for the image-select field.
874 */
875 private function render_email_image_select() {
876 if ( ! is_array( $this->value ) || empty( $this->value['choices'] ) || ! is_array( $this->value['choices'] ) ) {
877 return $this->render_empty_value_html();
878 }
879
880 $cards = array();
881 foreach ( $this->value['choices'] as $choice ) {
882 $letter = isset( $choice['selected'] ) ? esc_html( $choice['selected'] ) : '';
883 $label = ! empty( $choice['label'] ) ? esc_html( $choice['label'] ) : '';
884 $image_src = ! empty( $choice['image']['src'] ) ? esc_url( $choice['image']['src'] ) : '';
885 $show_label = ! empty( $choice['showLabels'] );
886
887 // Image thumbnail or gray placeholder at 138×144.
888 if ( $image_src !== '' ) {
889 $image_html = sprintf(
890 '<div style="padding: 8px 8px 0 8px;"><img src="%s" alt="%s" width="138" height="144" style="display: block; width: 138px; height: 144px; object-fit: cover;" /></div>',
891 $image_src,
892 $label !== '' ? $label : $letter
893 );
894 } else {
895 $placeholder_icon = Jetpack_Forms::plugin_url() . 'contact-form/images/field-icons/[email protected]';
896 $image_html = sprintf(
897 '<div style="padding: 8px 8px 0 8px;"><div style="width: 138px; height: 144px; background-color: #f0f0f0; text-align: center; line-height: 144px;"><img src="%s" alt="" width="24" height="24" style="vertical-align: middle;" /></div></div>',
898 esc_url( $placeholder_icon )
899 );
900 }
901
902 // Letter code box + label.
903 $caption_html = '';
904 if ( $letter !== '' ) {
905 $caption_html .= sprintf(
906 '<span style="display: inline-block; min-width: 1em; padding: 4px; line-height: 1; text-align: center; border: 1px solid #dcdcde; border-radius: 2px; font-size: 11px; font-weight: 600; color: #1e1e1e; vertical-align: baseline;">%s</span>',
907 $letter
908 );
909 }
910
911 if ( $show_label && $label !== '' ) {
912 $caption_html .= sprintf(
913 ' <span style="font-size: 13px; color: #1e1e1e; vertical-align: baseline;">%s</span>',
914 $label
915 );
916 }
917
918 // Card with fixed width matching the admin preview (138px image + 16px padding).
919 $card = '<div style="display: inline-block; vertical-align: top; width: 154px; border: 1px solid #dcdcde; border-radius: 8px; margin: 0 8px 8px 0;">';
920 $card .= $image_html;
921 if ( $caption_html !== '' ) {
922 $card .= sprintf(
923 '<div style="padding: 4px 8px 8px 8px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;">%s</div>',
924 $caption_html
925 );
926 }
927 $card .= '</div>';
928
929 $cards[] = $card;
930 }
931
932 if ( empty( $cards ) ) {
933 return $this->render_empty_value_html();
934 }
935
936 return implode( '', $cards );
937 }
938
939 /**
940 * Get the uploaded files of a file field.
941 *
942 * The stored value of a file field is normally an array with a `files` key,
943 * but a feedback can carry a malformed value — an empty string, for
944 * instance — so callers must never assume that shape.
945 *
946 * @since 7.26.0
947 *
948 * @return array The list of files, empty when the value holds none.
949 */
950 private function get_file_list() {
951 if ( ! is_array( $this->value ) || ! isset( $this->value['files'] ) || ! is_array( $this->value['files'] ) ) {
952 return array();
953 }
954
955 return $this->value['files'];
956 }
957
958 /**
959 * Get the default value of the field for rendering.
960 *
961 * @return string
962 */
963 private function get_render_default_value() {
964 if ( $this->is_of_type( 'file' ) ) {
965 $files = array();
966 foreach ( $this->get_file_list() as $file ) {
967 if ( ! isset( $file['size'] ) || ! isset( $file['file_id'] ) ) {
968 // this shouldn't happen, todo: log this
969 continue;
970 }
971 $file_name = $file['name'] ?? __( 'Attached file', 'jetpack-forms' );
972 $file_size = isset( $file['size'] ) ? size_format( $file['size'] ) : '';
973 $files[] = $file_name . ' (' . $file_size . ')';
974 }
975 return implode( ', ', $files );
976 }
977
978 if ( $this->is_of_type( 'image-select' ) ) {
979 // Return the array as is.
980 return $this->value;
981 }
982
983 if ( is_array( $this->value ) ) {
984 return implode( ', ', $this->value );
985 }
986
987 return $this->value;
988 }
989
990 /**
991 * Get the value of the field for the API.
992 *
993 * File, image-select and checkbox-multiple fields answer with the structured
994 * value the dashboard expects; everything else answers with a string.
995 *
996 * @return array|string The value for the API context.
997 */
998 private function get_render_api_value() {
999 if ( $this->is_of_type( 'file' ) ) {
1000 $files = array();
1001 $value = is_array( $this->value ) ? $this->value : array();
1002 foreach ( $this->get_file_list() as $file ) {
1003 if ( ! isset( $file['size'] ) || ! isset( $file['file_id'] ) ) {
1004 // this shouldn't happen, todo: log this
1005 continue;
1006 }
1007 $file_id = absint( $file['file_id'] );
1008 $file['file_id'] = $file_id;
1009 $file['size'] = size_format( $file['size'] );
1010 $file['url'] = apply_filters( 'jetpack_unauth_file_download_url', '', $file_id );
1011 $file['is_previewable'] = $this->is_previewable_file( $file );
1012 $files[] = $file;
1013 }
1014 $value['files'] = $files;
1015 return $value;
1016 }
1017
1018 if ( $this->is_of_type( 'image-select' ) ) {
1019 // Return the array as is.
1020 return $this->value;
1021 }
1022
1023 if ( $this->is_of_type( 'checkbox-multiple' ) ) {
1024 // Since API gets format: collection, return the array as is.
1025 return $this->value;
1026 }
1027
1028 if ( is_array( $this->value ) ) {
1029 // If the value is an array, we can return it as a JSON string.
1030 return implode( ', ', $this->value );
1031 }
1032 // This method is deprecated, use render_value instead.
1033 return $this->value;
1034 }
1035 /**
1036 * Get the value of the field for rendering when submitting.
1037 *
1038 * This method is used to prepare the value for submission, especially for file fields.
1039 *
1040 * @return array|string The prepared value for submission.
1041 */
1042 private function get_render_submit_value() {
1043 if ( $this->is_of_type( 'file' ) ) {
1044 $files = array();
1045 foreach ( $this->get_file_list() as $file ) {
1046 if ( ! isset( $file['size'] ) || ! isset( $file['file_id'] ) ) {
1047 // this shouldn't happen, todo: log this
1048 continue;
1049 }
1050 $files[] = array(
1051 'file_id' => absint( $file['file_id'] ),
1052 'name' => $file['name'] ?? '',
1053 'size' => absint( $file['size'] ),
1054 'type' => $file['type'] ?? '',
1055 );
1056 }
1057
1058 return array(
1059 'field_id' => $this->get_form_field_id(),
1060 'files' => $files,
1061 );
1062 }
1063
1064 return $this->value;
1065 }
1066
1067 /**
1068 * Check if the field is of a specific type.
1069 *
1070 * @param string $type The type to check against.
1071 *
1072 * @return bool True if the field is of the specified type, false otherwise.
1073 */
1074 public function is_of_type( $type ) {
1075 return $this->type === $type;
1076 }
1077
1078 /**
1079 * Check if the field should be compiled.
1080 *
1081 * @return bool
1082 */
1083 public function compile_field() {
1084 return $this->get_meta_key_value( 'render' ) === false;
1085 }
1086
1087 /**
1088 * Get the type of the field.
1089 *
1090 * @return string
1091 */
1092 public function get_type() {
1093 return $this->type;
1094 }
1095
1096 /**
1097 * Get the icon filename for a given field type.
1098 *
1099 * @param string $type The field type.
1100 * @return string The icon name (without path or extension).
1101 */
1102 public static function get_icon_name_for_type( $type ) {
1103 $map = array(
1104 'text' => 'field-text',
1105 'name' => 'field-name',
1106 'email' => 'field-email',
1107 'textarea' => 'field-textarea',
1108 'select' => 'field-select',
1109 'radio' => 'field-single-choice',
1110 'checkbox' => 'field-checkbox',
1111 'checkbox-multiple' => 'field-multiple-choice',
1112 'phone' => 'field-telephone',
1113 'telephone' => 'field-telephone',
1114 'number' => 'field-number',
1115 'slider' => 'field-slider',
1116 'date' => 'field-date',
1117 'time' => 'field-time',
1118 'url' => 'field-url',
1119 'rating' => 'field-rating',
1120 'image-select' => 'field-image-select',
1121 'file' => 'field-file',
1122 'consent' => 'field-consent',
1123 'hidden' => 'field-hidden',
1124 );
1125 return $map[ $type ] ?? 'field-text';
1126 }
1127
1128 /**
1129 * Get the WordPress admin theme color for use in email links.
1130 *
1131 * Resolves the site admin's admin_color preference to the matching
1132 * --wp-admin-theme-color hex value so email links visually match
1133 * the Forms dashboard.
1134 *
1135 * @return string Hex color string.
1136 */
1137 public static function get_admin_theme_color() {
1138 if ( self::$admin_theme_color !== null ) {
1139 return self::$admin_theme_color;
1140 }
1141
1142 $color_scheme = 'fresh';
1143 $admin_user = get_user_by( 'email', get_option( 'admin_email' ) );
1144 if ( $admin_user ) {
1145 $saved = get_user_option( 'admin_color', $admin_user->ID );
1146 if ( $saved ) {
1147 $color_scheme = $saved;
1148 }
1149 }
1150
1151 $map = array(
1152 'fresh' => '#2271b1',
1153 'light' => '#0085ba',
1154 'blue' => '#096484',
1155 'coffee' => '#c7a589',
1156 'ectoplasm' => '#a3b745',
1157 'midnight' => '#e14d43',
1158 'ocean' => '#9ebaa0',
1159 'sunrise' => '#dd823b',
1160 'modern' => '#3858e9',
1161 );
1162
1163 self::$admin_theme_color = $map[ $color_scheme ] ?? '#2271b1';
1164 return self::$admin_theme_color;
1165 }
1166
1167 /**
1168 * Get the meta array of the field.
1169 *
1170 * @return array
1171 */
1172 public function get_meta() {
1173 return $this->meta;
1174 }
1175
1176 /**
1177 * Get a specific meta value by key.
1178 *
1179 * @param string $meta_key The key of the meta to retrieve.
1180 *
1181 * @return mixed|null Returns the value of the meta key if it exists, null otherwise.
1182 */
1183 public function get_meta_key_value( $meta_key ) {
1184 if ( isset( $this->meta[ $meta_key ] ) ) {
1185 return $this->meta[ $meta_key ];
1186 }
1187 return null;
1188 }
1189
1190 /**
1191 * Get the serialized representation of the field.
1192 *
1193 * @return array
1194 */
1195 public function serialize() {
1196 return array(
1197 'key' => $this->get_key(),
1198 'label' => $this->get_label(),
1199 'value' => $this->get_value(),
1200 'type' => $this->get_type(),
1201 'meta' => $this->get_meta(),
1202 'form_field_id' => $this->get_form_field_id(),
1203 );
1204 }
1205 /**
1206 * Create a Feedback_Field object from serialized data.
1207 *
1208 * @param array $data The serialized data.
1209 *
1210 * @return Feedback_Field|null Returns a Feedback_Field object or null if the data is invalid.
1211 */
1212 public static function from_serialized( $data ) {
1213 if ( ! is_array( $data ) || ! isset( $data['key'] ) || ! isset( $data['value'] ) || ! isset( $data['label'] ) ) {
1214 return null;
1215 }
1216
1217 return new self(
1218 $data['key'],
1219 $data['label'],
1220 $data['value'],
1221 $data['type'] ?? 'basic',
1222 $data['meta'] ?? array(),
1223 $data['form_field_id'] ?? ''
1224 );
1225 }
1226
1227 /**
1228 * Normalize Unicode characters in a string.
1229 *
1230 * This is only used for V2 version of the feedback. Since we didn't escape special characters
1231 *
1232 * @param string $string The string to normalize.
1233 *
1234 * @return string
1235 */
1236 public static function normalize_unicode( $string ) {
1237 // Case 1: JSON-style escapes, e.g. "\u003cstrong\u003e" or "\ud83d\ude48"
1238 if ( strpos( $string, '\u' ) !== false ) {
1239 $decoded = json_decode( '"' . $string . '"' );
1240 if ( self::is_valid_json_decode( $decoded ) ) {
1241 return $decoded;
1242 }
1243 }
1244
1245 // Case 2: Raw surrogate dumps, e.g. "ud83dude48" or "u003cstrongu003e"
1246 if ( preg_match( '/u[0-9a-fA-F]{4}/', $string ) ) {
1247 // Add missing backslashes before each uXXXX
1248 $json_ready = preg_replace( '/u([0-9a-fA-F]{4})/', '\\\\u$1', $string );
1249 $decoded = json_decode( '"' . $json_ready . '"' );
1250 if ( self::is_valid_json_decode( $decoded ) ) {
1251 return $decoded;
1252 }
1253 }
1254
1255 // Fallback: return unchanged
1256 return $string;
1257 }
1258
1259 /**
1260 * Check if the decoded JSON is valid.
1261 *
1262 * @param mixed $decoded The decoded JSON data.
1263 * @return bool True if there are no errors, false otherwise.
1264 */
1265 private static function is_valid_json_decode( $decoded ) {
1266 return $decoded !== null && json_last_error() === JSON_ERROR_NONE;
1267 }
1268
1269 /**
1270 * Create a Feedback_Field object from serialized data.
1271 *
1272 * @param array $data The serialized data.
1273 *
1274 * @return Feedback_Field|null Returns a Feedback_Field object or null if the data is invalid.
1275 */
1276 public static function from_serialized_v2( $data ) {
1277 if ( ! is_array( $data ) || ! isset( $data['key'] ) || ! isset( $data['value'] ) || ! isset( $data['label'] ) ) {
1278 return null;
1279 }
1280
1281 if ( is_string( $data['value'] ) ) { // just normalize plain string for now.
1282 $data['value'] = self::normalize_unicode( $data['value'] );
1283 }
1284
1285 if ( is_string( $data['label'] ) ) { // just normalize plain string for now.
1286 $data['label'] = self::normalize_unicode( $data['label'] );
1287 }
1288
1289 return new self(
1290 $data['key'],
1291 $data['label'],
1292 $data['value'],
1293 $data['type'] ?? 'basic',
1294 $data['meta'] ?? array(),
1295 $data['form_field_id'] ?? ''
1296 );
1297 }
1298
1299 /**
1300 * Check if the field has a file
1301 *
1302 * @return bool
1303 */
1304 public function has_file() {
1305 if ( $this->is_of_type( 'file' ) ) {
1306 if ( ! isset( $this->value['files'] ) || ! is_array( $this->value['files'] ) ) {
1307 return false;
1308 }
1309 return count( $this->value['files'] ) > 0;
1310 }
1311
1312 return false;
1313 }
1314
1315 /**
1316 * Checks if the file is previewable based on its type or extension.
1317 * Only image formats are allowed to be previewed in the modal. PDFs may be previewed in the browser elsewhere, but not in the modal.
1318 *
1319 * @param array $file File data.
1320 * @return bool True if the file is previewable, false otherwise.
1321 */
1322 private function is_previewable_file( $file ) {
1323 $file_type = strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) );
1324 // Check if the file is previewable based on its type or extension.
1325 // Note: This is a simplified check and does not match if the file is allowed to be uploaded by the server.
1326 $previewable_types = array( 'jpg', 'jpeg', 'png', 'gif', 'webp' );
1327 return in_array( $file_type, $previewable_types, true );
1328 }
1329 }
1330