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