PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.4.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.4.0
2.4.0 2.3.0 2.2.0 2.1.0 2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 All 65 releases
subscription / includes / Admin / SettingsHelper.php

SettingsHelper.php in Subscriptions for WooCommerce with Stripe Recurring Payments 2.4.0, at includes/Admin/SettingsHelper.php

898 lines 31.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Settings Helper File
4 *
5 * @package SpringDevs\Subscription\Admin
6 */
7
8 namespace SpringDevs\Subscription\Admin;
9
10 /**
11 * Settings Helper Class
12 *
13 * @package SpringDevs\Subscription\Admin
14 */
15 class SettingsHelper {
16 /**
17 * Singleton instance
18 *
19 * @var SettingsHelper|null
20 */
21 private static $instance = null;
22
23 /**
24 * Get singleton instance
25 *
26 * @return SettingsHelper
27 */
28 public static function get_instance() {
29 if ( null === self::$instance ) {
30 self::$instance = new self();
31 }
32 return self::$instance;
33 }
34
35 /**
36 * Initialize the class.
37 */
38 private function __construct() {
39 add_filter( 'process_subscrpt_settings_fields', [ $this, 'process_settings_fields' ], 100, 1 );
40 }
41
42 /**
43 * Process settings fields.
44 *
45 * @param array $fields Settings fields.
46 * @return array Processed settings fields.
47 */
48 public function process_settings_fields( $fields ) {
49 // Group settings fields.
50 $fields = $this->group_settings_fields( $fields );
51
52 // Sort fields by priority (groups & fields).
53 $fields = $this->sort_settings_fields( $fields );
54
55 return $fields;
56 }
57
58 /**
59 * Group settings fields.
60 *
61 * @param array $fields Settings fields.
62 * @return array Processed settings fields.
63 */
64 public function group_settings_fields( $fields ) {
65 $tmp_fields = [];
66 foreach ( $fields as $field ) {
67 $field_group = $field['group'] ?? 'main';
68
69 if ( $field['type'] === 'heading' ) {
70 $group_priority = $field['priority'] ?? 0;
71 $tmp_fields[ $field_group ]['priority'] = $group_priority;
72 $field['priority'] = -1;
73 }
74
75 $tmp_fields[ $field_group ]['fields'][] = $field;
76 }
77 return $tmp_fields;
78 }
79
80 /**
81 * Sort settings fields.
82 *
83 * @param array $fields Settings fields.
84 * @return array Processed settings fields.
85 */
86 public function sort_settings_fields( $fields ) {
87 // Sort groups by priority.
88 uasort(
89 $fields,
90 function ( $a, $b ) {
91 $priority_a = $a['priority'] ?? 0;
92 $priority_b = $b['priority'] ?? 0;
93 return $priority_a <=> $priority_b;
94 }
95 );
96
97 // Sort fields within each group by priority.
98 foreach ( $fields as $group_key => $group_data ) {
99 uasort(
100 $group_data['fields'],
101 function ( $a, $b ) {
102 $priority_a = $a['priority'] ?? 0;
103 $priority_b = $b['priority'] ?? 0;
104 return $priority_a <=> $priority_b;
105 }
106 );
107 $fields[ $group_key ]['fields'] = $group_data['fields'];
108 }
109 return $fields;
110 }
111
112 /**
113 * Label a settings group for its tab.
114 *
115 * The label is the group's `heading` field, which is also what the panel
116 * shows, so a tab and its panel can never disagree. An add-on that adds a
117 * group without a heading still gets a usable tab rather than a blank one:
118 * `live_qr_settings` reads as "Live Qr Settings", which is wrong-ish but
119 * findable, and the fix is for that add-on to add a heading.
120 *
121 * `main` is the exception. It is what a field with no `group` falls back to,
122 * so it holds whatever nobody placed rather than anything named "Main". No
123 * field ships in it — this plugin has no settings that are merely general —
124 * and it only becomes a tab when something lands there uninvited.
125 *
126 * @param string $group_id Group key.
127 * @param array $group Group data: `fields`, `priority`.
128 * @return string Unescaped label.
129 */
130 public static function group_label( $group_id, array $group ) {
131 foreach ( $group['fields'] ?? array() as $field ) {
132 if ( 'heading' === ( $field['type'] ?? '' ) && ! empty( $field['field_data']['title'] ) ) {
133 return $field['field_data']['title'];
134 }
135 }
136
137 if ( 'main' === $group_id ) {
138 return __( 'General', 'subscription' );
139 }
140
141 return ucwords( str_replace( array( '_', '-' ), ' ', (string) $group_id ) );
142 }
143
144 /**
145 * Whether every field in a group is locked behind Pro.
146 *
147 * Drives the "Pro" marker on the tab, so the whole panel does not have to be
148 * opened to find out that none of it can be changed yet.
149 *
150 * @param array $group Group data.
151 * @return bool
152 */
153 public static function group_is_pro_locked( array $group ) {
154 $has_field = false;
155
156 foreach ( $group['fields'] ?? array() as $field ) {
157 if ( 'heading' === ( $field['type'] ?? '' ) ) {
158 continue;
159 }
160 $has_field = true;
161 if ( empty( $field['field_data']['pro_locked'] ) ) {
162 return false;
163 }
164 }
165
166 return $has_field;
167 }
168
169 /**
170 * The settings sections, in display order.
171 *
172 * One level, named for the job a merchant came to do rather than for the
173 * plugin's internals. Each section is one rail item and one panel; the
174 * groups inside it stack, so nothing is ever two clicks deep.
175 *
176 * There is deliberately no "All settings" entry. It duplicated every panel
177 * on one page, which made the rail beside it look like decoration and gave
178 * every setting two addresses.
179 *
180 * @return array<string,string> Section key => label.
181 */
182 public static function categories() {
183 return array(
184 'renewals' => __( 'Renewals', 'subscription' ),
185 'payments' => __( 'Payments', 'subscription' ),
186 'switching' => __( 'Switching & Upgrades', 'subscription' ),
187 'customers' => __( 'Customers', 'subscription' ),
188 'advanced' => __( 'Advanced', 'subscription' ),
189 );
190 }
191
192 /**
193 * Which section a settings group belongs to.
194 *
195 * Groups are merged rather than mapped one-to-one: a section holding a
196 * single option is a wasted click, so `health_queue` sits with the other
197 * plumbing in Advanced, and everything the customer meets — the role they
198 * are given, checking out as a guest, what their subscription's quick view
199 * shows — is in Customers.
200 *
201 * Unmapped groups, including any an add-on registers without knowing
202 * sections exist, fall into `advanced`, so a new group is always reachable.
203 *
204 * @param string $group_id Group key.
205 * @return string Section key.
206 */
207 public static function group_category( $group_id ) {
208 $map = array(
209 'renewals' => 'renewals',
210 'license_settings' => 'renewals',
211 'stock_management' => 'renewals',
212 'payment_gateways' => 'payments',
213 'payment_failure' => 'payments',
214 'grace_period' => 'payments',
215 'switching' => 'switching',
216 'role_based_settings' => 'customers',
217 'guest_checkout' => 'customers',
218 'live_qr_settings' => 'customers',
219 'api_settings' => 'advanced',
220 'health_queue' => 'advanced',
221 );
222
223 return $map[ $group_id ] ?? 'advanced';
224 }
225
226 /**
227 * Group keys bucketed by section, each list in the order the groups already
228 * sort in.
229 *
230 * Empty sections are dropped. Most of them are filled by Pro, and free
231 * alone would otherwise show rail items that open onto nothing.
232 *
233 * @param array $settings_fields Grouped, sorted settings fields.
234 * @return array<string,string[]> Section key => ordered group keys.
235 */
236 public static function category_groups( array $settings_fields ) {
237 $out = array();
238 foreach ( array_keys( self::categories() ) as $cat ) {
239 $out[ $cat ] = array();
240 }
241
242 foreach ( array_keys( $settings_fields ) as $group_id ) {
243 $cat = self::group_category( $group_id );
244 $out[ $cat ][] = $group_id;
245 }
246
247 return array_filter(
248 $out,
249 function ( $group_ids ) {
250 return ! empty( $group_ids );
251 }
252 );
253 }
254
255 /**
256 * Whether every group in a section is locked behind Pro.
257 *
258 * Drives the "Pro" marker on the rail item, so a section none of which can
259 * be changed yet says so before it is opened.
260 *
261 * @param string[] $group_ids Group keys in the section.
262 * @param array $settings_fields Grouped settings fields.
263 * @return bool
264 */
265 public static function category_is_pro_locked( array $group_ids, array $settings_fields ) {
266 if ( empty( $group_ids ) ) {
267 return false;
268 }
269
270 foreach ( $group_ids as $group_id ) {
271 if ( ! self::group_is_pro_locked( $settings_fields[ $group_id ] ?? array() ) ) {
272 return false;
273 }
274 }
275
276 return true;
277 }
278
279 /**
280 * Render specified settings field.
281 *
282 * @param string $field Field type.
283 * @param array $args Field arguments.
284 * @param bool $should_print Whether to print the field or return as HTML string.
285 */
286 public static function render_settings_field( $field, $args, $should_print = true ) {
287 if ( empty( $field ) ) {
288 $field = 'input'; // Default field type.
289 subscrpt_write_debug_log( "[SettingsHelper] Field type not specified. Defaulting to 'input'." );
290 }
291
292 switch ( $field ) {
293 case 'heading':
294 return self::render_heading( $args, $should_print );
295 case 'switch':
296 case 'toggle':
297 return self::render_switch_field( $args, $should_print );
298 case 'select':
299 return self::render_select_field( $args, $should_print );
300 case 'multi_select':
301 return self::render_multiselect_field( $args, $should_print );
302 case 'join':
303 return self::render_joined_field( $args, $should_print );
304 case 'editlist':
305 return self::render_editlist_field( $args, $should_print );
306 case 'input':
307 default:
308 return self::render_input_field( $args, $should_print );
309 }
310 }
311
312 /**
313 * Pro badge markup, shown beside settings that require WPSubscription Pro.
314 *
315 * @return string Pre-escaped badge HTML.
316 */
317 public static function pro_badge_html() {
318 return '<span class="subscrpt-pro-badge" title="' . esc_attr__( 'WPSubscription Pro required', 'subscription' ) . '">' . esc_html__( 'Pro', 'subscription' ) . '</span>';
319 }
320
321
322 /**
323 * Text Element HTML.
324 *
325 * @param array $args Same as 'render_text_field'.
326 * @param bool $join_item Whether to return element for 'join' container or not.
327 */
328 public static function inp_element( $args = [], $join_item = false ) {
329 $id = $args['id'];
330 $value = $args['value'] ?? '';
331 $placeholder = $args['placeholder'] ?? '';
332 $type = $args['type'] ?? 'text';
333
334 $disabled_attr = isset( $args['disabled'] ) && $args['disabled'] ? 'disabled' : '';
335
336 $style_attr = '';
337 if ( isset( $args['style'] ) ) {
338 $style_attr = $args['style'];
339 }
340
341 $other_attrs_html = '';
342 foreach ( ( $args['attributes'] ?? [] ) as $attr_key => $attr_value ) {
343 $other_attrs_html .= sprintf( ' %s="%s" ', esc_attr( $attr_key ), esc_attr( $attr_value ) );
344 }
345
346 ob_start();
347 ?>
348 <input
349 id="<?php echo esc_attr( $id ); ?>"
350 name="<?php echo esc_attr( $id ); ?>"
351 class="wpsubs-input"
352 <?php
353 if ( $style_attr ) :
354 ?>
355 style="<?php echo esc_attr( $style_attr ); ?>"<?php endif; ?>
356 type="<?php echo esc_attr( $type ); ?>"
357 placeholder="<?php echo esc_attr( $placeholder ); ?>"
358 value="<?php echo esc_attr( $value ); ?>"
359 <?php echo esc_attr( $disabled_attr ); ?>
360 <?php echo wp_kses_post( $other_attrs_html ); ?>
361 />
362 <?php
363 return ob_get_clean();
364 }
365
366 /**
367 * Select Element HTML.
368 *
369 * @param array $args Same as 'render_select_field'.
370 * @param bool $join_item Whether to return element for 'join' container or not.
371 */
372 public static function select_element( $args = [], $join_item = false ) {
373 $id = $args['id'];
374
375 // Enhanced / multiselect → wpsubs-tag-select (pill input with filter).
376 if ( isset( $args['enhanced'] ) && $args['enhanced'] ) {
377 $multiple = isset( $args['attributes']['multiple'] ) && $args['attributes']['multiple'];
378 $adv_options = array();
379 foreach ( ( $args['options'] ?? [] ) as $opt_value => $opt_label ) {
380 $adv_options[] = array(
381 'value' => (string) $opt_value,
382 'label' => $opt_label,
383 );
384 }
385
386 ob_start();
387 wpsubs_render_tag_select(
388 array(
389 'name' => $id,
390 'value' => $args['selected'] ?? ( $multiple ? array() : '' ),
391 'options' => $adv_options,
392 'multiple' => $multiple,
393 )
394 );
395 return ob_get_clean();
396 }
397
398 // Regular select → wpsubs-adv-select (button-based custom dropdown).
399 $selected = (string) ( $args['selected'] ?? '' );
400 $adv_options = array();
401 foreach ( ( $args['options'] ?? [] ) as $opt_value => $opt_label ) {
402 $adv_options[] = array(
403 'value' => (string) $opt_value,
404 'label' => $opt_label,
405 'disabled' => isset( $args['disabled'] ) && ( is_array( $args['disabled'] )
406 ? in_array( $opt_value, $args['disabled'], true )
407 : $args['disabled'] === $opt_value ),
408 );
409 }
410
411 ob_start();
412 wpsubs_render_adv_select(
413 array(
414 'name' => $id,
415 'value' => $selected,
416 'options' => $adv_options,
417 'align' => 'left',
418 'class' => $args['class'] ?? '',
419 )
420 );
421 return ob_get_clean();
422 }
423
424 /**
425 * Render Field Heading.
426 *
427 * - Args:
428 * - title (string) - Field title.
429 * - description (string) - Field description (optional).
430 *
431 * @param array $args Field arguments.
432 * @param bool $should_print Whether to print the field or return as HTML string.
433 */
434 public static function render_heading( $args = [], $should_print = true ) {
435 $title = $args['title'] ?? '';
436 $description = $args['description'] ?? '';
437
438 ob_start();
439 ?>
440 <div class="wpsubs-settings-heading">
441 <h3 class="wpsubs-settings-heading__title"><?php echo esc_html( $title ); ?></h3>
442 <?php if ( ! empty( $description ) ) : ?>
443 <p class="wpsubs-settings-heading__desc"><?php echo wp_kses_post( $description ); ?></p>
444 <?php endif; ?>
445 </div>
446 <?php
447 $html_content = ob_get_clean();
448
449 // Output not escaped intentionally. Breaks the HTML structure when escaped.
450 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
451 return $should_print ? print( $html_content ) : $html_content;
452 }
453
454 /**
455 * Render Text field.
456 *
457 * - Args:
458 * - id (string) - Field ID.
459 * - title (string) - Field title.
460 * - description (string) - Field description (optional).
461 * - value (string) - Default value.
462 * - placeholder (string) - Default placeholder.
463 * - disabled (bool) - Disabled status.
464 * - type (string) - Input type [text, email, number, date, time, etc.].
465 *
466 * @param array $args Field arguments.
467 * @param bool $should_print Whether to print the field or return as HTML string.
468 */
469 public static function render_input_field( $args = [], $should_print = true ) {
470 $title = $args['title'] ?? '';
471 $description = $args['description'] ?? '';
472
473 // Return error if ID is not provided.
474 if ( empty( $args['id'] ?? '' ) ) {
475 $field_hint = empty( $title ) ? 'Error' : $title;
476 $no_id_msg = '<p><strong>' . $field_hint . ':</strong> ' . __( 'Field ID is required.', 'subscription' ) . '</p>';
477 return $should_print ? print wp_kses_post( $no_id_msg ) : $no_id_msg;
478 }
479
480 // Input HTML.
481 $text_el_html = self::inp_element( $args );
482
483 ob_start();
484 ?>
485 <div class="wpsubs-settings-field<?php echo ! empty( $args['pro_locked'] ) ? ' wpsubs-settings-field--locked' : ''; ?>">
486 <div class="wpsubs-settings-field__label">
487 <?php
488 if ( ! empty( $args['pro_locked'] ) ) {
489 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped badge markup.
490 echo self::pro_badge_html();
491 }
492 ?>
493 <?php echo esc_html( $title ); ?>
494 </div>
495 <div class="wpsubs-settings-field__control">
496 <?php
497 // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
498 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
499 echo $text_el_html;
500 ?>
501 <?php if ( ! empty( $description ) ) : ?>
502 <p class="wpsubs-settings-field__hint"><?php echo wp_kses_post( $description ); ?></p>
503 <?php endif; ?>
504 </div>
505 </div>
506 <?php
507 $html_content = ob_get_clean();
508
509 // Output not escaped intentionally. Breaks the HTML structure when escaped.
510 // All form elements inside $html_content are pre-escaped during generation (esc_attr, esc_html).
511 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
512 return $should_print ? print( $html_content ) : $html_content;
513 }
514
515 /**
516 * Render Switch field.
517 *
518 * - Args:
519 * - id (string) - Field ID.
520 * - title (string) - Field title.
521 * - label (string) - Checkbox label.
522 * - description (string) - Field description (optional).
523 * - value (string) - Checked value.
524 * - checked (bool) - Checked status.
525 * - disabled (bool) - Disabled status.
526 *
527 * @param array $args Field arguments.
528 * @param bool $should_print Whether to print the field or return as HTML string.
529 */
530 public static function render_switch_field( $args = [], $should_print = true ) {
531 $id = $args['id'];
532 $title = $args['title'] ?? '';
533 $label = $args['label'] ?? '';
534 $description = $args['description'] ?? '';
535 $value = $args['value'] ?? '0';
536
537 // Return error if ID is not provided.
538 if ( empty( $args['id'] ?? '' ) ) {
539 $field_hint = empty( $title ) ? 'Error' : $title;
540 $no_id_msg = '<p><strong>' . $field_hint . ':</strong> ' . __( 'Field ID is required.', 'subscription' ) . '</p>';
541 return $should_print ? print wp_kses_post( $no_id_msg ) : $no_id_msg;
542 }
543
544 $description_html = '';
545 if ( ! empty( $description ) ) {
546 $description_html = sprintf(
547 '<p class="wpsubs-settings-field__hint">%s</p>',
548 wp_kses_post( $description )
549 );
550 }
551
552 $style_attr = '';
553 if ( isset( $args['style'] ) ) {
554 $style_attr .= ' ' . $args['style'];
555 }
556
557 $other_attrs_html = '';
558 foreach ( ( $args['attributes'] ?? [] ) as $attr_key => $attr_value ) {
559 $other_attrs_html .= sprintf( ' %s="%s" ', esc_attr( $attr_key ), esc_attr( $attr_value ) );
560 }
561
562 $checked_attr = isset( $args['checked'] ) && (bool) $args['checked'] ? 'checked' : '';
563 $disabled_attr = isset( $args['disabled'] ) && (bool) $args['disabled'] ? 'disabled' : '';
564
565 ob_start();
566 ?>
567 <div class="wpsubs-settings-field<?php echo ! empty( $args['pro_locked'] ) ? ' wpsubs-settings-field--locked' : ''; ?>">
568 <div class="wpsubs-settings-field__label">
569 <?php
570 if ( ! empty( $args['pro_locked'] ) ) {
571 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped badge markup.
572 echo self::pro_badge_html();
573 }
574 ?>
575 <?php echo esc_html( $title ); ?>
576 </div>
577 <div class="wpsubs-settings-field__control">
578 <label class="wpsubs-settings-toggle-label" for="<?php echo esc_attr( $id ); ?>">
579 <input
580 id="<?php echo esc_attr( $id ); ?>"
581 name="<?php echo esc_attr( $id ); ?>"
582 class="wpsubs-toggle"
583 type="checkbox"
584 value="<?php echo esc_attr( $value ); ?>"
585 <?php echo esc_attr( $checked_attr ); ?>
586 <?php echo esc_attr( $disabled_attr ); ?>
587 <?php
588 // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
589 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
590 echo $other_attrs_html;
591 ?>
592 />
593 <span class="wpsubs-toggle-ui" aria-hidden="true"></span>
594 <?php if ( ! empty( $label ) ) : ?>
595 <span class="wpsubs-settings-toggle-label__text"><?php echo esc_html( $label ); ?></span>
596 <?php endif; ?>
597 </label>
598 <?php echo wp_kses_post( $description_html ); ?>
599 </div>
600 </div>
601 <?php
602 $html_content = ob_get_clean();
603
604 // Output not escaped intentionally. Breaks the HTML structure when escaped.
605 // All form elements inside $html_content are pre-escaped during generation (esc_attr, esc_html).
606 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
607 return $should_print ? print( $html_content ) : $html_content;
608 }
609
610 /**
611 * Render Select field.
612 *
613 * - Args:
614 * - id (string) - Field ID.
615 * - title (string) - Field title.
616 * - description (string) - Field description (optional).
617 * - options (array) - Field options [value => label].
618 * - selected (string) - Selected option value.
619 * - disabled (string|array) - Disabled option value(s).
620 *
621 * @param array $args Field arguments.
622 * @param bool $should_print Whether to print the field or return as HTML string.
623 */
624 public static function render_select_field( $args = [], $should_print = true ) {
625 $title = $args['title'] ?? '';
626 $description = $args['description'] ?? '';
627
628 // Return error if ID is not provided.
629 if ( empty( $args['id'] ?? '' ) ) {
630 $field_hint = empty( $title ) ? 'Error' : $title;
631 $no_id_msg = '<p><strong>' . $field_hint . ':</strong> ' . __( 'Field ID is required.', 'subscription' ) . '</p>';
632 return $should_print ? print wp_kses_post( $no_id_msg ) : $no_id_msg;
633 }
634
635 // Select HTML.
636 $select_el_html = self::select_element( $args );
637
638 ob_start();
639 ?>
640 <div class="wpsubs-settings-field<?php echo ! empty( $args['pro_locked'] ) ? ' wpsubs-settings-field--locked' : ''; ?>">
641 <div class="wpsubs-settings-field__label">
642 <?php
643 if ( ! empty( $args['pro_locked'] ) ) {
644 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped badge markup.
645 echo self::pro_badge_html();
646 }
647 ?>
648 <?php echo esc_html( $title ); ?>
649 </div>
650 <div class="wpsubs-settings-field__control">
651 <?php
652 // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
653 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
654 echo $select_el_html;
655 ?>
656 <?php if ( ! empty( $description ) ) : ?>
657 <p class="wpsubs-settings-field__hint"><?php echo wp_kses_post( $description ); ?></p>
658 <?php endif; ?>
659 </div>
660 </div>
661 <?php
662 $html_content = ob_get_clean();
663
664 // Output not escaped intentionally. Breaks the HTML structure when escaped.
665 // All form elements inside $html_content are pre-escaped during generation (esc_attr, esc_html).
666 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
667 return $should_print ? print( $html_content ) : $html_content;
668 }
669
670 /**
671 * Render Multiselect field.
672 * Just a wrapper over 'render_select_field' with multiple attribute.
673 *
674 * @param array $args Field arguments.
675 * @param bool $should_print Whether to print the field or return as HTML string.
676 */
677 public static function render_multiselect_field( $args = [], $should_print = true ) {
678 $default_multiselect_args = [
679 'attributes' => [
680 'multiple' => 'multiple',
681 ],
682 'enhanced' => true,
683 ];
684
685 $args = wp_parse_args( $args, $default_multiselect_args );
686
687 return self::render_select_field( $args, $should_print );
688 }
689
690 /**
691 * Render Joined field with multiple elements.
692 *
693 * - Args:
694 * - title (string) - Field title.
695 * - description (string) - Field description (optional).
696 * - vertical (bool) - Whether to show items vertically or not.
697 * - elements ([...string]) - Array of HTML elements to join.
698 *
699 * @param array $args Field arguments.
700 * @param bool $should_print Whether to print the field or return as HTML string.
701 */
702 public static function render_joined_field( $args = [], $should_print = true ) {
703 $title = $args['title'] ?? '';
704 $description = $args['description'] ?? '';
705
706 $vertical_style = ( $args['vertical'] ?? false ) ? 'flex-direction:column;' : '';
707
708 ob_start();
709 ?>
710 <div class="wpsubs-settings-field<?php echo ! empty( $args['pro_locked'] ) ? ' wpsubs-settings-field--locked' : ''; ?>">
711 <div class="wpsubs-settings-field__label">
712 <?php
713 if ( ! empty( $args['pro_locked'] ) ) {
714 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped badge markup.
715 echo self::pro_badge_html();
716 }
717 ?>
718 <?php echo esc_html( $title ); ?>
719 </div>
720 <div class="wpsubs-settings-field__control">
721 <div class="wpsubs-input-group"
722 <?php
723 if ( $vertical_style ) :
724 ?>
725 style="<?php echo esc_attr( $vertical_style ); ?>"<?php endif; ?>>
726 <?php
727 foreach ( ( $args['elements'] ?? [] ) as $element_html ) {
728 // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
729 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
730 echo $element_html;
731 }
732 ?>
733 </div>
734 <?php if ( ! empty( $description ) ) : ?>
735 <p class="wpsubs-settings-field__hint"><?php echo wp_kses_post( $description ); ?></p>
736 <?php endif; ?>
737 </div>
738 </div>
739 <?php
740 $html_content = ob_get_clean();
741
742 // Output not escaped intentionally. Breaks the HTML structure when escaped.
743 // All form elements inside $html_content are pre-escaped during generation (esc_attr, esc_html).
744 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
745 return $should_print ? print( $html_content ) : $html_content;
746 }
747
748 /**
749 * Render an editable, reorderable list field (the `wpsubs-editlist` component).
750 *
751 * Generic and reusable: a sortable list of text items with per-row remove/move
752 * controls and an inline input + add button. The ordered list is serialized as
753 * JSON (`[{ key, label }]`) into a hidden input so it submits with the form;
754 * behaviour is wired by `WPSubsEditList` (admin-components/editlist.js). All user-facing
755 * strings are overridable so the field carries no feature-specific text.
756 *
757 * When `modal` is true the list lives inside a `wpsubs-modal` (via the
758 * `WPSubsModal` component) and the settings row only shows a trigger button with
759 * a live item count.
760 *
761 * - Args:
762 * - id (string) - Field ID / option key.
763 * - title (string)
764 * - description (string)
765 * - value (array) - Ordered list of { key, label } entries.
766 * - add_placeholder (string) - Inline input placeholder.
767 * - add_label (string) - Add button accessible label.
768 * - empty_text (string) - Message shown when the list is empty.
769 * - modal (bool) - Present the list inside a modal (default false).
770 * - button_label (string) - Modal trigger button text (modal mode).
771 * - modal_title (string) - Modal header title (modal mode; defaults to title).
772 * - pro_locked (bool)
773 *
774 * @param array $args Field arguments.
775 * @param bool $should_print Whether to print the field or return as HTML string.
776 */
777 public static function render_editlist_field( $args = [], $should_print = true ) {
778 $id = $args['id'] ?? '';
779 $title = $args['title'] ?? '';
780 $description = $args['description'] ?? '';
781 $items = is_array( $args['value'] ?? null ) ? $args['value'] : [];
782 $locked = ! empty( $args['pro_locked'] );
783 $modal = ! empty( $args['modal'] );
784
785 $add_placeholder = $args['add_placeholder'] ?? __( 'Add an item…', 'subscription' );
786 $add_label = $args['add_label'] ?? __( 'Add item', 'subscription' );
787 $empty_text = $args['empty_text'] ?? __( 'No items yet. Add one below.', 'subscription' );
788 $button_label = $args['button_label'] ?? __( 'Manage list', 'subscription' );
789 $modal_title = $args['modal_title'] ?? ( '' !== $title ? $title : __( 'Edit list', 'subscription' ) );
790
791 if ( empty( $id ) ) {
792 $field_hint = empty( $title ) ? 'Error' : $title;
793 $no_id_msg = '<p><strong>' . $field_hint . ':</strong> ' . __( 'Field ID is required.', 'subscription' ) . '</p>';
794 return $should_print ? print wp_kses_post( $no_id_msg ) : $no_id_msg;
795 }
796
797 $body = self::editlist_body_html( $items, $add_placeholder, $add_label, $empty_text, $locked );
798
799 ob_start();
800 ?>
801 <div class="wpsubs-settings-field<?php echo $locked ? ' wpsubs-settings-field--locked' : ''; ?>">
802 <div class="wpsubs-settings-field__label">
803 <?php
804 if ( $locked ) {
805 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped badge markup.
806 echo self::pro_badge_html();
807 }
808 ?>
809 <?php echo esc_html( $title ); ?>
810 </div>
811 <div class="wpsubs-settings-field__control">
812 <div class="wpsubs-editlist<?php echo $locked ? ' wpsubs-editlist--locked' : ''; ?>">
813 <input type="hidden" id="<?php echo esc_attr( $id ); ?>" name="<?php echo esc_attr( $id ); ?>" value="<?php echo esc_attr( wp_json_encode( array_values( $items ) ) ); ?>" />
814 <?php if ( $modal ) : ?>
815 <button type="button" class="wpsubs-btn wpsubs-btn--outline wpsubs-editlist__trigger" data-wpsubs-modal-open="<?php echo esc_attr( $id . '_modal' ); ?>"<?php echo $locked ? ' disabled' : ''; ?>>
816 <?php echo esc_html( $button_label ); ?>
817 <span class="wpsubs-editlist__count"><?php echo esc_html( (string) count( $items ) ); ?></span>
818 </button>
819 <?php
820 wpsubs_render_modal(
821 [
822 'id' => $id . '_modal',
823 'title' => $modal_title,
824 'body' => $body,
825 'class' => 'wpsubs-modal--editlist',
826 'footer' => '<button type="button" class="wpsubs-btn wpsubs-btn--primary" data-wpsubs-modal-close>' . esc_html__( 'Done', 'subscription' ) . '</button>',
827 ]
828 );
829 ?>
830 <?php else : ?>
831 <?php
832 // Body markup is pre-escaped during generation.
833 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
834 echo $body;
835 ?>
836 <?php endif; ?>
837 </div>
838 <?php if ( ! empty( $description ) ) : ?>
839 <p class="wpsubs-settings-field__hint"><?php echo wp_kses_post( $description ); ?></p>
840 <?php endif; ?>
841 </div>
842 </div>
843 <?php
844 $html_content = ob_get_clean();
845
846 // Output not escaped intentionally. Breaks the HTML structure when escaped.
847 // All form elements inside $html_content are pre-escaped during generation (esc_attr, esc_html).
848 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
849 return $should_print ? print( $html_content ) : $html_content;
850 }
851
852 /**
853 * Build the inner markup of an edit list (items + empty message + add row).
854 *
855 * Shared by inline and modal presentations of {@see self::render_editlist_field}.
856 *
857 * @param array $items Ordered list of { key, label } entries.
858 * @param string $add_placeholder Inline input placeholder.
859 * @param string $add_label Add button accessible label.
860 * @param string $empty_text Message shown when the list is empty.
861 * @param bool $locked Whether the controls are disabled.
862 * @return string Pre-escaped HTML.
863 */
864 private static function editlist_body_html( array $items, $add_placeholder, $add_label, $empty_text, $locked ) {
865 ob_start();
866 ?>
867 <ul class="wpsubs-editlist__items">
868 <?php foreach ( $items as $item ) : ?>
869 <?php
870 $item_key = isset( $item['key'] ) ? (string) $item['key'] : '';
871 $item_label = isset( $item['label'] ) ? (string) $item['label'] : '';
872 if ( '' === $item_label ) {
873 continue;
874 }
875 ?>
876 <li class="wpsubs-editlist__item" data-key="<?php echo esc_attr( $item_key ); ?>">
877 <span class="wpsubs-editlist__handle" aria-hidden="true">&#8942;&#8942;</span>
878 <span class="wpsubs-editlist__label"><?php echo esc_html( $item_label ); ?></span>
879 <span class="wpsubs-editlist__actions">
880 <button type="button" class="wpsubs-editlist__btn" data-editlist-up aria-label="<?php esc_attr_e( 'Move up', 'subscription' ); ?>"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="18 15 12 9 6 15"/></svg></button>
881 <button type="button" class="wpsubs-editlist__btn" data-editlist-down aria-label="<?php esc_attr_e( 'Move down', 'subscription' ); ?>"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button>
882 <button type="button" class="wpsubs-editlist__btn wpsubs-editlist__btn--danger" data-editlist-remove aria-label="<?php esc_attr_e( 'Remove', 'subscription' ); ?>"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
883 </span>
884 </li>
885 <?php endforeach; ?>
886 </ul>
887 <p class="wpsubs-editlist__empty"<?php echo empty( $items ) ? '' : ' hidden'; ?>><?php echo esc_html( $empty_text ); ?></p>
888 <div class="wpsubs-editlist__add">
889 <input type="text" class="wpsubs-input wpsubs-editlist__input" placeholder="<?php echo esc_attr( $add_placeholder ); ?>"<?php echo $locked ? ' disabled' : ''; ?> />
890 <button type="button" class="wpsubs-editlist__add-btn" data-editlist-add aria-label="<?php echo esc_attr( $add_label ); ?>"<?php echo $locked ? ' disabled' : ''; ?>>
891 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>
892 </button>
893 </div>
894 <?php
895 return ob_get_clean();
896 }
897 }
898