| @@ -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 | } |
| @@ -245,8 +412,9 @@ | ||
| 245 | 412 | 'name' => $id, |
| 246 | 413 | 'value' => $selected, |
| 247 | 414 | 'options' => $adv_options, |
| 248 | 415 | 'align' => 'left', |
| 416 | + 'class' => $args['class'] ?? '', | |
| 249 | 417 | ) |
| 250 | 418 | ); |
| 251 | 419 | return ob_get_clean(); |
| 252 | 420 | } |
| @@ -572,6 +740,156 @@ | ||
| 572 | 740 | // Output not escaped intentionally. Breaks the HTML structure when escaped. |
| 573 | 741 | // All form elements inside $html_content are pre-escaped during generation (esc_attr, esc_html). |
| 574 | 742 | // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| 575 | 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> | |
| 817 | + <?php | |
| 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 | + ); | |
| 827 | + ?> | |
| 828 | + <?php else : ?> | |
| 829 | + <?php | |
| 830 | + // Body markup is pre-escaped during generation. | |
| 831 | + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped | |
| 832 | + echo $body; | |
| 833 | + ?> | |
| 834 | + <?php endif; ?> | |
| 835 | + </div> | |
| 836 | + <?php if ( ! empty( $description ) ) : ?> | |
| 837 | + <p class="wpsubs-settings-field__hint"><?php echo wp_kses_post( $description ); ?></p> | |
| 838 | + <?php endif; ?> | |
| 839 | + </div> | |
| 840 | + </div> | |
| 841 | + <?php | |
| 842 | + $html_content = ob_get_clean(); | |
| 843 | + | |
| 844 | + // Output not escaped intentionally. Breaks the HTML structure when escaped. | |
| 845 | + // All form elements inside $html_content are pre-escaped during generation (esc_attr, esc_html). | |
| 846 | + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped | |
| 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">⋮⋮</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(); | |
| 576 | 894 | } |
| 577 | 895 | } |