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