PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.2.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.2.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.2.0, at includes/Admin/SettingsHelper.php

897 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 'payment_gateways' => 'payments',
212 'payment_failure' => 'payments',
213 'grace_period' => 'payments',
214 'switching' => 'switching',
215 'role_based_settings' => 'customers',
216 'guest_checkout' => 'customers',
217 'live_qr_settings' => 'customers',
218 'api_settings' => 'advanced',
219 'health_queue' => 'advanced',
220 );
221
222 return $map[ $group_id ] ?? 'advanced';
223 }
224
225 /**
226 * Group keys bucketed by section, each list in the order the groups already
227 * sort in.
228 *
229 * Empty sections are dropped. Most of them are filled by Pro, and free
230 * alone would otherwise show rail items that open onto nothing.
231 *
232 * @param array $settings_fields Grouped, sorted settings fields.
233 * @return array<string,string[]> Section key => ordered group keys.
234 */
235 public static function category_groups( array $settings_fields ) {
236 $out = array();
237 foreach ( array_keys( self::categories() ) as $cat ) {
238 $out[ $cat ] = array();
239 }
240
241 foreach ( array_keys( $settings_fields ) as $group_id ) {
242 $cat = self::group_category( $group_id );
243 $out[ $cat ][] = $group_id;
244 }
245
246 return array_filter(
247 $out,
248 function ( $group_ids ) {
249 return ! empty( $group_ids );
250 }
251 );
252 }
253
254 /**
255 * Whether every group in a section is locked behind Pro.
256 *
257 * Drives the "Pro" marker on the rail item, so a section none of which can
258 * be changed yet says so before it is opened.
259 *
260 * @param string[] $group_ids Group keys in the section.
261 * @param array $settings_fields Grouped settings fields.
262 * @return bool
263 */
264 public static function category_is_pro_locked( array $group_ids, array $settings_fields ) {
265 if ( empty( $group_ids ) ) {
266 return false;
267 }
268
269 foreach ( $group_ids as $group_id ) {
270 if ( ! self::group_is_pro_locked( $settings_fields[ $group_id ] ?? array() ) ) {
271 return false;
272 }
273 }
274
275 return true;
276 }
277
278 /**
279 * Render specified settings field.
280 *
281 * @param string $field Field type.
282 * @param array $args Field arguments.
283 * @param bool $should_print Whether to print the field or return as HTML string.
284 */
285 public static function render_settings_field( $field, $args, $should_print = true ) {
286 if ( empty( $field ) ) {
287 $field = 'input'; // Default field type.
288 subscrpt_write_debug_log( "[SettingsHelper] Field type not specified. Defaulting to 'input'." );
289 }
290
291 switch ( $field ) {
292 case 'heading':
293 return self::render_heading( $args, $should_print );
294 case 'switch':
295 case 'toggle':
296 return self::render_switch_field( $args, $should_print );
297 case 'select':
298 return self::render_select_field( $args, $should_print );
299 case 'multi_select':
300 return self::render_multiselect_field( $args, $should_print );
301 case 'join':
302 return self::render_joined_field( $args, $should_print );
303 case 'editlist':
304 return self::render_editlist_field( $args, $should_print );
305 case 'input':
306 default:
307 return self::render_input_field( $args, $should_print );
308 }
309 }
310
311 /**
312 * Pro badge markup, shown beside settings that require WPSubscription Pro.
313 *
314 * @return string Pre-escaped badge HTML.
315 */
316 public static function pro_badge_html() {
317 return '<span class="subscrpt-pro-badge" title="' . esc_attr__( 'WPSubscription Pro required', 'subscription' ) . '">' . esc_html__( 'Pro', 'subscription' ) . '</span>';
318 }
319
320
321 /**
322 * Text Element HTML.
323 *
324 * @param array $args Same as 'render_text_field'.
325 * @param bool $join_item Whether to return element for 'join' container or not.
326 */
327 public static function inp_element( $args = [], $join_item = false ) {
328 $id = $args['id'];
329 $value = $args['value'] ?? '';
330 $placeholder = $args['placeholder'] ?? '';
331 $type = $args['type'] ?? 'text';
332
333 $disabled_attr = isset( $args['disabled'] ) && $args['disabled'] ? 'disabled' : '';
334
335 $style_attr = '';
336 if ( isset( $args['style'] ) ) {
337 $style_attr = $args['style'];
338 }
339
340 $other_attrs_html = '';
341 foreach ( ( $args['attributes'] ?? [] ) as $attr_key => $attr_value ) {
342 $other_attrs_html .= sprintf( ' %s="%s" ', esc_attr( $attr_key ), esc_attr( $attr_value ) );
343 }
344
345 ob_start();
346 ?>
347 <input
348 id="<?php echo esc_attr( $id ); ?>"
349 name="<?php echo esc_attr( $id ); ?>"
350 class="wpsubs-input"
351 <?php
352 if ( $style_attr ) :
353 ?>
354 style="<?php echo esc_attr( $style_attr ); ?>"<?php endif; ?>
355 type="<?php echo esc_attr( $type ); ?>"
356 placeholder="<?php echo esc_attr( $placeholder ); ?>"
357 value="<?php echo esc_attr( $value ); ?>"
358 <?php echo esc_attr( $disabled_attr ); ?>
359 <?php echo wp_kses_post( $other_attrs_html ); ?>
360 />
361 <?php
362 return ob_get_clean();
363 }
364
365 /**
366 * Select Element HTML.
367 *
368 * @param array $args Same as 'render_select_field'.
369 * @param bool $join_item Whether to return element for 'join' container or not.
370 */
371 public static function select_element( $args = [], $join_item = false ) {
372 $id = $args['id'];
373
374 // Enhanced / multiselect → wpsubs-tag-select (pill input with filter).
375 if ( isset( $args['enhanced'] ) && $args['enhanced'] ) {
376 $multiple = isset( $args['attributes']['multiple'] ) && $args['attributes']['multiple'];
377 $adv_options = array();
378 foreach ( ( $args['options'] ?? [] ) as $opt_value => $opt_label ) {
379 $adv_options[] = array(
380 'value' => (string) $opt_value,
381 'label' => $opt_label,
382 );
383 }
384
385 ob_start();
386 wpsubs_render_tag_select(
387 array(
388 'name' => $id,
389 'value' => $args['selected'] ?? ( $multiple ? array() : '' ),
390 'options' => $adv_options,
391 'multiple' => $multiple,
392 )
393 );
394 return ob_get_clean();
395 }
396
397 // Regular select → wpsubs-adv-select (button-based custom dropdown).
398 $selected = (string) ( $args['selected'] ?? '' );
399 $adv_options = array();
400 foreach ( ( $args['options'] ?? [] ) as $opt_value => $opt_label ) {
401 $adv_options[] = array(
402 'value' => (string) $opt_value,
403 'label' => $opt_label,
404 'disabled' => isset( $args['disabled'] ) && ( is_array( $args['disabled'] )
405 ? in_array( $opt_value, $args['disabled'], true )
406 : $args['disabled'] === $opt_value ),
407 );
408 }
409
410 ob_start();
411 wpsubs_render_adv_select(
412 array(
413 'name' => $id,
414 'value' => $selected,
415 'options' => $adv_options,
416 'align' => 'left',
417 'class' => $args['class'] ?? '',
418 )
419 );
420 return ob_get_clean();
421 }
422
423 /**
424 * Render Field Heading.
425 *
426 * - Args:
427 * - title (string) - Field title.
428 * - description (string) - Field description (optional).
429 *
430 * @param array $args Field arguments.
431 * @param bool $should_print Whether to print the field or return as HTML string.
432 */
433 public static function render_heading( $args = [], $should_print = true ) {
434 $title = $args['title'] ?? '';
435 $description = $args['description'] ?? '';
436
437 ob_start();
438 ?>
439 <div class="wpsubs-settings-heading">
440 <h3 class="wpsubs-settings-heading__title"><?php echo esc_html( $title ); ?></h3>
441 <?php if ( ! empty( $description ) ) : ?>
442 <p class="wpsubs-settings-heading__desc"><?php echo wp_kses_post( $description ); ?></p>
443 <?php endif; ?>
444 </div>
445 <?php
446 $html_content = ob_get_clean();
447
448 // Output not escaped intentionally. Breaks the HTML structure when escaped.
449 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
450 return $should_print ? print( $html_content ) : $html_content;
451 }
452
453 /**
454 * Render Text field.
455 *
456 * - Args:
457 * - id (string) - Field ID.
458 * - title (string) - Field title.
459 * - description (string) - Field description (optional).
460 * - value (string) - Default value.
461 * - placeholder (string) - Default placeholder.
462 * - disabled (bool) - Disabled status.
463 * - type (string) - Input type [text, email, number, date, time, etc.].
464 *
465 * @param array $args Field arguments.
466 * @param bool $should_print Whether to print the field or return as HTML string.
467 */
468 public static function render_input_field( $args = [], $should_print = true ) {
469 $title = $args['title'] ?? '';
470 $description = $args['description'] ?? '';
471
472 // Return error if ID is not provided.
473 if ( empty( $args['id'] ?? '' ) ) {
474 $field_hint = empty( $title ) ? 'Error' : $title;
475 $no_id_msg = '<p><strong>' . $field_hint . ':</strong> ' . __( 'Field ID is required.', 'subscription' ) . '</p>';
476 return $should_print ? print wp_kses_post( $no_id_msg ) : $no_id_msg;
477 }
478
479 // Input HTML.
480 $text_el_html = self::inp_element( $args );
481
482 ob_start();
483 ?>
484 <div class="wpsubs-settings-field<?php echo ! empty( $args['pro_locked'] ) ? ' wpsubs-settings-field--locked' : ''; ?>">
485 <div class="wpsubs-settings-field__label">
486 <?php
487 if ( ! empty( $args['pro_locked'] ) ) {
488 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped badge markup.
489 echo self::pro_badge_html();
490 }
491 ?>
492 <?php echo esc_html( $title ); ?>
493 </div>
494 <div class="wpsubs-settings-field__control">
495 <?php
496 // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
497 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
498 echo $text_el_html;
499 ?>
500 <?php if ( ! empty( $description ) ) : ?>
501 <p class="wpsubs-settings-field__hint"><?php echo wp_kses_post( $description ); ?></p>
502 <?php endif; ?>
503 </div>
504 </div>
505 <?php
506 $html_content = ob_get_clean();
507
508 // Output not escaped intentionally. Breaks the HTML structure when escaped.
509 // All form elements inside $html_content are pre-escaped during generation (esc_attr, esc_html).
510 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
511 return $should_print ? print( $html_content ) : $html_content;
512 }
513
514 /**
515 * Render Switch field.
516 *
517 * - Args:
518 * - id (string) - Field ID.
519 * - title (string) - Field title.
520 * - label (string) - Checkbox label.
521 * - description (string) - Field description (optional).
522 * - value (string) - Checked value.
523 * - checked (bool) - Checked status.
524 * - disabled (bool) - Disabled status.
525 *
526 * @param array $args Field arguments.
527 * @param bool $should_print Whether to print the field or return as HTML string.
528 */
529 public static function render_switch_field( $args = [], $should_print = true ) {
530 $id = $args['id'];
531 $title = $args['title'] ?? '';
532 $label = $args['label'] ?? '';
533 $description = $args['description'] ?? '';
534 $value = $args['value'] ?? '0';
535
536 // Return error if ID is not provided.
537 if ( empty( $args['id'] ?? '' ) ) {
538 $field_hint = empty( $title ) ? 'Error' : $title;
539 $no_id_msg = '<p><strong>' . $field_hint . ':</strong> ' . __( 'Field ID is required.', 'subscription' ) . '</p>';
540 return $should_print ? print wp_kses_post( $no_id_msg ) : $no_id_msg;
541 }
542
543 $description_html = '';
544 if ( ! empty( $description ) ) {
545 $description_html = sprintf(
546 '<p class="wpsubs-settings-field__hint">%s</p>',
547 wp_kses_post( $description )
548 );
549 }
550
551 $style_attr = '';
552 if ( isset( $args['style'] ) ) {
553 $style_attr .= ' ' . $args['style'];
554 }
555
556 $other_attrs_html = '';
557 foreach ( ( $args['attributes'] ?? [] ) as $attr_key => $attr_value ) {
558 $other_attrs_html .= sprintf( ' %s="%s" ', esc_attr( $attr_key ), esc_attr( $attr_value ) );
559 }
560
561 $checked_attr = isset( $args['checked'] ) && (bool) $args['checked'] ? 'checked' : '';
562 $disabled_attr = isset( $args['disabled'] ) && (bool) $args['disabled'] ? 'disabled' : '';
563
564 ob_start();
565 ?>
566 <div class="wpsubs-settings-field<?php echo ! empty( $args['pro_locked'] ) ? ' wpsubs-settings-field--locked' : ''; ?>">
567 <div class="wpsubs-settings-field__label">
568 <?php
569 if ( ! empty( $args['pro_locked'] ) ) {
570 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped badge markup.
571 echo self::pro_badge_html();
572 }
573 ?>
574 <?php echo esc_html( $title ); ?>
575 </div>
576 <div class="wpsubs-settings-field__control">
577 <label class="wpsubs-settings-toggle-label" for="<?php echo esc_attr( $id ); ?>">
578 <input
579 id="<?php echo esc_attr( $id ); ?>"
580 name="<?php echo esc_attr( $id ); ?>"
581 class="wpsubs-toggle"
582 type="checkbox"
583 value="<?php echo esc_attr( $value ); ?>"
584 <?php echo esc_attr( $checked_attr ); ?>
585 <?php echo esc_attr( $disabled_attr ); ?>
586 <?php
587 // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
588 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
589 echo $other_attrs_html;
590 ?>
591 />
592 <span class="wpsubs-toggle-ui" aria-hidden="true"></span>
593 <?php if ( ! empty( $label ) ) : ?>
594 <span class="wpsubs-settings-toggle-label__text"><?php echo esc_html( $label ); ?></span>
595 <?php endif; ?>
596 </label>
597 <?php echo wp_kses_post( $description_html ); ?>
598 </div>
599 </div>
600 <?php
601 $html_content = ob_get_clean();
602
603 // Output not escaped intentionally. Breaks the HTML structure when escaped.
604 // All form elements inside $html_content are pre-escaped during generation (esc_attr, esc_html).
605 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
606 return $should_print ? print( $html_content ) : $html_content;
607 }
608
609 /**
610 * Render Select field.
611 *
612 * - Args:
613 * - id (string) - Field ID.
614 * - title (string) - Field title.
615 * - description (string) - Field description (optional).
616 * - options (array) - Field options [value => label].
617 * - selected (string) - Selected option value.
618 * - disabled (string|array) - Disabled option value(s).
619 *
620 * @param array $args Field arguments.
621 * @param bool $should_print Whether to print the field or return as HTML string.
622 */
623 public static function render_select_field( $args = [], $should_print = true ) {
624 $title = $args['title'] ?? '';
625 $description = $args['description'] ?? '';
626
627 // Return error if ID is not provided.
628 if ( empty( $args['id'] ?? '' ) ) {
629 $field_hint = empty( $title ) ? 'Error' : $title;
630 $no_id_msg = '<p><strong>' . $field_hint . ':</strong> ' . __( 'Field ID is required.', 'subscription' ) . '</p>';
631 return $should_print ? print wp_kses_post( $no_id_msg ) : $no_id_msg;
632 }
633
634 // Select HTML.
635 $select_el_html = self::select_element( $args );
636
637 ob_start();
638 ?>
639 <div class="wpsubs-settings-field<?php echo ! empty( $args['pro_locked'] ) ? ' wpsubs-settings-field--locked' : ''; ?>">
640 <div class="wpsubs-settings-field__label">
641 <?php
642 if ( ! empty( $args['pro_locked'] ) ) {
643 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped badge markup.
644 echo self::pro_badge_html();
645 }
646 ?>
647 <?php echo esc_html( $title ); ?>
648 </div>
649 <div class="wpsubs-settings-field__control">
650 <?php
651 // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
652 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
653 echo $select_el_html;
654 ?>
655 <?php if ( ! empty( $description ) ) : ?>
656 <p class="wpsubs-settings-field__hint"><?php echo wp_kses_post( $description ); ?></p>
657 <?php endif; ?>
658 </div>
659 </div>
660 <?php
661 $html_content = ob_get_clean();
662
663 // Output not escaped intentionally. Breaks the HTML structure when escaped.
664 // All form elements inside $html_content are pre-escaped during generation (esc_attr, esc_html).
665 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
666 return $should_print ? print( $html_content ) : $html_content;
667 }
668
669 /**
670 * Render Multiselect field.
671 * Just a wrapper over 'render_select_field' with multiple attribute.
672 *
673 * @param array $args Field arguments.
674 * @param bool $should_print Whether to print the field or return as HTML string.
675 */
676 public static function render_multiselect_field( $args = [], $should_print = true ) {
677 $default_multiselect_args = [
678 'attributes' => [
679 'multiple' => 'multiple',
680 ],
681 'enhanced' => true,
682 ];
683
684 $args = wp_parse_args( $args, $default_multiselect_args );
685
686 return self::render_select_field( $args, $should_print );
687 }
688
689 /**
690 * Render Joined field with multiple elements.
691 *
692 * - Args:
693 * - title (string) - Field title.
694 * - description (string) - Field description (optional).
695 * - vertical (bool) - Whether to show items vertically or not.
696 * - elements ([...string]) - Array of HTML elements to join.
697 *
698 * @param array $args Field arguments.
699 * @param bool $should_print Whether to print the field or return as HTML string.
700 */
701 public static function render_joined_field( $args = [], $should_print = true ) {
702 $title = $args['title'] ?? '';
703 $description = $args['description'] ?? '';
704
705 $vertical_style = ( $args['vertical'] ?? false ) ? 'flex-direction:column;' : '';
706
707 ob_start();
708 ?>
709 <div class="wpsubs-settings-field<?php echo ! empty( $args['pro_locked'] ) ? ' wpsubs-settings-field--locked' : ''; ?>">
710 <div class="wpsubs-settings-field__label">
711 <?php
712 if ( ! empty( $args['pro_locked'] ) ) {
713 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped badge markup.
714 echo self::pro_badge_html();
715 }
716 ?>
717 <?php echo esc_html( $title ); ?>
718 </div>
719 <div class="wpsubs-settings-field__control">
720 <div class="wpsubs-input-group"
721 <?php
722 if ( $vertical_style ) :
723 ?>
724 style="<?php echo esc_attr( $vertical_style ); ?>"<?php endif; ?>>
725 <?php
726 foreach ( ( $args['elements'] ?? [] ) as $element_html ) {
727 // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
728 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
729 echo $element_html;
730 }
731 ?>
732 </div>
733 <?php if ( ! empty( $description ) ) : ?>
734 <p class="wpsubs-settings-field__hint"><?php echo wp_kses_post( $description ); ?></p>
735 <?php endif; ?>
736 </div>
737 </div>
738 <?php
739 $html_content = ob_get_clean();
740
741 // Output not escaped intentionally. Breaks the HTML structure when escaped.
742 // All form elements inside $html_content are pre-escaped during generation (esc_attr, esc_html).
743 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
744 return $should_print ? print( $html_content ) : $html_content;
745 }
746
747 /**
748 * Render an editable, reorderable list field (the `wpsubs-editlist` component).
749 *
750 * Generic and reusable: a sortable list of text items with per-row remove/move
751 * controls and an inline input + add button. The ordered list is serialized as
752 * JSON (`[{ key, label }]`) into a hidden input so it submits with the form;
753 * behaviour is wired by `WPSubsEditList` (admin-components/editlist.js). All user-facing
754 * strings are overridable so the field carries no feature-specific text.
755 *
756 * When `modal` is true the list lives inside a `wpsubs-modal` (via the
757 * `WPSubsModal` component) and the settings row only shows a trigger button with
758 * a live item count.
759 *
760 * - Args:
761 * - id (string) - Field ID / option key.
762 * - title (string)
763 * - description (string)
764 * - value (array) - Ordered list of { key, label } entries.
765 * - add_placeholder (string) - Inline input placeholder.
766 * - add_label (string) - Add button accessible label.
767 * - empty_text (string) - Message shown when the list is empty.
768 * - modal (bool) - Present the list inside a modal (default false).
769 * - button_label (string) - Modal trigger button text (modal mode).
770 * - modal_title (string) - Modal header title (modal mode; defaults to title).
771 * - pro_locked (bool)
772 *
773 * @param array $args Field arguments.
774 * @param bool $should_print Whether to print the field or return as HTML string.
775 */
776 public static function render_editlist_field( $args = [], $should_print = true ) {
777 $id = $args['id'] ?? '';
778 $title = $args['title'] ?? '';
779 $description = $args['description'] ?? '';
780 $items = is_array( $args['value'] ?? null ) ? $args['value'] : [];
781 $locked = ! empty( $args['pro_locked'] );
782 $modal = ! empty( $args['modal'] );
783
784 $add_placeholder = $args['add_placeholder'] ?? __( 'Add an item…', 'subscription' );
785 $add_label = $args['add_label'] ?? __( 'Add item', 'subscription' );
786 $empty_text = $args['empty_text'] ?? __( 'No items yet. Add one below.', 'subscription' );
787 $button_label = $args['button_label'] ?? __( 'Manage list', 'subscription' );
788 $modal_title = $args['modal_title'] ?? ( '' !== $title ? $title : __( 'Edit list', 'subscription' ) );
789
790 if ( empty( $id ) ) {
791 $field_hint = empty( $title ) ? 'Error' : $title;
792 $no_id_msg = '<p><strong>' . $field_hint . ':</strong> ' . __( 'Field ID is required.', 'subscription' ) . '</p>';
793 return $should_print ? print wp_kses_post( $no_id_msg ) : $no_id_msg;
794 }
795
796 $body = self::editlist_body_html( $items, $add_placeholder, $add_label, $empty_text, $locked );
797
798 ob_start();
799 ?>
800 <div class="wpsubs-settings-field<?php echo $locked ? ' wpsubs-settings-field--locked' : ''; ?>">
801 <div class="wpsubs-settings-field__label">
802 <?php
803 if ( $locked ) {
804 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped badge markup.
805 echo self::pro_badge_html();
806 }
807 ?>
808 <?php echo esc_html( $title ); ?>
809 </div>
810 <div class="wpsubs-settings-field__control">
811 <div class="wpsubs-editlist<?php echo $locked ? ' wpsubs-editlist--locked' : ''; ?>">
812 <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 ) ) ); ?>" />
813 <?php if ( $modal ) : ?>
814 <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' : ''; ?>>
815 <?php echo esc_html( $button_label ); ?>
816 <span class="wpsubs-editlist__count"><?php echo esc_html( (string) count( $items ) ); ?></span>
817 </button>
818 <?php
819 wpsubs_render_modal(
820 [
821 'id' => $id . '_modal',
822 'title' => $modal_title,
823 'body' => $body,
824 'class' => 'wpsubs-modal--editlist',
825 'footer' => '<button type="button" class="wpsubs-btn wpsubs-btn--primary" data-wpsubs-modal-close>' . esc_html__( 'Done', 'subscription' ) . '</button>',
826 ]
827 );
828 ?>
829 <?php else : ?>
830 <?php
831 // Body markup is pre-escaped during generation.
832 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
833 echo $body;
834 ?>
835 <?php endif; ?>
836 </div>
837 <?php if ( ! empty( $description ) ) : ?>
838 <p class="wpsubs-settings-field__hint"><?php echo wp_kses_post( $description ); ?></p>
839 <?php endif; ?>
840 </div>
841 </div>
842 <?php
843 $html_content = ob_get_clean();
844
845 // Output not escaped intentionally. Breaks the HTML structure when escaped.
846 // All form elements inside $html_content are pre-escaped during generation (esc_attr, esc_html).
847 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
848 return $should_print ? print( $html_content ) : $html_content;
849 }
850
851 /**
852 * Build the inner markup of an edit list (items + empty message + add row).
853 *
854 * Shared by inline and modal presentations of {@see self::render_editlist_field}.
855 *
856 * @param array $items Ordered list of { key, label } entries.
857 * @param string $add_placeholder Inline input placeholder.
858 * @param string $add_label Add button accessible label.
859 * @param string $empty_text Message shown when the list is empty.
860 * @param bool $locked Whether the controls are disabled.
861 * @return string Pre-escaped HTML.
862 */
863 private static function editlist_body_html( array $items, $add_placeholder, $add_label, $empty_text, $locked ) {
864 ob_start();
865 ?>
866 <ul class="wpsubs-editlist__items">
867 <?php foreach ( $items as $item ) : ?>
868 <?php
869 $item_key = isset( $item['key'] ) ? (string) $item['key'] : '';
870 $item_label = isset( $item['label'] ) ? (string) $item['label'] : '';
871 if ( '' === $item_label ) {
872 continue;
873 }
874 ?>
875 <li class="wpsubs-editlist__item" data-key="<?php echo esc_attr( $item_key ); ?>">
876 <span class="wpsubs-editlist__handle" aria-hidden="true">&#8942;&#8942;</span>
877 <span class="wpsubs-editlist__label"><?php echo esc_html( $item_label ); ?></span>
878 <span class="wpsubs-editlist__actions">
879 <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>
880 <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>
881 <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>
882 </span>
883 </li>
884 <?php endforeach; ?>
885 </ul>
886 <p class="wpsubs-editlist__empty"<?php echo empty( $items ) ? '' : ' hidden'; ?>><?php echo esc_html( $empty_text ); ?></p>
887 <div class="wpsubs-editlist__add">
888 <input type="text" class="wpsubs-input wpsubs-editlist__input" placeholder="<?php echo esc_attr( $add_placeholder ); ?>"<?php echo $locked ? ' disabled' : ''; ?> />
889 <button type="button" class="wpsubs-editlist__add-btn" data-editlist-add aria-label="<?php echo esc_attr( $add_label ); ?>"<?php echo $locked ? ' disabled' : ''; ?>>
890 <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>
891 </button>
892 </div>
893 <?php
894 return ob_get_clean();
895 }
896 }
897