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