| @@ -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">⋮⋮</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 | } |