PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.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 13.7.2 All 507 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-a.3, at jetpack_vendor/automattic/jetpack-forms/src/contact-form/class-feedback-field.php

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