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
subscription / includes / Admin / AdminComponents.php

AdminComponents.php in Subscriptions for WooCommerce with Stripe Recurring Payments 2.0.0, at includes/Admin/AdminComponents.php

996 lines 36.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Shared admin UI component renderers.
4 *
5 * @package SpringDevs\Subscription\Admin
6 */
7
8 // Exit if accessed directly.
9 if ( ! defined( 'ABSPATH' ) ) {
10 exit;
11 }
12
13 /**
14 * Render the standard admin page header: title, optional description, optional
15 * right-aligned actions, an optional right-hand aside (e.g. stat cards), and a
16 * dashed rule beneath. The one header for every admin screen in both plugins —
17 * use this instead of hand-writing the markup on a new page.
18 *
19 * @param array $args {
20 * Header args.
21 *
22 * @type string $title Page title. Required.
23 * @type string $description One-line description under the title.
24 * @type string $doc_url Docs URL; appends a "view documentation" link to
25 * the description.
26 * @type string $actions Pre-escaped HTML on the right of the title row.
27 * @type string $aside Pre-escaped HTML for a right-hand column beside
28 * the header (e.g. stat cards).
29 * @type string $class Extra classes on the wrapper.
30 * }
31 * @return void
32 */
33 function wpsubs_render_page_header( array $args ): void {
34 $args = wp_parse_args(
35 $args,
36 array(
37 'title' => '',
38 'description' => '',
39 'doc_url' => '',
40 'actions' => '',
41 'aside' => '',
42 'class' => '',
43 )
44 );
45
46 $classes = 'wpsubs-page-header';
47 if ( '' !== $args['aside'] ) {
48 $classes .= ' wpsubs-page-header--has-aside';
49 }
50 if ( $args['class'] ) {
51 $classes .= ' ' . $args['class'];
52 }
53 ?>
54 <div class="<?php echo esc_attr( $classes ); ?>">
55 <div class="wpsubs-page-header__main">
56 <div class="wpsubs-page-header__row">
57 <h1 class="wpsubs-page-header__title"><?php echo esc_html( $args['title'] ); ?></h1>
58 <?php if ( '' !== $args['actions'] ) : ?>
59 <span class="wpsubs-toolbar__spacer"></span>
60 <?php echo $args['actions']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- caller passes pre-escaped markup. ?>
61 <?php endif; ?>
62 </div>
63 <?php if ( '' !== $args['description'] ) : ?>
64 <p class="wpsubs-page-header__desc">
65 <?php echo esc_html( $args['description'] ); ?>
66 <?php if ( '' !== $args['doc_url'] ) : ?>
67 <?php esc_html_e( 'For more information,', 'subscription' ); ?>
68 <a href="<?php echo esc_url( $args['doc_url'] ); ?>" target="_blank" rel="noopener"><?php esc_html_e( 'view documentation', 'subscription' ); ?></a>.
69 <?php endif; ?>
70 </p>
71 <?php endif; ?>
72 <div class="wpsubs-page-header__rule"></div>
73 </div>
74 <?php if ( '' !== $args['aside'] ) : ?>
75 <div class="wpsubs-page-header__aside"><?php echo $args['aside']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- caller passes pre-escaped markup. ?></div>
76 <?php endif; ?>
77 </div>
78 <?php
79 }
80
81 /**
82 * Compute the visible page list for a paginator (current ± 1 window with
83 * ellipsis for wider gaps, page 1 and the last page always pinned).
84 *
85 * Mirrors the JS algorithm used by WPSubsPager so server- and client-side
86 * markup agree on what to render.
87 *
88 * @param int $current Current page (1-indexed). Clamped to [1, total].
89 * @param int $total Total number of pages. Floored at 1.
90 * @return array<int|null> Page numbers in display order; `null` is an ellipsis.
91 */
92 function wpsubs_pager_page_range( int $current, int $total ): array {
93 $total = max( 1, $total );
94 $first = 1;
95 $last = $total;
96 $current = max( 1, min( $total, $current ) );
97
98 // Slide a 3-number window centred on the current page, excluding the pinned
99 // first/last pages so they don't show twice.
100 $near_start = max( 2, $current - 1 );
101 $near_end = min( $last - 1, $current + 1 );
102
103 $nearby = array();
104 for ( $i = $near_start; $i <= $near_end; $i++ ) {
105 $nearby[] = $i;
106 }
107
108 // Dedupe while preserving order (first/last may already be in $nearby).
109 $parts = array();
110 $seen = array();
111 $push = function ( int $n ) use ( &$parts, &$seen ) {
112 if ( ! isset( $seen[ $n ] ) ) {
113 $seen[ $n ] = true;
114 $parts[] = $n;
115 }
116 };
117 $push( $first );
118 foreach ( $nearby as $n ) {
119 $push( $n );
120 }
121 if ( $last > $first ) {
122 $push( $last );
123 }
124
125 // Emit ellipsis for gaps > 1 between consecutive visible numbers.
126 $range = array();
127 foreach ( $parts as $j => $p ) {
128 if ( $j > 0 ) {
129 $gap = $p - $parts[ $j - 1 ];
130 if ( 2 === $gap ) {
131 $range[] = $parts[ $j - 1 ] + 1; // single hidden page — surface it
132 } elseif ( $gap > 2 ) {
133 $range[] = null; // ellipsis
134 }
135 }
136 $range[] = $p;
137 }
138 return $range;
139 }
140
141 /**
142 * Render a WPSubscription paginator footer (info text + prev / next / numbered
143 * buttons + ellipsis). Single source of truth used by the subscriptions list
144 * (server-side) and the subscription details cards (hydrated by JS).
145 *
146 * Pair with `WPSubsPager` in `assets/js/admin-components.js` for auto-init on
147 * `.wpsubs-pager[data-wpsubs-pager]` elements. Pass `link_mode => 'cb'` from
148 * the details page so buttons trigger the JS controller instead of navigating.
149 *
150 * Markup follows the BEM classes defined in `admin-components.css`:
151 * .wpsubs-pagination
152 * .wpsubs-pagination__info
153 * .wpsubs-pagination__nav
154 * .wpsubs-pagination__btn (mods: --active | --disabled | --ellipsis)
155 *
156 * @param array $args {
157 * @type int $current Current page (1-indexed). Default 1.
158 * @type int $total Total number of pages. Default 1.
159 * @type bool $info Whether to render the "Showing X–Y of Z" info
160 * text alongside the buttons. Default false (only
161 * buttons are rendered).
162 * @type int $per_page Items per page (used when $info is true).
163 * @type int $item_count Total items (defaults to total * per_page).
164 * @type string $base_url Base URL for server-side links (link_mode=url).
165 * The component appends ?paged= and ?per_page= query args.
166 * Ignored if `link_url_callback` is provided.
167 * @type callable $link_url_callback Optional callable `function( int $page ): string`
168 * that returns the URL for a given page. When supplied,
169 * it replaces the default `add_query_arg( $base_url )` builder
170 * and lets callers (e.g. Pro templates) build URLs with
171 * admin_url() and arbitrary query args. The returned URL is
172 * not re-escaped — callers must escape with esc_url() before
173 * returning.
174 * @type string $link_mode 'url' (default, server <a href>) or 'cb' (<button data-page>).
175 * @type string $info_format sprintf-style override for the info text. Default
176 * 'Showing %1$s–%2$s of %3$s'. Pass '%3$s subscriptions'
177 * etc. for richer copy.
178 * @type string $aria_label ARIA label on the root. Default 'Pagination'.
179 * @type string $class Extra classes on the root element.
180 * @type string $id Optional id on the root.
181 * @type array $attrs Extra HTML attributes (key => value) on the root.
182 * @type string $context Free-form hint passed to filters. Default ''.
183 * }
184 */
185 function wpsubs_render_pager( array $args ): void {
186 $args = wp_parse_args(
187 $args,
188 array(
189 'current' => 1,
190 'total' => 1,
191 'info' => false,
192 'per_page' => 10,
193 'item_count' => 0,
194 'base_url' => '',
195 'link_url_callback' => null,
196 'link_mode' => 'url',
197 'info_format' => '',
198 'aria_label' => __( 'Pagination', 'subscription' ),
199 'class' => '',
200 'id' => '',
201 'attrs' => array(),
202 'context' => '',
203 )
204 );
205
206 $current = max( 1, (int) $args['current'] );
207 $total = max( 1, (int) $args['total'] );
208 $per_page = max( 1, (int) $args['per_page'] );
209 $show_info = (bool) $args['info'];
210
211 // Item window for "Showing X–Y of Z" (only used when $info is true).
212 if ( $show_info ) {
213 // Callers that know the real item_count pass it; for cards where the
214 // row count isn't known at render time (Pro activities), item_count=0
215 // keeps the info text minimal ("0–0 of 0") and the JS rehydrates it
216 // once rows are visible.
217 if ( $args['item_count'] > 0 ) {
218 $item_total = (int) $args['item_count'];
219 } else {
220 $item_total = 0;
221 }
222 $start_item = $item_total > 0 ? ( ( $current - 1 ) * $per_page ) + 1 : 0;
223 $end_item = $item_total > 0 ? min( $current * $per_page, $item_total ) : 0;
224 } else {
225 $start_item = 0;
226 $end_item = 0;
227 $item_total = 0;
228 }
229
230 $root_classes = 'wpsubs-pager wpsubs-pagination';
231 if ( 'cb' !== $args['link_mode'] ) {
232 $root_classes .= ' wpsubs-pager--links';
233 }
234 if ( $args['class'] ) {
235 $root_classes .= ' ' . $args['class'];
236 }
237
238 $attrs_out = '';
239 foreach ( $args['attrs'] as $name => $value ) {
240 $attrs_out .= ' ' . esc_attr( $name ) . '="' . esc_attr( $value ) . '"';
241 }
242
243 $page_range = wpsubs_pager_page_range( $current, $total );
244 $has_prev = $current > 1;
245 $has_next = $current < $total;
246
247 // Filterable info string (only composed when $info is true). The default
248 // keeps the previous hard-coded copy so the existing POT entry stays the
249 // source of truth.
250 $info_text = '';
251 if ( $show_info ) {
252 // translators: Pagination: %1$s: first item, %2$s: last item, %3$s: total items
253 $info_format = '' !== $args['info_format'] ? $args['info_format'] : __( 'Showing %1$s–%2$s of %3$s', 'subscription' );
254 $info_text = sprintf(
255 $info_format,
256 number_format_i18n( $start_item ),
257 number_format_i18n( $end_item ),
258 number_format_i18n( $item_total )
259 );
260 /**
261 * Filter the paginator info text (right-hand label, e.g. "Showing 1–10 of 96").
262 *
263 * @param string $info_text Composed info text.
264 * @param int $start_item First item on the current page (1-indexed, 0 if empty).
265 * @param int $end_item Last item on the current page (1-indexed, 0 if empty).
266 * @param int $item_total Total number of items across all pages.
267 * @param string $context Caller-supplied hint from `$args['context']`.
268 */
269 $info_text = apply_filters( 'wpsubs_pager_info_text', $info_text, $start_item, $end_item, $item_total, $args['context'] );
270 }
271
272 $build_link = function ( int $page ) use ( $args ): string {
273 /**
274 * Filter the URL built for a paginator page link (server mode only).
275 *
276 * @param string $url Computed URL (may be empty).
277 * @param int $page Target page number (1-indexed).
278 * @param array $args Pager args passed to wpsubs_render_pager().
279 */
280 $url = '';
281 if ( is_callable( $args['link_url_callback'] ) ) {
282 // Caller-supplied builder takes over entirely; it must escape itself.
283 $url = (string) call_user_func( $args['link_url_callback'], $page );
284 } elseif ( $args['base_url'] ) {
285 $url = add_query_arg(
286 array(
287 'paged' => $page,
288 'per_page' => max( 1, (int) $args['per_page'] ),
289 ),
290 $args['base_url']
291 );
292 $url = esc_url( $url );
293 }
294 return apply_filters( 'wpsubs_pager_link_url', $url, $page, $args );
295 };
296
297 $render_page_btn = function ( int $p ) use ( $current, $build_link, $args ): string {
298 $is_active = $p === $current;
299 $classes = 'wpsubs-pagination__btn';
300 if ( $is_active ) {
301 $classes .= ' wpsubs-pagination__btn--active';
302 }
303 $label = (string) $p;
304 /**
305 * Filter the label rendered for a paginator page button.
306 *
307 * @param string $label Default label (the page number as a string).
308 * @param int $p Page number being rendered.
309 * @param bool $is_active Whether this is the current page.
310 */
311 $label = apply_filters( 'wpsubs_pager_page_label', $label, $p, $is_active );
312
313 if ( 'cb' === $args['link_mode'] ) {
314 return '<button type="button" class="' . esc_attr( $classes )
315 . '" data-page="' . esc_attr( (string) $p ) . '"'
316 . ( $is_active ? ' aria-current="page"' : '' )
317 . '>' . esc_html( $label ) . '</button>';
318 }
319 return '<a href="' . $build_link( $p )
320 . '" class="' . esc_attr( $classes ) . '"'
321 . ( $is_active ? ' aria-current="page"' : '' )
322 . '>' . esc_html( $label ) . '</a>';
323 };
324
325 $render_ellipsis = function (): string {
326 return '<span class="wpsubs-pagination__btn wpsubs-pagination__btn--ellipsis" aria-hidden="true">…</span>';
327 };
328
329 $render_prev = function () use ( $has_prev, $current, $build_link, $args ): string {
330 $page = $current - 1;
331 $label = '&#8249;';
332 $attrs = ' aria-label="' . esc_attr__( 'Previous page', 'subscription' ) . '"';
333 if ( $has_prev ) {
334 if ( 'cb' === $args['link_mode'] ) {
335 return '<button type="button" class="wpsubs-pagination__btn" data-page="' . esc_attr( (string) $page ) . '"' . $attrs . '>' . $label . '</button>';
336 }
337 return '<a href="' . $build_link( $page ) . '" class="wpsubs-pagination__btn"' . $attrs . '>' . $label . '</a>';
338 }
339 return '<span class="wpsubs-pagination__btn wpsubs-pagination__btn--disabled" aria-hidden="true">' . $label . '</span>';
340 };
341
342 $render_next = function () use ( $has_next, $current, $build_link, $args ): string {
343 $page = $current + 1;
344 $label = '&#8250;';
345 $attrs = ' aria-label="' . esc_attr__( 'Next page', 'subscription' ) . '"';
346 if ( $has_next ) {
347 if ( 'cb' === $args['link_mode'] ) {
348 return '<button type="button" class="wpsubs-pagination__btn" data-page="' . esc_attr( (string) $page ) . '"' . $attrs . '>' . $label . '</button>';
349 }
350 return '<a href="' . $build_link( $page ) . '" class="wpsubs-pagination__btn"' . $attrs . '>' . $label . '</a>';
351 }
352 return '<span class="wpsubs-pagination__btn wpsubs-pagination__btn--disabled" aria-hidden="true">' . $label . '</span>';
353 };
354 ?>
355 <div class="<?php echo esc_attr( $root_classes ); ?>"
356 <?php
357 if ( $args['id'] ) :
358 ?>
359 id="<?php echo esc_attr( $args['id'] ); ?>"<?php endif; ?>
360 role="navigation"
361 aria-label="<?php echo esc_attr( $args['aria_label'] ); ?>"
362 data-wpsubs-pager
363 data-current="<?php echo esc_attr( (string) $current ); ?>"
364 data-total="<?php echo esc_attr( (string) $total ); ?>"
365 data-per-page="<?php echo esc_attr( (string) $per_page ); ?>"
366 <?php
367 if ( 'cb' === $args['link_mode'] ) :
368 ?>
369 data-link-mode="cb"<?php endif; ?>
370 <?php
371 if ( $args['info_format'] ) :
372 ?>
373 data-info-format="<?php echo esc_attr( $args['info_format'] ); ?>"<?php endif; ?>
374 <?php echo $attrs_out; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built from esc_attr() parts above. ?>
375 >
376 <?php if ( $show_info ) : ?>
377 <span class="wpsubs-pagination__info"><?php echo esc_html( $info_text ); ?></span>
378 <?php endif; ?>
379 <div class="wpsubs-pagination__nav">
380 <?php echo $render_prev(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output built from safe esc_*() helpers. ?>
381 <?php
382 foreach ( $page_range as $p ) :
383 if ( null === $p ) {
384 echo $render_ellipsis(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Markup is fully escaped.
385 } else {
386 echo $render_page_btn( (int) $p ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Markup is fully escaped.
387 }
388 endforeach;
389 ?>
390 <?php echo $render_next(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output built from safe esc_*() helpers. ?>
391 </div>
392 </div>
393 <?php
394 }
395
396
397 /**
398 * Render a WooCommerce-style multiselect field.
399 *
400 * @param array $field {
401 * Field arguments.
402 *
403 * @type string $id Required. Meta key / input ID.
404 * @type string $label Field label.
405 * @type array $options Key => Label pairs for options.
406 * @type array|string $selected Optional. Selected value(s). Array, JSON, or CSV.
407 * @type string $desc_tip Optional. Description tooltip text.
408 * @type string $description Optional. Field description text.
409 * @type string $wrapper_class Optional. Extra wrapper classes.
410 * @type string $class Optional. Extra <select> classes.
411 * @type string $name Optional. Input name. Defaults to $id.'[]'.
412 * }
413 */
414 function subscrpt_multiselect_field( $field ) {
415 $defaults = [
416 'id' => '',
417 'label' => '',
418 'options' => [],
419 'selected' => [],
420 'desc_tip' => false,
421 'description' => '',
422 'wrapper_class' => '',
423 'wrapper_style' => '',
424 'class' => 'wc-enhanced-select',
425 'style' => '',
426 'name' => '',
427 ];
428
429 $field = wp_parse_args( $field, $defaults );
430
431 if ( empty( $field['id'] ) ) {
432 return;
433 }
434
435 $id = esc_attr( $field['id'] );
436 $name = $field['name'] ? $field['name'] : $id . '[]';
437 $label = esc_html( $field['label'] );
438 $description = $field['description'];
439 $desc_tip = $field['desc_tip'];
440
441 // Normalize selected values into array.
442 $selected = [];
443 if ( is_array( $field['selected'] ) ) {
444 $selected = $field['selected'];
445 } elseif ( is_string( $field['selected'] ) && $field['selected'] !== '' ) {
446 if ( false !== strpos( $field['selected'], '[' ) ) {
447 $tmp = json_decode( $field['selected'], true );
448 $selected = is_array( $tmp ) ? $tmp : [];
449 } else {
450 $selected = array_filter( array_map( 'trim', explode( ',', $field['selected'] ) ) );
451 }
452 }
453
454 // Build <option> list.
455 $options_html = '';
456 foreach ( $field['options'] as $key => $text ) {
457 $is_selected = in_array( (string) $key, (array) $selected, true ) ? ' selected="selected"' : '';
458 $options_html .= sprintf(
459 '<option value="%s"%s>%s</option>',
460 esc_attr( $key ),
461 $is_selected,
462 esc_html( $text )
463 );
464 }
465
466 $tooltip_html = '';
467 if ( $desc_tip && $description ) {
468 $tooltip_html = wc_help_tip( $description );
469 }
470
471 $description_html = '';
472 if ( $description && ! $desc_tip ) {
473 $description_html = '<span class="description">' . wp_kses_post( $description ) . '</span>';
474 }
475
476 // ? Escaped intentionally.
477 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
478 ?>
479 <p
480 class="form-field <?php echo esc_attr( $id . '_field ' . ( $field['wrapper_class'] ) ); ?>"
481 style="<?php echo esc_attr( $field['wrapper_style'] ); ?>"
482 >
483 <label for="<?php echo esc_attr( $id ); ?>">
484 <?php echo esc_html( $label ); ?>
485 </label>
486
487 <?php echo $tooltip_html; ?>
488
489 <select
490 multiple="multiple"
491 id="<?php echo esc_attr( $id ); ?>"
492 name="<?php echo esc_attr( $name ); ?>"
493 class="<?php echo esc_attr( $field['class'] ); ?>"
494 style="<?php echo esc_attr( $field['style'] ); ?>"
495 >
496 <?php echo $options_html; ?>
497 </select>
498
499 <?php echo $description_html; ?>
500 </p>
501 <?php
502 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
503 }
504
505 /**
506 * Render a preview for pages that require WPSubscription Pro, with a blurred background image and a call-to-action overlay.
507 *
508 * @param array $args Preview arguments.
509 */
510 function subscrpt_render_page_preview( array $args = [] ) {
511 $defaults = [
512 'preview_image_url' => SUBSCRPT_ASSETS . '/images/previews/subscrpt-health-preview.png',
513 'cta_title' => __( 'Upgrade to WPSubscription Pro', 'subscription' ),
514 'cta_description' => __( 'This page requires WPSubscription Pro. Unlock advanced features, priority support, and more with WPSubscription Pro.', 'subscription' ),
515 'cta_button_text' => __( '⚡ Upgrade to Pro', 'subscription' ),
516 'cta_button_url' => 'https://wpsubscription.co/?utm_source=plugin&utm_medium=admin&utm_campaign=upgrade_pro',
517 ];
518
519 $args = wp_parse_args( $args, $defaults );
520
521 ob_start();
522 ?>
523 <div style="position: relative;">
524 <div style="filter:blur(4px);pointer-events:none;">
525 <div style="max-width:1240px;margin:32px auto 0 auto;">
526 <img
527 src="<?php echo esc_url( $args['preview_image_url'] ); ?>"
528 alt="<?php esc_attr_e( 'page preview', 'subscription' ); ?>"
529 style="width:100%;display:block;"
530 />
531 </div>
532 </div>
533 <div style="position:absolute;inset:0;display:flex;align-items:top;justify-content:center;padding:100px 32px 32px;">
534 <div style="height:fit-content;background:#fff;border-radius:12px;padding:28px 32px;text-align:center;max-width:440px;box-shadow:0 8px 48px rgba(0,0,0,0.22);">
535
536 <!-- Lock icon with radial glow -->
537 <div style="position:relative;display:flex;align-items:center;justify-content:center;margin-bottom:20px;">
538 <div style="position:absolute;width:100px;height:100px;background:radial-gradient(circle,var(--wpsubs-brand-ring) 0%,transparent 70%);border-radius:50%;"></div>
539 <div style="position:relative;width:56px;height:56px;border:1.5px solid var(--wpsubs-brand);border-radius:14px;display:flex;align-items:center;justify-content:center;background:#fff;">
540 <svg width="24" height="24" fill="none" viewBox="0 0 24 24" style="stroke:var(--wpsubs-brand);" stroke-width="2" aria-hidden="true">
541 <rect x="5" y="11" width="14" height="10" rx="2"/>
542 <path stroke-linecap="round" d="M8 11V7a4 4 0 018 0v4"/>
543 </svg>
544 </div>
545 </div>
546
547 <!-- Title -->
548 <div style="font-size:22px;font-weight:700;color:#111;margin-bottom:10px;line-height:1.3;">
549 <?php echo esc_html( $args['cta_title'] ); ?>
550 </div>
551
552 <!-- Subtitle -->
553 <div style="font-size:14px;color:#6b7280;margin-bottom:20px;line-height:1.6;">
554 <?php echo esc_html( $args['cta_description'] ); ?>
555 </div>
556
557 <!-- CTA button -->
558 <a href="<?php echo esc_url( $args['cta_button_url'] ); ?>" target="_blank" style="display:flex;align-items:center;justify-content:center;gap:8px;background:var(--wpsubs-brand);color:#fff;font-size:15px;font-weight:600;padding:14px 28px;border-radius:8px;text-decoration:none;">
559 <?php echo esc_html( $args['cta_button_text'] ); ?>
560 </a>
561 </div>
562 </div>
563 </div>
564 <?php
565 return ob_get_clean();
566 }
567
568 /**
569 * Render an Advanced Select component.
570 *
571 * Outputs a styled trigger-button + dropdown that replaces a native <select>.
572 * A hidden <input> carries the selected value for form submission.
573 * JS (admin-components.js WPSubsAdvSelect) handles open/close and selection.
574 *
575 * @param array $args {
576 * @type string $name Hidden input name attribute. Required.
577 * @type string $placeholder Trigger label when nothing is selected.
578 * @type string $value Initial hidden-input value (default: '').
579 * @type array $options Each item: {
580 * string value Value submitted on selection.
581 * string label Display text.
582 * bool danger Red destructive style.
583 * string confirm JS confirm() message before selecting.
584 * bool divider Render a divider BEFORE this item.
585 * bool disabled Non-selectable item.
586 * }
587 * @type string $align Menu alignment: 'left' (default) or 'right'.
588 * @type string $id Optional id on the root element.
589 * @type string $class Extra classes on the root element.
590 * }
591 */
592 function wpsubs_render_adv_select( array $args ): void {
593 $args = wp_parse_args(
594 $args,
595 array(
596 'name' => '',
597 'placeholder' => __( 'Select', 'subscription' ),
598 'value' => '',
599 'options' => array(),
600 'align' => 'left',
601 'id' => '',
602 'class' => '',
603 'attrs' => array(),
604 )
605 );
606
607 $root_classes = 'wpsubs-adv-select wpsubs-adv-select--' . ( 'right' === $args['align'] ? 'right' : 'left' );
608 if ( $args['class'] ) {
609 $root_classes .= ' ' . $args['class'];
610 }
611
612 // Resolve trigger label: use matching option's label when a value is already set.
613 $trigger_label = $args['placeholder'];
614 $current_value = (string) $args['value'];
615 if ( '' !== $current_value && '-1' !== $current_value ) {
616 foreach ( $args['options'] as $opt ) {
617 if ( (string) ( $opt['value'] ?? '' ) === $current_value ) {
618 $trigger_label = $opt['label'] ?? $args['placeholder'];
619 break;
620 }
621 }
622 }
623
624 $chevron_svg = '<svg class="wpsubs-adv-select__chevron" xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 9l6 6 6-6"/></svg>';
625 ?>
626 <div class="<?php echo esc_attr( $root_classes ); ?>"
627 <?php
628 if ( $args['id'] ) :
629 ?>
630 id="<?php echo esc_attr( $args['id'] ); ?>"<?php endif; ?>
631 data-placeholder="<?php echo esc_attr( $args['placeholder'] ); ?>"
632 data-default-value="<?php echo esc_attr( $args['value'] ); ?>"
633 <?php
634 foreach ( $args['attrs'] as $attr_name => $attr_value ) :
635 echo esc_attr( $attr_name ) . '="' . esc_attr( $attr_value ) . '" '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Both parts escaped.
636 endforeach;
637 ?>
638 >
639 <button type="button" class="wpsubs-adv-select__trigger" aria-haspopup="listbox" aria-expanded="false">
640 <span class="wpsubs-adv-select__label"><?php echo esc_html( $trigger_label ); ?></span>
641 <?php echo $chevron_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
642 </button>
643
644 <div class="wpsubs-adv-select__menu" role="listbox">
645 <?php
646 foreach ( $args['options'] as $option ) :
647 $option = wp_parse_args(
648 $option,
649 array(
650 'value' => '',
651 'label' => '',
652 'danger' => false,
653 'confirm' => '',
654 'divider' => false,
655 'disabled' => false,
656 )
657 );
658 if ( $option['divider'] ) :
659 ?>
660 <div class="wpsubs-adv-select__divider"></div>
661 <?php
662 continue;
663 endif;
664 ?>
665 <button
666 type="button"
667 class="wpsubs-adv-select__item<?php echo $option['danger'] ? ' wpsubs-adv-select__item--danger' : ''; ?>"
668 data-value="<?php echo esc_attr( $option['value'] ); ?>"
669 <?php
670 if ( $option['confirm'] ) :
671 ?>
672 data-confirm="<?php echo esc_attr( $option['confirm'] ); ?>"<?php endif; ?>
673 <?php
674 if ( $option['disabled'] ) :
675 ?>
676 data-disabled<?php endif; ?>
677 role="option"
678 >
679 <span class="wpsubs-adv-select__item-label"><?php echo esc_html( $option['label'] ); ?></span>
680 </button>
681 <?php endforeach; ?>
682 </div>
683
684 <?php if ( $args['name'] ) : ?>
685 <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>" value="<?php echo esc_attr( $args['value'] ); ?>">
686 <?php endif; ?>
687 </div>
688 <?php
689 }
690
691 /**
692 * Render a "per page" selector as an Advanced Select.
693 *
694 * Wraps wpsubs_render_adv_select() with the project-standard page-size options
695 * (10 / 20 / 50 / 100) so every list uses the same choices. Everything is
696 * overridable: pass `options` (an array of ints, or full adv-select option
697 * arrays) to change the choices, `value` for the initial selection, and any
698 * other wpsubs_render_adv_select() arg (name, align, id, class, attrs) — they
699 * pass straight through.
700 *
701 * @param array $args {
702 * @type string $name Hidden input name.
703 * @type string $value Initial value. Default '10'.
704 * @type array $options Page sizes (ints) or option arrays. Default 10/20/50/100.
705 * @type mixed ... Any other wpsubs_render_adv_select() arg.
706 * }
707 *
708 * @return void
709 */
710 function wpsubs_render_per_page_select( array $args = array() ): void {
711 $args = array_merge(
712 array(
713 'value' => '10',
714 'options' => array( 10, 20, 50, 100 ),
715 ),
716 $args
717 );
718
719 // Expand plain int page sizes into adv-select option arrays.
720 $options = array();
721 foreach ( $args['options'] as $option ) {
722 if ( is_array( $option ) ) {
723 $options[] = $option;
724 continue;
725 }
726 $options[] = array(
727 'value' => (string) $option,
728 /* translators: %d: number of items shown per page. */
729 'label' => sprintf( __( '%d / page', 'subscription' ), (int) $option ),
730 );
731 }
732
733 $args['options'] = $options;
734 $args['value'] = (string) $args['value'];
735
736 wpsubs_render_adv_select( $args );
737 }
738
739 /**
740 * Render a tag/pill select input with an inline filter and filterable dropdown.
741 * Supports single and multiple selection. No external dependencies.
742 *
743 * JS: WPSubsTagSelect (admin-components.js) auto-inits elements.
744 * Event fired on root: `wpsubs:select` — detail: { value, label, selected }
745 *
746 * @param array $args {
747 * string $name Form field name (base name, without [] suffix).
748 * string $placeholder Input placeholder shown when nothing is selected.
749 * string|array $value Current value(s). Array for multiple, string for single.
750 * array $options Options: array of { value, label, disabled? }.
751 * bool $multiple Enable multi-select mode.
752 * string $id Optional root element id.
753 * string $class Extra CSS classes for the root element.
754 * array $attrs Extra HTML attributes for the root element.
755 * }
756 */
757 function wpsubs_render_tag_select( array $args ): void {
758 $args = wp_parse_args(
759 $args,
760 array(
761 'name' => '',
762 'placeholder' => __( 'Select...', 'subscription' ),
763 'value' => '',
764 'options' => array(),
765 'multiple' => false,
766 'id' => '',
767 'class' => '',
768 'attrs' => array(),
769 )
770 );
771
772 $multiple = (bool) $args['multiple'];
773 $current_value = $multiple ? (array) $args['value'] : (string) $args['value'];
774
775 if ( $multiple ) {
776 $selected_values = array_filter( array_map( 'strval', $current_value ), fn( $v ) => '' !== $v );
777 } else {
778 $selected_values = ( '' !== $current_value ) ? array( $current_value ) : array();
779 }
780
781 // Map selected values to their labels for pill rendering.
782 $selected_labels = array();
783 foreach ( $args['options'] as $opt ) {
784 $opt_val = (string) ( $opt['value'] ?? '' );
785 if ( in_array( $opt_val, $selected_values, true ) ) {
786 $selected_labels[ $opt_val ] = $opt['label'] ?? $opt_val;
787 }
788 }
789
790 $root_classes = 'wpsubs-tag-select';
791 if ( $multiple ) {
792 $root_classes .= ' wpsubs-tag-select--multi';
793 }
794 if ( $args['class'] ) {
795 $root_classes .= ' ' . $args['class'];
796 }
797
798 $has_pills = ! empty( $selected_values );
799 $placeholder = $has_pills ? '' : esc_attr( $args['placeholder'] );
800
801 $chevron_svg = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 9l6 6 6-6"/></svg>';
802 ?>
803 <div
804 class="<?php echo esc_attr( $root_classes ); ?>"
805 <?php
806 if ( $args['id'] ) :
807 ?>
808 id="<?php echo esc_attr( $args['id'] ); ?>"<?php endif; ?>
809 data-placeholder="<?php echo esc_attr( $args['placeholder'] ); ?>"
810 data-name="<?php echo esc_attr( $args['name'] ); ?>"
811 <?php
812 if ( $multiple ) :
813 ?>
814 data-multiple="1"<?php endif; ?>
815 <?php
816 foreach ( $args['attrs'] as $attr_name => $attr_value ) :
817 echo esc_attr( $attr_name ) . '="' . esc_attr( $attr_value ) . '" '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Both parts escaped.
818 endforeach;
819 ?>
820 >
821 <div class="wpsubs-tag-select__field">
822 <?php foreach ( $selected_labels as $val => $lbl ) : ?>
823 <span class="wpsubs-tag-select__pill" data-value="<?php echo esc_attr( $val ); ?>">
824 <span class="wpsubs-tag-select__pill-label"><?php echo esc_html( $lbl ); ?></span>
825 <button type="button" class="wpsubs-tag-select__pill-remove" aria-label="<?php esc_attr_e( 'Remove', 'subscription' ); ?>">&#x2715;</button>
826 </span>
827 <?php endforeach; ?>
828 <input
829 type="text"
830 class="wpsubs-tag-select__input"
831 placeholder="<?php echo $placeholder; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already esc_attr'd above. ?>"
832 autocomplete="off"
833 aria-label="<?php esc_attr_e( 'Filter options', 'subscription' ); ?>"
834 />
835 <span class="wpsubs-tag-select__chevron" aria-hidden="true">
836 <?php echo $chevron_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
837 </span>
838 </div>
839
840 <div class="wpsubs-tag-select__dropdown">
841 <div class="wpsubs-tag-select__list" role="listbox"
842 <?php
843 if ( $multiple ) :
844 ?>
845 aria-multiselectable="true"<?php endif; ?>>
846 <?php
847 foreach ( $args['options'] as $option ) :
848 $option = wp_parse_args(
849 $option,
850 array(
851 'value' => '',
852 'label' => '',
853 'disabled' => false,
854 )
855 );
856 $opt_value = (string) $option['value'];
857 $is_selected = in_array( $opt_value, $selected_values, true );
858 ?>
859 <button
860 type="button"
861 class="wpsubs-tag-select__item"
862 data-value="<?php echo esc_attr( $opt_value ); ?>"
863 role="option"
864 aria-selected="<?php echo $is_selected ? 'true' : 'false'; ?>"
865 <?php
866 if ( $is_selected ) :
867 ?>
868 data-selected<?php endif; ?>
869 <?php
870 if ( $option['disabled'] ) :
871 ?>
872 data-disabled<?php endif; ?>
873 style="<?php echo $is_selected ? 'display:none;' : ''; ?>"
874 ><?php echo esc_html( $option['label'] ); ?></button>
875 <?php endforeach; ?>
876 </div>
877 <div class="wpsubs-tag-select__empty"><?php esc_html_e( 'No results found.', 'subscription' ); ?></div>
878 </div>
879
880 <?php if ( $args['name'] ) : ?>
881 <?php if ( $multiple ) : ?>
882 <?php if ( empty( $selected_values ) ) : ?>
883 <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>[]" value="" data-ts-val />
884 <?php else : ?>
885 <?php foreach ( $selected_values as $val ) : ?>
886 <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>[]" value="<?php echo esc_attr( $val ); ?>" data-ts-val />
887 <?php endforeach; ?>
888 <?php endif; ?>
889 <?php else : ?>
890 <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>" value="<?php echo esc_attr( $current_value ); ?>" data-ts-val />
891 <?php endif; ?>
892 <?php endif; ?>
893 </div>
894 <?php
895 }
896
897 /**
898 * Render a modal dialog (admin-components `wpsubs-modal`).
899 *
900 * Behaviour is handled by WPSubsModal (admin-components.js): open it from any
901 * control with `data-wpsubs-modal-open="<id>"`; the backdrop, header close, and
902 * footer buttons close it; Escape closes it. The dialog is hidden until opened.
903 *
904 * @param array $args Modal arguments: `id` (required, matches the opener's target),
905 * `title` (header title), `body` (pre-escaped body HTML), `footer`
906 * (pre-escaped footer HTML, optional), `class` (extra root class,
907 * optional).
908 * @return void
909 */
910 function wpsubs_render_modal( array $args ): void {
911 $id = $args['id'] ?? '';
912 if ( empty( $id ) ) {
913 return;
914 }
915
916 $title = $args['title'] ?? '';
917 $body = $args['body'] ?? '';
918 $footer = $args['footer'] ?? '';
919 $extra_class = $args['class'] ?? '';
920 ?>
921 <div class="wpsubs-modal <?php echo esc_attr( $extra_class ); ?>" id="<?php echo esc_attr( $id ); ?>" hidden>
922 <div class="wpsubs-modal__backdrop" data-wpsubs-modal-close></div>
923 <div class="wpsubs-modal__dialog" role="dialog" aria-modal="true"<?php echo $title ? ' aria-label="' . esc_attr( $title ) . '"' : ''; ?>>
924 <div class="wpsubs-modal__head">
925 <span class="wpsubs-modal__title"><?php echo esc_html( $title ); ?></span>
926 <button type="button" class="wpsubs-modal__close" data-wpsubs-modal-close aria-label="<?php esc_attr_e( 'Close', 'subscription' ); ?>">&times;</button>
927 </div>
928 <div class="wpsubs-modal__body">
929 <?php
930 // Body is pre-escaped by the caller; re-escaping would break markup.
931 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
932 echo $body;
933 ?>
934 </div>
935 <?php if ( '' !== $footer ) : ?>
936 <div class="wpsubs-modal__footer">
937 <?php
938 // Footer is pre-escaped by the caller.
939 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
940 echo $footer;
941 ?>
942 </div>
943 <?php endif; ?>
944 </div>
945 </div>
946 <?php
947 }
948
949 /**
950 * Render an inline help hint: a help icon that reveals text on hover.
951 *
952 * Thin wrapper over the canonical `wpsubs-tooltip` component
953 * (admin-components/tooltip.css): a help dashicon wrapped in a data-tip span.
954 * Returns the markup so it can be concatenated into a label; place it inside an
955 * overflow:visible container so the bubble is not clipped.
956 *
957 * @param string $text Hint text.
958 * @param array $args Optional. 'placement' => 'top' (default)|'bottom'|'left'|'right';
959 * 'align' => 'center' (default)|'start'|'end'; 'class' => extra trigger classes.
960 *
961 * @return string Escaped markup.
962 */
963 function wpsubs_render_hint( string $text, array $args = array() ): string {
964 $args = wp_parse_args(
965 $args,
966 array(
967 'placement' => 'top',
968 'align' => 'center',
969 'class' => '',
970 )
971 );
972
973 $classes = 'wpsubs-tooltip wpsubs-tooltip--hint';
974 if ( in_array( $args['placement'], array( 'bottom', 'left', 'right' ), true ) ) {
975 $classes .= ' wpsubs-tooltip--' . $args['placement'];
976 }
977 if ( in_array( $args['align'], array( 'start', 'end' ), true ) ) {
978 $classes .= ' wpsubs-tooltip--' . $args['align'];
979 }
980 if ( '' !== $args['class'] ) {
981 $classes .= ' ' . $args['class'];
982 }
983
984 // A <span> (not a <button>/<input>) is NOT a labelable element, so nesting
985 // it inside a <label> does not associate the label with it — hovering the
986 // label text therefore never reveals the tooltip, only hovering the icon
987 // does. The text is exposed to assistive tech via role="img" + aria-label.
988 return sprintf(
989 '<span class="%1$s" data-tip="%2$s" role="img" aria-label="%2$s">'
990 . '<span class="dashicons dashicons-editor-help wpsubs-tooltip__icon" aria-hidden="true"></span>'
991 . '</span>',
992 esc_attr( $classes ),
993 esc_attr( $text )
994 );
995 }
996