PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.0.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.0.0
2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 All 61 releases
← All changes | includes/Admin/Menu.php +219 -46 1.11.22.0.0 View file →
@@ -1,9 +1,15 @@
1 1 <?php
2 +/**
3 + * Admin menu + shared admin header/breadcrumb renderer.
4 + *
5 + * @package SpringDevs\Subscription\Admin
6 + */
2 7
3 8 namespace SpringDevs\Subscription\Admin;
4 9
5 10 use SpringDevs\Subscription\Illuminate\Helper;
11 +use SpringDevs\Subscription\Illuminate\Stats;
6 12
7 13 /**
8 14 * Menu class
9 15 *
@@ -18,11 +24,48 @@
18 24 add_action( 'admin_menu', array( $this, 'create_admin_menu' ) );
19 25 add_action( 'admin_menu', array( $this, 'reorder_submenu' ), 999 );
20 26 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) );
21 27 add_action( 'wp_ajax_subscrpt_bulk_action', array( $this, 'handle_bulk_action_ajax' ) );
28 + add_action( 'admin_init', array( $this, 'maybe_onboarding_redirect' ) );
22 29 }
23 30
24 31 /**
32 + * Send a first-time user to the onboarding wizard when they open the
33 + * WPSubscription dashboard with no plan created yet.
34 + *
35 + * This runs on the dashboard visit rather than on activation, so it works
36 + * regardless of when WooCommerce gets installed (the plugin only loads its
37 + * admin once WooCommerce is active). A persistent "seen" flag makes it fire
38 + * at most once, so the user is never trapped away from the dashboard.
39 + *
40 + * @return void
41 + */
42 + public function maybe_onboarding_redirect() {
43 + // Only on the WPSubscription dashboard page.
44 + if ( ! isset( $_GET['page'] ) || 'wp-subscription' !== $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- menu navigation, no state change.
45 + return;
46 + }
47 +
48 + if ( wp_doing_ajax() || is_network_admin() || ! current_user_can( 'manage_options' ) ) {
49 + return;
50 + }
51 +
52 + // Fire at most once, ever.
53 + if ( get_option( 'subscrpt_onboarding_seen' ) ) {
54 + return;
55 + }
56 + update_option( 'subscrpt_onboarding_seen', 1, false );
57 +
58 + // Only first-time users with no plan yet.
59 + if ( ! empty( \SpringDevs\Subscription\Illuminate\Plans\PlanRepository::get_groups() ) ) {
60 + return;
61 + }
62 +
63 + wp_safe_redirect( admin_url( 'admin.php?page=wp-subscription-onboarding' ) );
64 + exit;
65 + }
66 +
67 + /**
25 68 * Enqueue admin assets
26 69 */
27 70 public function enqueue_admin_assets() {
28 71 wp_enqueue_style(
@@ -48,16 +91,25 @@
48 91 array(),
49 92 SUBSCRPT_VERSION
50 93 );
51 94
52 - // Enqueue onboarding wizard JS (loaded on wizard page)
95 + // Enqueue onboarding wizard JS (loaded on wizard page). Depends on the
96 + // admin components so the cadence picker (adv-select) is ready.
53 97 wp_enqueue_script(
54 98 'subscrpt-onboarding-wizard',
55 99 SUBSCRPT_ASSETS . '/js/admin/onboarding-wizard.js',
56 - array( 'jquery' ),
100 + array( 'jquery', 'subscrpt_admin_components' ),
57 101 SUBSCRPT_VERSION,
58 102 true
59 103 );
104 + $subscrpt_wizard_has_products = (bool) wc_get_products(
105 + array(
106 + 'status' => array( 'publish', 'draft', 'pending', 'private' ),
107 + 'limit' => 1,
108 + 'return' => 'ids',
109 + )
110 + );
111 +
60 112 wp_localize_script(
61 113 'subscrpt-onboarding-wizard',
62 114 'subscrpt_wizard',
63 115 array(
@@ -62,9 +114,16 @@
62 114 'subscrpt_wizard',
63 115 array(
64 116 'ajax_url' => admin_url( 'admin-ajax.php' ),
65 117 'subscriptions_url' => admin_url( 'admin.php?page=wp-subscription' ),
118 + 'dashboard_url' => admin_url( 'admin.php?page=wp-subscription' ),
119 + 'products_url' => admin_url( 'edit.php?post_type=product' ),
120 + 'plans_url' => admin_url( 'admin.php?page=wp-subscription-plans' ),
121 + 'rest_url' => rest_url( 'wpsubscription/v1/plans' ),
122 + 'rest_nonce' => wp_create_nonce( 'wp_rest' ),
66 123 'currency_symbol' => get_woocommerce_currency_symbol(),
124 + 'is_pro' => subscrpt_pro_activated(),
125 + 'has_products' => $subscrpt_wizard_has_products,
67 126 )
68 127 );
69 128
70 129 // Localize script for AJAX
@@ -97,9 +156,9 @@
97 156 __( 'WPSubscription', 'subscription' ),
98 157 __( 'WPSubscription', 'subscription' ),
99 158 'manage_options',
100 159 $parent_slug,
101 - array( $this, 'render_subscriptions_page' ),
160 + array( $this, 'render_dashboard_page' ),
102 161 $icon_url,
103 162 40
104 163 );
105 164
@@ -113,15 +172,28 @@
113 172 'wp-subscription-onboarding',
114 173 array( $this, 'render_onboarding_wizard' )
115 174 );
116 175
117 - // Subscriptions List
176 + // Overview. WordPress makes the first submenu entry share the parent
177 + // slug, so this is the page the top-level item opens.
118 178 add_submenu_page(
119 179 $parent_slug,
180 + __( 'Overview', 'subscription' ),
181 + __( 'Overview', 'subscription' ),
182 + 'manage_options',
183 + $parent_slug,
184 + array( $this, 'render_dashboard_page' )
185 + );
186 +
187 + // Subscriptions List. Moved off the parent slug when the dashboard took
188 + // it; render_dashboard_page() redirects here when the request carries
189 + // list-only query arguments, so old bookmarks still work.
190 + add_submenu_page(
191 + $parent_slug,
120 192 __( 'Subscriptions', 'subscription' ),
121 193 __( 'Subscriptions', 'subscription' ),
122 194 'manage_options',
123 - $parent_slug,
195 + 'wp-subscription-list',
124 196 array( $this, 'render_subscriptions_page' )
125 197 );
126 198
127 199 // Subscription Details (hidden from menu with CSS — accessed via admin.php?page=wp-subscription-details&id=ID).
@@ -174,16 +246,24 @@
174 246 'wp-subscription-support',
175 247 array( $this, 'render_support_page' )
176 248 );
177 249
178 - // Add WPSubscription link under WooCommerce menu
250 + /*
251 + * Subscriptions link under the WooCommerce menu, opening the list.
252 + *
253 + * A link, not a page: the slug is the list's URL and there is no
254 + * callback, so WordPress registers no page and no hook for it.
255 + * Registering `wp-subscription-list` under `woocommerce` as well would
256 + * give the list a second hookname (`woocommerce_page_…` beside
257 + * `wpsubscription_page_…`), and whichever parent WordPress found first
258 + * would set the page's $hook_suffix, which its asset loading keys on.
259 + */
179 260 add_submenu_page(
180 261 'woocommerce',
181 - __( 'WPSubscription', 'subscription' ),
182 - __( 'WPSubscription', 'subscription' ),
262 + __( 'Subscriptions', 'subscription' ),
263 + __( 'Subscriptions', 'subscription' ),
183 264 'manage_options',
184 - 'wp-subscription',
185 - array( $this, 'render_subscriptions_page' )
265 + 'admin.php?page=wp-subscription-list'
186 266 );
187 267 }
188 268
189 269 /**
@@ -204,26 +284,25 @@
204 284 return;
205 285 }
206 286
207 287 // slug => position. Use gaps of 10 so extensions can insert between items.
288 + //
289 + // The order is the same whether or not pro is active: a locked page
290 + // carries a pro badge but keeps its place, so the menu does not
291 + // rearrange itself the moment a licence is activated. Plans and
292 + // Cancellation Flow position themselves through the filter below.
208 293 $default_order = [
209 - 'wp-subscription' => 10, // Subscriptions
210 - 'wp-subscription-stats' => 20, // Reports
211 - 'wp-subscription-delivery' => 30, // Delivery (pro)
212 - 'wp-subscription-health' => 50, // Health
213 - 'wp-subscription-integrations' => 60, // Integrations
214 - 'wp-subscription-support' => 70, // Help & Resources
294 + 'wp-subscription' => 5, // Overview
295 + 'wp-subscription-delivery' => 20, // Delivery (pro)
296 + 'wp-subscription-list' => 30, // Subscriptions
297 + 'wp-subscription-stats' => 40, // Reports
298 + 'wp-subscription-health' => 50, // Health
299 + 'wp-subscription-integrations' => 60, // Integrations
215 300 'wp-subscription-settings' => 998, // Settings
216 301 'wp-subscription-license' => 999, // License (pro)
302 + 'wp-subscription-support' => 1000, // Help & Resources
217 303 ];
218 304
219 - // Place pro pages at the bottom if pro is not active.
220 - if ( ! subscrpt_pro_activated() ) {
221 - $default_order['wp-subscription-stats'] = 200;
222 - $default_order['wp-subscription-delivery'] = 210;
223 - $default_order['wp-subscription-health'] = 220;
224 - }
225 -
226 305 /**
227 306 * Filter the WPSubscription submenu slug order.
228 307 *
229 308 * Each entry is a slug => integer position pair. Lower positions appear
@@ -229,11 +308,11 @@
229 308 * Each entry is a slug => integer position pair. Lower positions appear
230 309 * first. Use gaps of 10 between built-in positions so extensions can
231 310 * insert their own slugs between existing items without renumbering.
232 311 *
233 - * Example (pro plugin adding Delivery at position 35):
312 + * Example (an add-on placing its page between Reports and Health):
234 313 * add_filter( 'subscrpt_submenu_order', function( $order ) {
235 - * $order['wp-subscription-delivery'] = 35;
314 + * $order['wp-subscription-my-addon'] = 45;
236 315 * return $order;
237 316 * } );
238 317 *
239 318 * @param array<string,int> $order Map of slug => position.
@@ -305,23 +384,28 @@
305 384 unset( $menu_items ); // Nav no longer rendered in header; navigation is in WP sidebar.
306 385
307 386 // Normalize to a single trail. Explicit breadcrumbs win; otherwise the
308 387 // legacy single $title segment is used.
388 + // A long breadcrumb label is truncated for display; the full text is kept
389 + // so the renderer can add it as a title attribute.
390 + $make_crumb = static function ( $label, $url ) {
391 + $label = (string) $label;
392 + return [
393 + 'label' => subscrpt_truncate_text( $label ),
394 + 'full' => $label,
395 + 'url' => (string) $url,
396 + ];
397 + };
398 +
309 399 $trail = [];
310 400 if ( ! empty( $breadcrumbs ) ) {
311 401 foreach ( $breadcrumbs as $crumb ) {
312 402 if ( is_array( $crumb ) && '' !== ( $crumb['label'] ?? '' ) ) {
313 - $trail[] = [
314 - 'label' => (string) $crumb['label'],
315 - 'url' => isset( $crumb['url'] ) ? (string) $crumb['url'] : '',
316 - ];
403 + $trail[] = $make_crumb( $crumb['label'], $crumb['url'] ?? '' );
317 404 }
318 405 }
319 406 } elseif ( '' !== $title ) {
320 - $trail[] = [
321 - 'label' => $title,
322 - 'url' => '',
323 - ];
407 + $trail[] = $make_crumb( $title, '' );
324 408 }
325 409
326 410 $last_index = count( $trail ) - 1;
327 411 ?>
@@ -333,17 +417,51 @@
333 417 <span class="dashicons dashicons-admin-home"></span>
334 418 </a>
335 419 <?php foreach ( $trail as $index => $crumb ) : ?>
336 420 <span class="wp-subscription-breadcrumb-sep" aria-hidden="true">/</span>
421 + <?php $crumb_title = $crumb['full'] !== $crumb['label'] ? ' title="' . esc_attr( $crumb['full'] ) . '"' : ''; ?>
337 422 <?php if ( '' !== $crumb['url'] && $index < $last_index ) : ?>
338 - <a href="<?php echo esc_url( $crumb['url'] ); ?>" class="wp-subscription-breadcrumb-link"><?php echo esc_html( $crumb['label'] ); ?></a>
423 + <a href="<?php echo esc_url( $crumb['url'] ); ?>" class="wp-subscription-breadcrumb-link"<?php echo $crumb_title; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- attribute pre-escaped. ?>><?php echo esc_html( $crumb['label'] ); ?></a>
339 424 <?php else : ?>
340 - <span class="wp-subscription-breadcrumb-current"><?php echo esc_html( $crumb['label'] ); ?></span>
425 + <span class="wp-subscription-breadcrumb-current"<?php echo $crumb_title; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- attribute pre-escaped. ?>><?php echo esc_html( $crumb['label'] ); ?></span>
341 426 <?php endif; ?>
342 427 <?php endforeach; ?>
343 428 </nav>
344 429 </div>
345 430 <div class="wp-subscription-admin-header-right">
431 + <?php
432 + /**
433 + * Filters the pro licence state shown in the admin header.
434 + *
435 + * This plugin cannot ask the pro plugin directly — it runs alone
436 + * on nearly every install, so naming a symbol pro declares would
437 + * fatal there. Pro answers this filter when it is present; when
438 + * it is not, the value stays null and no badge is rendered.
439 + *
440 + * @since 1.11.3
441 + *
442 + * @param array|null $license {
443 + * Licence state, or null when pro is not installed.
444 + *
445 + * @type bool $active Whether the licence is valid.
446 + * @type string $url Admin URL of the licence page.
447 + * }
448 + */
449 + $license = apply_filters( 'subscrpt_admin_header_license', null );
450 +
451 + // Only the "Activate license" badge is shown; the "License active"
452 + // badge is intentionally hidden from the header.
453 + if ( is_array( $license ) && isset( $license['active'] ) && ! $license['active'] ) :
454 + $license_url = isset( $license['url'] ) ? (string) $license['url'] : '';
455 + ?>
456 + <a href="<?php echo esc_url( $license_url ); ?>" class="wpsubs-badge wpsubs-badge--warning wp-subscription-license-badge">
457 + <span class="wpsubs-badge__dot"></span>
458 + <?php esc_html_e( 'Activate license', 'subscription' ); ?>
459 + </a>
460 + <?php
461 + endif;
462 + ?>
463 +
346 464 <?php if ( ! class_exists( 'Sdevs_Wc_Subscription_Pro' ) ) : ?>
347 465 <a target="_blank" href="https://wpsubscription.co/?utm_source=plugin&utm_medium=admin&utm_campaign=upgrade_pro" class="wpsubs-btn wpsubs-btn--primary wpsubs-btn--sm" rel="noreferrer noopener">
348 466 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="flex-shrink:0;"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 19h-14c-.5 0 -.9 -.3 -1 -.8l-2 -10c0 -.4 .1 -.8 .5 -1.1c.4 -.2 .8 -.2 1.1 0l4.1 3.3l3.4 -5.1c.4 -.6 1.3 -.6 1.7 0l3.4 5.1l4.1 -3.3c.3 -.3 .8 -.3 1.1 0c.4 .2 .5 .6 .5 1.1l-2 10c0 .5 -.5 .8 -1 .8z"/></svg>
349 467 <?php esc_html_e( 'Upgrade to Pro', 'subscription' ); ?>
@@ -356,8 +474,46 @@
356 474 <?php
357 475 }
358 476
359 477 /**
478 + * Render the dashboard, or hand off to the list.
479 + *
480 + * The subscriptions list used to live on this slug. Anything still linking
481 + * here with a list-only argument — a saved filter, a bookmarked search, a
482 + * pagination link — means the list, so it is sent there with its arguments
483 + * intact rather than landing on a dashboard that ignores them. The one
484 + * exception is `post_status`, renamed to the `subscrpt_status` the list reads.
485 + *
486 + * @return void
487 + */
488 + public function render_dashboard_page() {
489 + // phpcs:disable WordPress.Security.NonceVerification.Recommended
490 + $list_args = array( 'post_status', 's', 'paged', 'filter_action', 'orderby', 'order', 'subscrpt_status', 'date_filter', 'per_page', 'renewal_due' );
491 +
492 + foreach ( $list_args as $arg ) {
493 + if ( isset( $_GET[ $arg ] ) && '' !== $_GET[ $arg ] ) {
494 + $query = wp_unslash( $_GET );
495 + $query['page'] = 'wp-subscription-list';
496 +
497 + // The list filters on `subscrpt_status` and ignores `post_status`,
498 + // so forwarding the old name as-is lands on an unfiltered list.
499 + if ( isset( $query['post_status'] ) ) {
500 + if ( empty( $query['subscrpt_status'] ) ) {
501 + $query['subscrpt_status'] = $query['post_status'];
502 + }
503 + unset( $query['post_status'] );
504 + }
505 +
506 + wp_safe_redirect( add_query_arg( array_map( 'sanitize_text_field', $query ), admin_url( 'admin.php' ) ) );
507 + exit;
508 + }
509 + }
510 + // phpcs:enable WordPress.Security.NonceVerification.Recommended
511 +
512 + ( new Dashboard() )->render();
513 + }
514 +
515 + /**
360 516 * Render Subscriptions page
361 517 */
362 518 public function render_subscriptions_page() {
363 519 $this->render_admin_header( __( 'Subscriptions', 'subscription' ), __( 'Manage your subscriptions', 'subscription' ) );
@@ -367,8 +523,9 @@
367 523 $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '';
368 524 $date_filter = isset( $_GET['date_filter'] ) ? sanitize_text_field( wp_unslash( $_GET['date_filter'] ) ) : '';
369 525 $per_page = isset( $_GET['per_page'] ) ? max( 1, intval( $_GET['per_page'] ) ) : 20;
370 526 $paged = isset( $_GET['paged'] ) ? max( 1, intval( $_GET['paged'] ) ) : 1;
527 + $renewal_due = isset( $_GET['renewal_due'] ) ? min( 366, absint( $_GET['renewal_due'] ) ) : 0;
371 528
372 529 // Handle form submissions (both filters and bulk actions)
373 530 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
374 531 if ( 'POST' === $request_method ) {
@@ -398,9 +555,9 @@
398 555 wp_delete_post( $sub_id, true );
399 556 }
400 557 }
401 558
402 - wp_safe_redirect( admin_url( 'admin.php?page=wp-subscription' ) );
559 + wp_safe_redirect( admin_url( 'admin.php?page=wp-subscription-list' ) );
403 560 exit;
404 561 }
405 562 }
406 563
@@ -413,8 +570,11 @@
413 570 }
414 571 if ( ! empty( $_POST['date_filter'] ) ) {
415 572 $filter_params['date_filter'] = sanitize_text_field( wp_unslash( $_POST['date_filter'] ) );
416 573 }
574 + if ( ! empty( $_POST['renewal_due'] ) ) {
575 + $filter_params['renewal_due'] = absint( $_POST['renewal_due'] );
576 + }
417 577 if ( ! empty( $_POST['s'] ) ) {
418 578 $filter_params['s'] = sanitize_text_field( wp_unslash( $_POST['s'] ) );
419 579 }
420 580 if ( ! empty( $_POST['per_page'] ) ) {
@@ -420,9 +580,9 @@
420 580 if ( ! empty( $_POST['per_page'] ) ) {
421 581 $filter_params['per_page'] = intval( $_POST['per_page'] );
422 582 }
423 583
424 - $redirect_url = add_query_arg( $filter_params, admin_url( 'admin.php?page=wp-subscription' ) );
584 + $redirect_url = add_query_arg( $filter_params, admin_url( 'admin.php?page=wp-subscription-list' ) );
425 585 wp_safe_redirect( $redirect_url );
426 586 exit;
427 587 }
428 588 }
@@ -455,9 +615,9 @@
455 615 foreach ( $trash_posts as $trash_id ) {
456 616 wp_delete_post( $trash_id, true );
457 617 }
458 618
459 - wp_safe_redirect( admin_url( 'admin.php?page=wp-subscription&subscrpt_status=trash' ) );
619 + wp_safe_redirect( admin_url( 'admin.php?page=wp-subscription-list&subscrpt_status=trash' ) );
460 620 exit;
461 621 } else {
462 622 // For other actions, verify nonce with subscription ID.
463 623 $nonce_action = 'wpsubs_action_' . $sub_id;
@@ -465,9 +625,9 @@
465 625 echo '<div class="notice notice-error"><p>' . esc_html__( 'Security check failed. Please try again.', 'subscription' ) . '</p></div>';
466 626 wp_die();
467 627 }
468 628
469 - $redirect_url = admin_url( 'admin.php?page=wp-subscription' );
629 + $redirect_url = admin_url( 'admin.php?page=wp-subscription-list' );
470 630
471 631 switch ( $action ) {
472 632 case 'duplicate':
473 633 $post = get_post( $sub_id );
@@ -496,9 +656,9 @@
496 656 wp_untrash_post( $sub_id );
497 657 break;
498 658 case 'delete':
499 659 wp_delete_post( $sub_id, true );
500 - $redirect_url = admin_url( 'admin.php?page=wp-subscription&subscrpt_status=trash' );
660 + $redirect_url = admin_url( 'admin.php?page=wp-subscription-list&subscrpt_status=trash' );
501 661 break;
502 662 }
503 663
504 664 wp_safe_redirect( $redirect_url );
@@ -535,9 +695,24 @@
535 695 'year' => intval( $year ),
536 696 'month' => intval( $month ),
537 697 ];
538 698 }
699 + // Next-renewal window, soonest first. The same arguments as the
700 + // Overview's "Renewals due" figure, so its 7-day rows equal its count.
701 + if ( $renewal_due ) {
702 + $due_args = Stats::renewals_due_args( $renewal_due );
539 703
704 + // Only an active subscription renews, so a renewal window combined
705 + // with any other status matches nothing — the filters AND together.
706 + if ( $status && $due_args['post_status'] !== $status ) {
707 + $args['post__in'] = array( 0 );
708 + }
709 +
710 + $args['post_status'] = $due_args['post_status'];
711 + $args['meta_query'] = $due_args['meta_query']; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
712 + $args['orderby'] = [ 'subscrpt_next_date' => 'ASC' ];
713 + }
714 +
540 715 $query = new \WP_Query( $args );
541 716 $subscriptions = $query->posts;
542 717 $total = $query->found_posts;
543 718 $max_num_pages = $query->max_num_pages;
@@ -576,9 +751,9 @@
576 751 if ( ! current_user_can( 'edit_post', $subscription_id ) ) {
577 752 wp_die( esc_html__( 'You do not have permission to view this subscription.', 'subscription' ) );
578 753 }
579 754
580 - $list_url = admin_url( 'admin.php?page=wp-subscription' );
755 + $list_url = admin_url( 'admin.php?page=wp-subscription-list' );
581 756 $form_action = admin_url( 'admin.php?page=wp-subscription-details&id=' . $subscription_id );
582 757
583 758 // Handle the status-change submission.
584 759 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
@@ -626,8 +801,9 @@
626 801 'active' => array( 'pe_cancelled', 'cancelled' ),
627 802 'pe_cancelled' => array( 'active', 'cancelled' ),
628 803 'cancelled' => array( 'active' ),
629 804 'expired' => array( 'active', 'cancelled' ),
805 + 'completed' => array( 'cancelled' ),
630 806 );
631 807
632 808 $status = get_post_status( $subscription_id );
633 809 $actions = $map_actions[ $status ] ?? array();
@@ -728,17 +904,14 @@
728 904 * SPA-style: all sections rendered at once, JS controls visibility
729 905 * Initial load always shows page 1 (JS handles transitions from there)
730 906 */
731 907 public function render_onboarding_wizard() {
732 - // Start session if not already started
908 + // Start session if not already started (used by the wizard reset handler).
733 909 if ( ! session_id() && ! headers_sent() ) {
734 910 session_start();
735 911 }
736 912
737 - // Always start at page 1 on direct load (SPA behavior — JS drives page transitions)
738 - $GLOBALS['wizard_page'] = 1;
739 -
740 - $this->render_admin_header( __( 'Setup Wizard', 'subscription' ), __( 'Create your first subscription product', 'subscription' ) );
913 + $this->render_admin_header( __( 'Setup Wizard', 'subscription' ), __( 'Create your first subscription plan', 'subscription' ) );
741 914 include __DIR__ . '/views/onboarding-wizard.php';
742 915 $this->render_admin_footer();
743 916 }
744 917