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