PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.0.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.0.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 1.4.1 1.4.2 1.5.0 1.5.1 All 61 releases
← All changes | includes/Admin/SettingsHelper.php +478 -165 1.10.02.0.0 View file →
@@ -109,8 +109,173 @@
109 109 return $fields;
110 110 }
111 111
112 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 + 'payment_gateways' => 'payments',
211 + 'payment_failure' => 'payments',
212 + 'grace_period' => 'payments',
213 + 'switching' => 'switching',
214 + 'role_based_settings' => 'customers',
215 + 'guest_checkout' => 'customers',
216 + 'live_qr_settings' => 'customers',
217 + 'api_settings' => 'advanced',
218 + 'health_queue' => 'advanced',
219 + );
220 +
221 + return $map[ $group_id ] ?? 'advanced';
222 + }
223 +
224 + /**
225 + * Group keys bucketed by section, each list in the order the groups already
226 + * sort in.
227 + *
228 + * Empty sections are dropped. Most of them are filled by Pro, and free
229 + * alone would otherwise show rail items that open onto nothing.
230 + *
231 + * @param array $settings_fields Grouped, sorted settings fields.
232 + * @return array<string,string[]> Section key => ordered group keys.
233 + */
234 + public static function category_groups( array $settings_fields ) {
235 + $out = array();
236 + foreach ( array_keys( self::categories() ) as $cat ) {
237 + $out[ $cat ] = array();
238 + }
239 +
240 + foreach ( array_keys( $settings_fields ) as $group_id ) {
241 + $cat = self::group_category( $group_id );
242 + $out[ $cat ][] = $group_id;
243 + }
244 +
245 + return array_filter(
246 + $out,
247 + function ( $group_ids ) {
248 + return ! empty( $group_ids );
249 + }
250 + );
251 + }
252 +
253 + /**
254 + * Whether every group in a section is locked behind Pro.
255 + *
256 + * Drives the "Pro" marker on the rail item, so a section none of which can
257 + * be changed yet says so before it is opened.
258 + *
259 + * @param string[] $group_ids Group keys in the section.
260 + * @param array $settings_fields Grouped settings fields.
261 + * @return bool
262 + */
263 + public static function category_is_pro_locked( array $group_ids, array $settings_fields ) {
264 + if ( empty( $group_ids ) ) {
265 + return false;
266 + }
267 +
268 + foreach ( $group_ids as $group_id ) {
269 + if ( ! self::group_is_pro_locked( $settings_fields[ $group_id ] ?? array() ) ) {
270 + return false;
271 + }
272 + }
273 +
274 + return true;
275 + }
276 +
277 + /**
113 278 * Render specified settings field.
114 279 *
115 280 * @param string $field Field type.
116 281 * @param array $args Field arguments.
@@ -133,8 +298,10 @@
133 298 case 'multi_select':
134 299 return self::render_multiselect_field( $args, $should_print );
135 300 case 'join':
136 301 return self::render_joined_field( $args, $should_print );
302 + case 'editlist':
303 + return self::render_editlist_field( $args, $should_print );
137 304 case 'input':
138 305 default:
139 306 return self::render_input_field( $args, $should_print );
140 307 }
@@ -139,9 +306,18 @@
139 306 return self::render_input_field( $args, $should_print );
140 307 }
141 308 }
142 309
310 + /**
311 + * Pro badge markup, shown beside settings that require WPSubscription Pro.
312 + *
313 + * @return string Pre-escaped badge HTML.
314 + */
315 + public static function pro_badge_html() {
316 + return '<span class="subscrpt-pro-badge" title="' . esc_attr__( 'WPSubscription Pro required', 'subscription' ) . '">' . esc_html__( 'Pro', 'subscription' ) . '</span>';
317 + }
143 318
319 +
144 320 /**
145 321 * Text Element HTML.
146 322 *
147 323 * @param array $args Same as 'render_text_field'.
@@ -152,15 +328,13 @@
152 328 $value = $args['value'] ?? '';
153 329 $placeholder = $args['placeholder'] ?? '';
154 330 $type = $args['type'] ?? 'text';
155 331
156 - $join_class = $join_item ? 'join-item mx-0!' : '';
157 -
158 332 $disabled_attr = isset( $args['disabled'] ) && $args['disabled'] ? 'disabled' : '';
159 333
160 - $style_attr = 'outline-offset: 0.5px !important; outline-color: #e5e7eb !important;';
334 + $style_attr = '';
161 335 if ( isset( $args['style'] ) ) {
162 - $style_attr .= ' ' . $args['style'];
336 + $style_attr = $args['style'];
163 337 }
164 338
165 339 $other_attrs_html = '';
166 340 foreach ( ( $args['attributes'] ?? [] ) as $attr_key => $attr_value ) {
@@ -168,13 +342,16 @@
168 342 }
169 343
170 344 ob_start();
171 345 ?>
172 - <input
346 + <input
173 347 id="<?php echo esc_attr( $id ); ?>"
174 348 name="<?php echo esc_attr( $id ); ?>"
175 - class="input! min-w-80! max-w-full! <?php echo esc_attr( $join_class ); ?>"
176 - style="<?php echo esc_attr( $style_attr ); ?>"
349 + class="wpsubs-input"
350 + <?php
351 + if ( $style_attr ) :
352 + ?>
353 + style="<?php echo esc_attr( $style_attr ); ?>"<?php endif; ?>
177 354 type="<?php echo esc_attr( $type ); ?>"
178 355 placeholder="<?php echo esc_attr( $placeholder ); ?>"
179 356 value="<?php echo esc_attr( $value ); ?>"
180 357 <?php echo esc_attr( $disabled_attr ); ?>
@@ -190,88 +367,56 @@
190 367 * @param array $args Same as 'render_select_field'.
191 368 * @param bool $join_item Whether to return element for 'join' container or not.
192 369 */
193 370 public static function select_element( $args = [], $join_item = false ) {
194 - $id = $args['id'];
195 - $value = $args['value'] ?? '';
371 + $id = $args['id'];
196 372
197 - $join_class = $join_item ? 'join-item mx-0!' : '';
198 - $wc_enhanced_class = isset( $args['enhanced'] ) && $args['enhanced'];
373 + // Enhanced / multiselect → wpsubs-tag-select (pill input with filter).
374 + if ( isset( $args['enhanced'] ) && $args['enhanced'] ) {
375 + $multiple = isset( $args['attributes']['multiple'] ) && $args['attributes']['multiple'];
376 + $adv_options = array();
377 + foreach ( ( $args['options'] ?? [] ) as $opt_value => $opt_label ) {
378 + $adv_options[] = array(
379 + 'value' => (string) $opt_value,
380 + 'label' => $opt_label,
381 + );
382 + }
199 383
200 - $basic_classes = 'select! min-w-80! max-w-full!';
201 -
202 - // WC Select2 style (multiselect).
203 - if ( $wc_enhanced_class ) {
204 - // Enqueue WooCommerce enhanced select script & styles.
205 - wp_enqueue_style( 'woocommerce_admin_styles' );
206 - wp_enqueue_script( 'wc-enhanced-select' );
207 -
208 - // Update basic classes for wc-enhanced-select.
209 - $basic_classes = 'min-w-80! max-w-full! wc-enhanced-select';
384 + ob_start();
385 + wpsubs_render_tag_select(
386 + array(
387 + 'name' => $id,
388 + 'value' => $args['selected'] ?? ( $multiple ? array() : '' ),
389 + 'options' => $adv_options,
390 + 'multiple' => $multiple,
391 + )
392 + );
393 + return ob_get_clean();
210 394 }
211 395
212 - // Need to prefix name with [] for multiple select.
213 - $name_prefix = '';
214 - if ( isset( $args['attributes']['multiple'] ) && $args['attributes']['multiple'] ) {
215 - $name_prefix = '[]';
216 - }
217 -
218 - $style_attr = 'outline-offset: 0.5px !important; outline-color: #e5e7eb !important;';
219 - if ( isset( $args['style'] ) ) {
220 - $style_attr .= ' ' . $args['style'];
221 - }
222 -
223 - $other_attrs_html = '';
224 - foreach ( ( $args['attributes'] ?? [] ) as $attr_key => $attr_value ) {
225 - $other_attrs_html .= sprintf( ' %s="%s" ', esc_attr( $attr_key ), esc_attr( $attr_value ) );
226 - }
227 -
228 - $options_html = '';
229 - foreach ( ( $args['options'] ?? [] ) as $value => $label ) {
230 - $selected = false;
231 - if ( isset( $args['selected'] ) ) {
232 - if ( is_array( $args['selected'] ) ) {
233 - $selected = in_array( $value, $args['selected'], true );
234 - } else {
235 - $selected = $args['selected'] === $value;
236 - }
237 - }
238 -
239 - $disabled = false;
240 - if ( isset( $args['disabled'] ) ) {
241 - if ( is_array( $args['disabled'] ) ) {
242 - $disabled = in_array( $value, $args['disabled'], true );
243 - } else {
244 - $disabled = $args['disabled'] === $value;
245 - }
246 - }
247 -
248 - $options_tmp_html = sprintf(
249 - '<option value="%s" %s %s>%s</option>',
250 - esc_attr( $value ),
251 - $selected ? 'selected' : '',
252 - $disabled ? 'disabled' : '',
253 - esc_html( $label ),
396 + // Regular select → wpsubs-adv-select (button-based custom dropdown).
397 + $selected = (string) ( $args['selected'] ?? '' );
398 + $adv_options = array();
399 + foreach ( ( $args['options'] ?? [] ) as $opt_value => $opt_label ) {
400 + $adv_options[] = array(
401 + 'value' => (string) $opt_value,
402 + 'label' => $opt_label,
403 + 'disabled' => isset( $args['disabled'] ) && ( is_array( $args['disabled'] )
404 + ? in_array( $opt_value, $args['disabled'], true )
405 + : $args['disabled'] === $opt_value ),
254 406 );
255 - $options_html .= $options_tmp_html;
256 407 }
257 408
258 409 ob_start();
259 - ?>
260 - <select
261 - id="<?php echo esc_attr( $id ); ?>"
262 - name="<?php echo esc_attr( $id . $name_prefix ); ?>"
263 - class="<?php echo esc_attr( $basic_classes . ' ' . $join_class ); ?>"
264 - style="<?php echo esc_attr( $style_attr ); ?>"
265 - <?php echo wp_kses_post( $other_attrs_html ); ?>
266 - >
267 - <?php
268 - // Output intentionally not escaped as options are already escaped during generation & re-escaping breaks the HTML structure.
269 - // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
270 - echo $options_html;
271 - ?>
272 - </select>
273 - <?php
410 + wpsubs_render_adv_select(
411 + array(
412 + 'name' => $id,
413 + 'value' => $selected,
414 + 'options' => $adv_options,
415 + 'align' => 'left',
416 + 'class' => $args['class'] ?? '',
417 + )
418 + );
274 419 return ob_get_clean();
275 420 }
276 421
277 422 /**
@@ -289,17 +434,14 @@
289 434 $description = $args['description'] ?? '';
290 435
291 436 ob_start();
292 437 ?>
293 - <div class="my-4 first-of-type:mt-0">
294 - <h2 class="m-0!"><?php echo esc_html( $title ); ?></h2>
295 -
296 - <?php if ( ! empty( $description ) ) : ?>
297 - <p class="mb-0! mt-2! ml-0.5! text-[13px]! text-gray-500!">
298 - <?php echo wp_kses_post( $description ); ?>
299 - </p>
300 - <?php endif; ?>
301 - </div>
438 + <div class="wpsubs-settings-heading">
439 + <h3 class="wpsubs-settings-heading__title"><?php echo esc_html( $title ); ?></h3>
440 + <?php if ( ! empty( $description ) ) : ?>
441 + <p class="wpsubs-settings-heading__desc"><?php echo wp_kses_post( $description ); ?></p>
442 + <?php endif; ?>
443 + </div>
302 444 <?php
303 445 $html_content = ob_get_clean();
304 446
305 447 // Output not escaped intentionally. Breaks the HTML structure when escaped.
@@ -337,25 +479,29 @@
337 479 $text_el_html = self::inp_element( $args );
338 480
339 481 ob_start();
340 482 ?>
341 - <div class="grid grid-cols-6 gap-4">
342 - <span class="font-semibold text-sm mt-0.5"><?php echo esc_html( $title ); ?></span>
343 -
344 - <div class="col-span-5">
345 - <?php
346 - // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
347 - // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
348 - echo $text_el_html;
349 - ?>
350 - <br/>
351 - <?php if ( ! empty( $description ) ) : ?>
352 - <p class="mb-0! mt-2! ml-0.5! text-[13px]! text-gray-500!">
353 - <?php echo wp_kses_post( $description ); ?>
354 - </p>
355 - <?php endif; ?>
356 - </div>
483 + <div class="wpsubs-settings-field<?php echo ! empty( $args['pro_locked'] ) ? ' wpsubs-settings-field--locked' : ''; ?>">
484 + <div class="wpsubs-settings-field__label">
485 + <?php
486 + if ( ! empty( $args['pro_locked'] ) ) {
487 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped badge markup.
488 + echo self::pro_badge_html();
489 + }
490 + ?>
491 + <?php echo esc_html( $title ); ?>
357 492 </div>
493 + <div class="wpsubs-settings-field__control">
494 + <?php
495 + // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
496 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
497 + echo $text_el_html;
498 + ?>
499 + <?php if ( ! empty( $description ) ) : ?>
500 + <p class="wpsubs-settings-field__hint"><?php echo wp_kses_post( $description ); ?></p>
501 + <?php endif; ?>
502 + </div>
503 + </div>
358 504 <?php
359 505 $html_content = ob_get_clean();
360 506
361 507 // Output not escaped intentionally. Breaks the HTML structure when escaped.
@@ -395,9 +541,9 @@
395 541
396 542 $description_html = '';
397 543 if ( ! empty( $description ) ) {
398 544 $description_html = sprintf(
399 - '<p class="mb-0! mt-2! ml-0.5! text-[13px]! text-gray-500!">%s</p>',
545 + '<p class="wpsubs-settings-field__hint">%s</p>',
400 546 wp_kses_post( $description )
401 547 );
402 548 }
403 549
@@ -415,37 +561,42 @@
415 561 $disabled_attr = isset( $args['disabled'] ) && (bool) $args['disabled'] ? 'disabled' : '';
416 562
417 563 ob_start();
418 564 ?>
419 - <div class="grid grid-cols-6 gap-4">
420 - <span class="font-semibold text-sm mt-0.5"><?php echo esc_html( $title ); ?></span>
421 -
422 - <div class="col-span-5">
423 - <label for="<?php echo esc_attr( $id ); ?>">
424 - <input
425 - id="<?php echo esc_attr( $id ); ?>"
426 - name="<?php echo esc_attr( $id ); ?>"
427 - class="wp-subscription-toggle"
428 - style="<?php echo esc_attr( $style_attr ); ?>"
429 - type="checkbox"
430 - value="<?php echo esc_attr( $value ); ?>"
431 - <?php echo esc_attr( $checked_attr ); ?>
432 - <?php echo esc_attr( $disabled_attr ); ?>
433 - <?php
434 - // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
435 - // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
436 - echo $other_attrs_html;
437 - ?>
438 - />
439 - <span class="wp-subscription-toggle-ui" aria-hidden="true"></span>
440 -
441 - <span class="ml-2 text-sm align-middle"><?php echo esc_html( $label ); ?></span>
442 - </label>
443 -
444 - <br/>
445 - <?php echo wp_kses_post( $description_html ); ?>
446 - </div>
565 + <div class="wpsubs-settings-field<?php echo ! empty( $args['pro_locked'] ) ? ' wpsubs-settings-field--locked' : ''; ?>">
566 + <div class="wpsubs-settings-field__label">
567 + <?php
568 + if ( ! empty( $args['pro_locked'] ) ) {
569 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped badge markup.
570 + echo self::pro_badge_html();
571 + }
572 + ?>
573 + <?php echo esc_html( $title ); ?>
447 574 </div>
575 + <div class="wpsubs-settings-field__control">
576 + <label class="wpsubs-settings-toggle-label" for="<?php echo esc_attr( $id ); ?>">
577 + <input
578 + id="<?php echo esc_attr( $id ); ?>"
579 + name="<?php echo esc_attr( $id ); ?>"
580 + class="wpsubs-toggle"
581 + type="checkbox"
582 + value="<?php echo esc_attr( $value ); ?>"
583 + <?php echo esc_attr( $checked_attr ); ?>
584 + <?php echo esc_attr( $disabled_attr ); ?>
585 + <?php
586 + // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
587 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
588 + echo $other_attrs_html;
589 + ?>
590 + />
591 + <span class="wpsubs-toggle-ui" aria-hidden="true"></span>
592 + <?php if ( ! empty( $label ) ) : ?>
593 + <span class="wpsubs-settings-toggle-label__text"><?php echo esc_html( $label ); ?></span>
594 + <?php endif; ?>
595 + </label>
596 + <?php echo wp_kses_post( $description_html ); ?>
597 + </div>
598 + </div>
448 599 <?php
449 600 $html_content = ob_get_clean();
450 601
451 602 // Output not escaped intentionally. Breaks the HTML structure when escaped.
@@ -483,25 +634,29 @@
483 634 $select_el_html = self::select_element( $args );
484 635
485 636 ob_start();
486 637 ?>
487 - <div class="grid grid-cols-6 gap-4">
488 - <span class="font-semibold text-sm mt-0.5"><?php echo esc_html( $title ); ?></span>
489 -
490 - <div class="col-span-5">
491 - <?php
492 - // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
493 - // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
494 - echo $select_el_html;
495 - ?>
496 - <br/>
497 - <?php if ( ! empty( $description ) ) : ?>
498 - <p class="mb-0! mt-2! ml-0.5! text-[13px]! text-gray-500!">
499 - <?php echo wp_kses_post( $description ); ?>
500 - </p>
501 - <?php endif; ?>
502 - </div>
638 + <div class="wpsubs-settings-field<?php echo ! empty( $args['pro_locked'] ) ? ' wpsubs-settings-field--locked' : ''; ?>">
639 + <div class="wpsubs-settings-field__label">
640 + <?php
641 + if ( ! empty( $args['pro_locked'] ) ) {
642 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped badge markup.
643 + echo self::pro_badge_html();
644 + }
645 + ?>
646 + <?php echo esc_html( $title ); ?>
503 647 </div>
648 + <div class="wpsubs-settings-field__control">
649 + <?php
650 + // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
651 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
652 + echo $select_el_html;
653 + ?>
654 + <?php if ( ! empty( $description ) ) : ?>
655 + <p class="wpsubs-settings-field__hint"><?php echo wp_kses_post( $description ); ?></p>
656 + <?php endif; ?>
657 + </div>
658 + </div>
504 659 <?php
505 660 $html_content = ob_get_clean();
506 661
507 662 // Output not escaped intentionally. Breaks the HTML structure when escaped.
@@ -545,33 +700,145 @@
545 700 public static function render_joined_field( $args = [], $should_print = true ) {
546 701 $title = $args['title'] ?? '';
547 702 $description = $args['description'] ?? '';
548 703
549 - $vertical_class = ( $args['vertical'] ?? false ) ? 'join-vertical' : '';
704 + $vertical_style = ( $args['vertical'] ?? false ) ? 'flex-direction:column;' : '';
550 705
551 706 ob_start();
552 707 ?>
553 - <div class="grid grid-cols-6 gap-4">
554 - <span class="font-semibold text-sm mt-0.5"><?php echo esc_html( $title ); ?></span>
708 + <div class="wpsubs-settings-field<?php echo ! empty( $args['pro_locked'] ) ? ' wpsubs-settings-field--locked' : ''; ?>">
709 + <div class="wpsubs-settings-field__label">
710 + <?php
711 + if ( ! empty( $args['pro_locked'] ) ) {
712 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped badge markup.
713 + echo self::pro_badge_html();
714 + }
715 + ?>
716 + <?php echo esc_html( $title ); ?>
717 + </div>
718 + <div class="wpsubs-settings-field__control">
719 + <div class="wpsubs-input-group"
720 + <?php
721 + if ( $vertical_style ) :
722 + ?>
723 + style="<?php echo esc_attr( $vertical_style ); ?>"<?php endif; ?>>
724 + <?php
725 + foreach ( ( $args['elements'] ?? [] ) as $element_html ) {
726 + // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
727 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
728 + echo $element_html;
729 + }
730 + ?>
731 + </div>
732 + <?php if ( ! empty( $description ) ) : ?>
733 + <p class="wpsubs-settings-field__hint"><?php echo wp_kses_post( $description ); ?></p>
734 + <?php endif; ?>
735 + </div>
736 + </div>
737 + <?php
738 + $html_content = ob_get_clean();
555 739
556 - <div class="col-span-5">
557 - <div class="join <?php echo esc_attr( $vertical_class ); ?>">
740 + // Output not escaped intentionally. Breaks the HTML structure when escaped.
741 + // All form elements inside $html_content are pre-escaped during generation (esc_attr, esc_html).
742 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
743 + return $should_print ? print( $html_content ) : $html_content;
744 + }
745 +
746 + /**
747 + * Render an editable, reorderable list field (the `wpsubs-editlist` component).
748 + *
749 + * Generic and reusable: a sortable list of text items with per-row remove/move
750 + * controls and an inline input + add button. The ordered list is serialized as
751 + * JSON (`[{ key, label }]`) into a hidden input so it submits with the form;
752 + * behaviour is wired by `WPSubsEditList` (admin-components/editlist.js). All user-facing
753 + * strings are overridable so the field carries no feature-specific text.
754 + *
755 + * When `modal` is true the list lives inside a `wpsubs-modal` (via the
756 + * `WPSubsModal` component) and the settings row only shows a trigger button with
757 + * a live item count.
758 + *
759 + * - Args:
760 + * - id (string) - Field ID / option key.
761 + * - title (string)
762 + * - description (string)
763 + * - value (array) - Ordered list of { key, label } entries.
764 + * - add_placeholder (string) - Inline input placeholder.
765 + * - add_label (string) - Add button accessible label.
766 + * - empty_text (string) - Message shown when the list is empty.
767 + * - modal (bool) - Present the list inside a modal (default false).
768 + * - button_label (string) - Modal trigger button text (modal mode).
769 + * - modal_title (string) - Modal header title (modal mode; defaults to title).
770 + * - pro_locked (bool)
771 + *
772 + * @param array $args Field arguments.
773 + * @param bool $should_print Whether to print the field or return as HTML string.
774 + */
775 + public static function render_editlist_field( $args = [], $should_print = true ) {
776 + $id = $args['id'] ?? '';
777 + $title = $args['title'] ?? '';
778 + $description = $args['description'] ?? '';
779 + $items = is_array( $args['value'] ?? null ) ? $args['value'] : [];
780 + $locked = ! empty( $args['pro_locked'] );
781 + $modal = ! empty( $args['modal'] );
782 +
783 + $add_placeholder = $args['add_placeholder'] ?? __( 'Add an item…', 'subscription' );
784 + $add_label = $args['add_label'] ?? __( 'Add item', 'subscription' );
785 + $empty_text = $args['empty_text'] ?? __( 'No items yet. Add one below.', 'subscription' );
786 + $button_label = $args['button_label'] ?? __( 'Manage list', 'subscription' );
787 + $modal_title = $args['modal_title'] ?? ( '' !== $title ? $title : __( 'Edit list', 'subscription' ) );
788 +
789 + if ( empty( $id ) ) {
790 + $field_hint = empty( $title ) ? 'Error' : $title;
791 + $no_id_msg = '<p><strong>' . $field_hint . ':</strong> ' . __( 'Field ID is required.', 'subscription' ) . '</p>';
792 + return $should_print ? print wp_kses_post( $no_id_msg ) : $no_id_msg;
793 + }
794 +
795 + $body = self::editlist_body_html( $items, $add_placeholder, $add_label, $empty_text, $locked );
796 +
797 + ob_start();
798 + ?>
799 + <div class="wpsubs-settings-field<?php echo $locked ? ' wpsubs-settings-field--locked' : ''; ?>">
800 + <div class="wpsubs-settings-field__label">
801 + <?php
802 + if ( $locked ) {
803 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped badge markup.
804 + echo self::pro_badge_html();
805 + }
806 + ?>
807 + <?php echo esc_html( $title ); ?>
808 + </div>
809 + <div class="wpsubs-settings-field__control">
810 + <div class="wpsubs-editlist<?php echo $locked ? ' wpsubs-editlist--locked' : ''; ?>">
811 + <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 ) ) ); ?>" />
812 + <?php if ( $modal ) : ?>
813 + <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' : ''; ?>>
814 + <?php echo esc_html( $button_label ); ?>
815 + <span class="wpsubs-editlist__count"><?php echo esc_html( (string) count( $items ) ); ?></span>
816 + </button>
558 817 <?php
559 - foreach ( ( $args['elements'] ?? [] ) as $element_html ) {
560 - // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
561 - // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
562 - echo $element_html;
563 - }
818 + wpsubs_render_modal(
819 + [
820 + 'id' => $id . '_modal',
821 + 'title' => $modal_title,
822 + 'body' => $body,
823 + 'class' => 'wpsubs-modal--editlist',
824 + 'footer' => '<button type="button" class="wpsubs-btn wpsubs-btn--primary" data-wpsubs-modal-close>' . esc_html__( 'Done', 'subscription' ) . '</button>',
825 + ]
826 + );
564 827 ?>
565 - </div>
566 - <br/>
567 - <?php if ( ! empty( $description ) ) : ?>
568 - <p class="mb-0! mt-2! ml-0.5! text-[13px]! text-gray-500!">
569 - <?php echo wp_kses_post( $description ); ?>
570 - </p>
828 + <?php else : ?>
829 + <?php
830 + // Body markup is pre-escaped during generation.
831 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
832 + echo $body;
833 + ?>
571 834 <?php endif; ?>
572 835 </div>
836 + <?php if ( ! empty( $description ) ) : ?>
837 + <p class="wpsubs-settings-field__hint"><?php echo wp_kses_post( $description ); ?></p>
838 + <?php endif; ?>
573 839 </div>
840 + </div>
574 841 <?php
575 842 $html_content = ob_get_clean();
576 843
577 844 // Output not escaped intentionally. Breaks the HTML structure when escaped.
@@ -577,6 +844,52 @@
577 844 // Output not escaped intentionally. Breaks the HTML structure when escaped.
578 845 // All form elements inside $html_content are pre-escaped during generation (esc_attr, esc_html).
579 846 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
580 847 return $should_print ? print( $html_content ) : $html_content;
848 + }
849 +
850 + /**
851 + * Build the inner markup of an edit list (items + empty message + add row).
852 + *
853 + * Shared by inline and modal presentations of {@see self::render_editlist_field}.
854 + *
855 + * @param array $items Ordered list of { key, label } entries.
856 + * @param string $add_placeholder Inline input placeholder.
857 + * @param string $add_label Add button accessible label.
858 + * @param string $empty_text Message shown when the list is empty.
859 + * @param bool $locked Whether the controls are disabled.
860 + * @return string Pre-escaped HTML.
861 + */
862 + private static function editlist_body_html( array $items, $add_placeholder, $add_label, $empty_text, $locked ) {
863 + ob_start();
864 + ?>
865 + <ul class="wpsubs-editlist__items">
866 + <?php foreach ( $items as $item ) : ?>
867 + <?php
868 + $item_key = isset( $item['key'] ) ? (string) $item['key'] : '';
869 + $item_label = isset( $item['label'] ) ? (string) $item['label'] : '';
870 + if ( '' === $item_label ) {
871 + continue;
872 + }
873 + ?>
874 + <li class="wpsubs-editlist__item" data-key="<?php echo esc_attr( $item_key ); ?>">
875 + <span class="wpsubs-editlist__handle" aria-hidden="true">&#8942;&#8942;</span>
876 + <span class="wpsubs-editlist__label"><?php echo esc_html( $item_label ); ?></span>
877 + <span class="wpsubs-editlist__actions">
878 + <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>
879 + <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>
880 + <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>
881 + </span>
882 + </li>
883 + <?php endforeach; ?>
884 + </ul>
885 + <p class="wpsubs-editlist__empty"<?php echo empty( $items ) ? '' : ' hidden'; ?>><?php echo esc_html( $empty_text ); ?></p>
886 + <div class="wpsubs-editlist__add">
887 + <input type="text" class="wpsubs-input wpsubs-editlist__input" placeholder="<?php echo esc_attr( $add_placeholder ); ?>"<?php echo $locked ? ' disabled' : ''; ?> />
888 + <button type="button" class="wpsubs-editlist__add-btn" data-editlist-add aria-label="<?php echo esc_attr( $add_label ); ?>"<?php echo $locked ? ' disabled' : ''; ?>>
889 + <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>
890 + </button>
891 + </div>
892 + <?php
893 + return ob_get_clean();
581 894 }
582 895 }