PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.3.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.3.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
← All changes | includes/Admin/SettingsHelper.php +368 -8 1.10.2 → 2.3.0 View file →
@@ -109,8 +109,174 @@
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 + '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 + /**
113 279 * Render specified settings field.
114 280 *
115 281 * @param string $field Field type.
116 282 * @param array $args Field arguments.
@@ -133,8 +299,10 @@
133 299 case 'multi_select':
134 300 return self::render_multiselect_field( $args, $should_print );
135 301 case 'join':
136 302 return self::render_joined_field( $args, $should_print );
303 + case 'editlist':
304 + return self::render_editlist_field( $args, $should_print );
137 305 case 'input':
138 306 default:
139 307 return self::render_input_field( $args, $should_print );
140 308 }
@@ -139,9 +307,18 @@
139 307 return self::render_input_field( $args, $should_print );
140 308 }
141 309 }
142 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 + }
143 319
320 +
144 321 /**
145 322 * Text Element HTML.
146 323 *
147 324 * @param array $args Same as 'render_text_field'.
@@ -236,8 +413,9 @@
236 413 'name' => $id,
237 414 'value' => $selected,
238 415 'options' => $adv_options,
239 416 'align' => 'left',
417 + 'class' => $args['class'] ?? '',
240 418 )
241 419 );
242 420 return ob_get_clean();
243 421 }
@@ -302,10 +480,18 @@
302 480 $text_el_html = self::inp_element( $args );
303 481
304 482 ob_start();
305 483 ?>
306 - <div class="wpsubs-settings-field">
307 - <div class="wpsubs-settings-field__label"><?php echo esc_html( $title ); ?></div>
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>
308 494 <div class="wpsubs-settings-field__control">
309 495 <?php
310 496 // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
311 497 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
@@ -376,10 +562,18 @@
376 562 $disabled_attr = isset( $args['disabled'] ) && (bool) $args['disabled'] ? 'disabled' : '';
377 563
378 564 ob_start();
379 565 ?>
380 - <div class="wpsubs-settings-field">
381 - <div class="wpsubs-settings-field__label"><?php echo esc_html( $title ); ?></div>
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>
382 576 <div class="wpsubs-settings-field__control">
383 577 <label class="wpsubs-settings-toggle-label" for="<?php echo esc_attr( $id ); ?>">
384 578 <input
385 579 id="<?php echo esc_attr( $id ); ?>"
@@ -441,10 +635,18 @@
441 635 $select_el_html = self::select_element( $args );
442 636
443 637 ob_start();
444 638 ?>
445 - <div class="wpsubs-settings-field">
446 - <div class="wpsubs-settings-field__label"><?php echo esc_html( $title ); ?></div>
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>
447 649 <div class="wpsubs-settings-field__control">
448 650 <?php
449 651 // Output intentionally not escaped as element is already escaped during generation & re-escaping breaks the HTML structure.
450 652 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
@@ -503,10 +705,18 @@
503 705 $vertical_style = ( $args['vertical'] ?? false ) ? 'flex-direction:column;' : '';
504 706
505 707 ob_start();
506 708 ?>
507 - <div class="wpsubs-settings-field">
508 - <div class="wpsubs-settings-field__label"><?php echo esc_html( $title ); ?></div>
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>
509 719 <div class="wpsubs-settings-field__control">
510 720 <div class="wpsubs-input-group"
511 721 <?php
512 722 if ( $vertical_style ) :
@@ -531,6 +741,156 @@
531 741 // Output not escaped intentionally. Breaks the HTML structure when escaped.
532 742 // All form elements inside $html_content are pre-escaped during generation (esc_attr, esc_html).
533 743 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
534 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();
535 895 }
536 896 }