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 +351 -49 1.10.32.0.0 View file →
@@ -1,8 +1,16 @@
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
10 +use SpringDevs\Subscription\Illuminate\Helper;
11 +use SpringDevs\Subscription\Illuminate\Stats;
12 +
5 13 /**
6 14 * Menu class
7 15 *
8 16 * @package SpringDevs\Subscription\Admin
@@ -16,11 +24,48 @@
16 24 add_action( 'admin_menu', array( $this, 'create_admin_menu' ) );
17 25 add_action( 'admin_menu', array( $this, 'reorder_submenu' ), 999 );
18 26 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) );
19 27 add_action( 'wp_ajax_subscrpt_bulk_action', array( $this, 'handle_bulk_action_ajax' ) );
28 + add_action( 'admin_init', array( $this, 'maybe_onboarding_redirect' ) );
20 29 }
21 30
22 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 + /**
23 68 * Enqueue admin assets
24 69 */
25 70 public function enqueue_admin_assets() {
26 71 wp_enqueue_style(
@@ -38,16 +83,33 @@
38 83 SUBSCRPT_VERSION,
39 84 true
40 85 );
41 86
42 - // Enqueue onboarding wizard JS (loaded on wizard page)
87 + // Enqueue onboarding wizard styles.
88 + wp_enqueue_style(
89 + 'subscrpt-onboarding-wizard',
90 + SUBSCRPT_ASSETS . '/css/admin/onboarding-wizard.css',
91 + array(),
92 + SUBSCRPT_VERSION
93 + );
94 +
95 + // Enqueue onboarding wizard JS (loaded on wizard page). Depends on the
96 + // admin components so the cadence picker (adv-select) is ready.
43 97 wp_enqueue_script(
44 98 'subscrpt-onboarding-wizard',
45 99 SUBSCRPT_ASSETS . '/js/admin/onboarding-wizard.js',
46 - array( 'jquery' ),
100 + array( 'jquery', 'subscrpt_admin_components' ),
47 101 SUBSCRPT_VERSION,
48 102 true
49 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 +
50 112 wp_localize_script(
51 113 'subscrpt-onboarding-wizard',
52 114 'subscrpt_wizard',
53 115 array(
@@ -52,9 +114,16 @@
52 114 'subscrpt_wizard',
53 115 array(
54 116 'ajax_url' => admin_url( 'admin-ajax.php' ),
55 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' ),
56 123 'currency_symbol' => get_woocommerce_currency_symbol(),
124 + 'is_pro' => subscrpt_pro_activated(),
125 + 'has_products' => $subscrpt_wizard_has_products,
57 126 )
58 127 );
59 128
60 129 // Localize script for AJAX
@@ -87,14 +156,15 @@
87 156 __( 'WPSubscription', 'subscription' ),
88 157 __( 'WPSubscription', 'subscription' ),
89 158 'manage_options',
90 159 $parent_slug,
91 - array( $this, 'render_subscriptions_page' ),
160 + array( $this, 'render_dashboard_page' ),
92 161 $icon_url,
93 162 40
94 163 );
95 164
96 165 // Onboarding Wizard (hidden from menu with CSS. can be accessed via direct URL: admin.php?page=wp-subscription-onboarding)
166 + // CSS Record: admin.css -> `.wp-submenu a[href*="page=wp-subscription-onboarding"]`
97 167 add_submenu_page(
98 168 $parent_slug,
99 169 __( 'Setup Wizard', 'subscription' ),
100 170 __( 'Setup Wizard', 'subscription' ),
@@ -102,16 +172,40 @@
102 172 'wp-subscription-onboarding',
103 173 array( $this, 'render_onboarding_wizard' )
104 174 );
105 175
106 - // 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.
107 178 add_submenu_page(
108 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,
109 192 __( 'Subscriptions', 'subscription' ),
110 193 __( 'Subscriptions', 'subscription' ),
111 194 'manage_options',
195 + 'wp-subscription-list',
196 + array( $this, 'render_subscriptions_page' )
197 + );
198 +
199 + // Subscription Details (hidden from menu with CSS — accessed via admin.php?page=wp-subscription-details&id=ID).
200 + // CSS Record: admin.css -> `.wp-submenu a[href*="page=wp-subscription-details"]`
201 + add_submenu_page(
112 202 $parent_slug,
113 - array( $this, 'render_subscriptions_page' )
203 + __( 'Subscription Details', 'subscription' ),
204 + __( 'Subscription Details', 'subscription' ),
205 + 'manage_options',
206 + 'wp-subscription-details',
207 + array( $this, 'render_subscription_details_page' )
114 208 );
115 209
116 210 // Stats Overview
117 211 add_submenu_page(
@@ -152,16 +246,24 @@
152 246 'wp-subscription-support',
153 247 array( $this, 'render_support_page' )
154 248 );
155 249
156 - // 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 + */
157 260 add_submenu_page(
158 261 'woocommerce',
159 - __( 'WPSubscription', 'subscription' ),
160 - __( 'WPSubscription', 'subscription' ),
262 + __( 'Subscriptions', 'subscription' ),
263 + __( 'Subscriptions', 'subscription' ),
161 264 'manage_options',
162 - 'wp-subscription',
163 - array( $this, 'render_subscriptions_page' )
265 + 'admin.php?page=wp-subscription-list'
164 266 );
165 267 }
166 268
167 269 /**
@@ -182,26 +284,25 @@
182 284 return;
183 285 }
184 286
185 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.
186 293 $default_order = [
187 - 'wp-subscription' => 10, // Subscriptions
188 - 'wp-subscription-stats' => 20, // Reports
189 - 'wp-subscription-delivery' => 30, // Delivery (pro)
190 - 'wp-subscription-health' => 50, // Health
191 - 'wp-subscription-integrations' => 60, // Integrations
192 - '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
193 300 'wp-subscription-settings' => 998, // Settings
194 301 'wp-subscription-license' => 999, // License (pro)
302 + 'wp-subscription-support' => 1000, // Help & Resources
195 303 ];
196 304
197 - // Place pro pages at the bottom if pro is not active.
198 - if ( ! subscrpt_pro_activated() ) {
199 - $default_order['wp-subscription-stats'] = 200;
200 - $default_order['wp-subscription-delivery'] = 210;
201 - $default_order['wp-subscription-health'] = 220;
202 - }
203 -
204 305 /**
205 306 * Filter the WPSubscription submenu slug order.
206 307 *
207 308 * Each entry is a slug => integer position pair. Lower positions appear
@@ -207,11 +308,11 @@
207 308 * Each entry is a slug => integer position pair. Lower positions appear
208 309 * first. Use gaps of 10 between built-in positions so extensions can
209 310 * insert their own slugs between existing items without renumbering.
210 311 *
211 - * Example (pro plugin adding Delivery at position 35):
312 + * Example (an add-on placing its page between Reports and Health):
212 313 * add_filter( 'subscrpt_submenu_order', function( $order ) {
213 - * $order['wp-subscription-delivery'] = 35;
314 + * $order['wp-subscription-my-addon'] = 45;
214 315 * return $order;
215 316 * } );
216 317 *
217 318 * @param array<string,int> $order Map of slug => position.
@@ -251,11 +352,11 @@
251 352 ?>
252 353 <div style="text-align:center;margin:38px 0 0 0;font-size:14px;color:#888;">
253 354 Made with <span style="color:#e25555;font-size:1.1em;">♥</span> by the WPSubscription Team
254 355 <div style="margin-top:6px;">
255 - <a href="https://wpsubscription.co/contact" target="_blank" style="color:#2563eb;text-decoration:none;">Support</a>
356 + <a href="https://wpsubscription.co/contact?utm_source=plugin&utm_medium=admin&utm_campaign=support" target="_blank" style="color:#2563eb;text-decoration:none;">Support</a>
256 357 &nbsp;/&nbsp;
257 - <a href="https://docs.converslabs.com/en" target="_blank" style="color:#2563eb;text-decoration:none;">Docs</a>
358 + <a href="https://docs.wpsubscription.co/en?utm_source=plugin&utm_medium=admin&utm_campaign=docs" target="_blank" style="color:#2563eb;text-decoration:none;">Docs</a>
258 359 </div>
259 360 </div>
260 361 <?php
261 362 }
@@ -283,23 +384,28 @@
283 384 unset( $menu_items ); // Nav no longer rendered in header; navigation is in WP sidebar.
284 385
285 386 // Normalize to a single trail. Explicit breadcrumbs win; otherwise the
286 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 +
287 399 $trail = [];
288 400 if ( ! empty( $breadcrumbs ) ) {
289 401 foreach ( $breadcrumbs as $crumb ) {
290 402 if ( is_array( $crumb ) && '' !== ( $crumb['label'] ?? '' ) ) {
291 - $trail[] = [
292 - 'label' => (string) $crumb['label'],
293 - 'url' => isset( $crumb['url'] ) ? (string) $crumb['url'] : '',
294 - ];
403 + $trail[] = $make_crumb( $crumb['label'], $crumb['url'] ?? '' );
295 404 }
296 405 }
297 406 } elseif ( '' !== $title ) {
298 - $trail[] = [
299 - 'label' => $title,
300 - 'url' => '',
301 - ];
407 + $trail[] = $make_crumb( $title, '' );
302 408 }
303 409
304 410 $last_index = count( $trail ) - 1;
305 411 ?>
@@ -311,17 +417,51 @@
311 417 <span class="dashicons dashicons-admin-home"></span>
312 418 </a>
313 419 <?php foreach ( $trail as $index => $crumb ) : ?>
314 420 <span class="wp-subscription-breadcrumb-sep" aria-hidden="true">/</span>
421 + <?php $crumb_title = $crumb['full'] !== $crumb['label'] ? ' title="' . esc_attr( $crumb['full'] ) . '"' : ''; ?>
315 422 <?php if ( '' !== $crumb['url'] && $index < $last_index ) : ?>
316 - <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>
317 424 <?php else : ?>
318 - <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>
319 426 <?php endif; ?>
320 427 <?php endforeach; ?>
321 428 </nav>
322 429 </div>
323 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 +
324 464 <?php if ( ! class_exists( 'Sdevs_Wc_Subscription_Pro' ) ) : ?>
325 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">
326 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>
327 467 <?php esc_html_e( 'Upgrade to Pro', 'subscription' ); ?>
@@ -334,8 +474,46 @@
334 474 <?php
335 475 }
336 476
337 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 + /**
338 516 * Render Subscriptions page
339 517 */
340 518 public function render_subscriptions_page() {
341 519 $this->render_admin_header( __( 'Subscriptions', 'subscription' ), __( 'Manage your subscriptions', 'subscription' ) );
@@ -345,8 +523,9 @@
345 523 $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '';
346 524 $date_filter = isset( $_GET['date_filter'] ) ? sanitize_text_field( wp_unslash( $_GET['date_filter'] ) ) : '';
347 525 $per_page = isset( $_GET['per_page'] ) ? max( 1, intval( $_GET['per_page'] ) ) : 20;
348 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;
349 528
350 529 // Handle form submissions (both filters and bulk actions)
351 530 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
352 531 if ( 'POST' === $request_method ) {
@@ -376,9 +555,9 @@
376 555 wp_delete_post( $sub_id, true );
377 556 }
378 557 }
379 558
380 - wp_safe_redirect( admin_url( 'admin.php?page=wp-subscription' ) );
559 + wp_safe_redirect( admin_url( 'admin.php?page=wp-subscription-list' ) );
381 560 exit;
382 561 }
383 562 }
384 563
@@ -391,8 +570,11 @@
391 570 }
392 571 if ( ! empty( $_POST['date_filter'] ) ) {
393 572 $filter_params['date_filter'] = sanitize_text_field( wp_unslash( $_POST['date_filter'] ) );
394 573 }
574 + if ( ! empty( $_POST['renewal_due'] ) ) {
575 + $filter_params['renewal_due'] = absint( $_POST['renewal_due'] );
576 + }
395 577 if ( ! empty( $_POST['s'] ) ) {
396 578 $filter_params['s'] = sanitize_text_field( wp_unslash( $_POST['s'] ) );
397 579 }
398 580 if ( ! empty( $_POST['per_page'] ) ) {
@@ -398,9 +580,9 @@
398 580 if ( ! empty( $_POST['per_page'] ) ) {
399 581 $filter_params['per_page'] = intval( $_POST['per_page'] );
400 582 }
401 583
402 - $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' ) );
403 585 wp_safe_redirect( $redirect_url );
404 586 exit;
405 587 }
406 588 }
@@ -433,9 +615,9 @@
433 615 foreach ( $trash_posts as $trash_id ) {
434 616 wp_delete_post( $trash_id, true );
435 617 }
436 618
437 - 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' ) );
438 620 exit;
439 621 } else {
440 622 // For other actions, verify nonce with subscription ID.
441 623 $nonce_action = 'wpsubs_action_' . $sub_id;
@@ -443,9 +625,9 @@
443 625 echo '<div class="notice notice-error"><p>' . esc_html__( 'Security check failed. Please try again.', 'subscription' ) . '</p></div>';
444 626 wp_die();
445 627 }
446 628
447 - $redirect_url = admin_url( 'admin.php?page=wp-subscription' );
629 + $redirect_url = admin_url( 'admin.php?page=wp-subscription-list' );
448 630
449 631 switch ( $action ) {
450 632 case 'duplicate':
451 633 $post = get_post( $sub_id );
@@ -474,9 +656,9 @@
474 656 wp_untrash_post( $sub_id );
475 657 break;
476 658 case 'delete':
477 659 wp_delete_post( $sub_id, true );
478 - $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' );
479 661 break;
480 662 }
481 663
482 664 wp_safe_redirect( $redirect_url );
@@ -513,9 +695,24 @@
513 695 'year' => intval( $year ),
514 696 'month' => intval( $month ),
515 697 ];
516 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 );
517 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 +
518 715 $query = new \WP_Query( $args );
519 716 $subscriptions = $query->posts;
520 717 $total = $query->found_posts;
521 718 $max_num_pages = $query->max_num_pages;
@@ -527,11 +724,11 @@
527 724 ?>
528 725 <div style="text-align:center;margin:38px 0 0 0;font-size:14px;color:#888;">
529 726 Made with <span style="color:#e25555;font-size:1.1em;">♥</span> by the WPSubscription Team
530 727 <div style="margin-top:6px;">
531 - <a href="https://wpsubscription.co/contact" target="_blank" style="color:#2563eb;text-decoration:none;">Support</a>
728 + <a href="https://wpsubscription.co/contact?utm_source=plugin&utm_medium=admin&utm_campaign=support" target="_blank" style="color:#2563eb;text-decoration:none;">Support</a>
532 729 &nbsp;/&nbsp;
533 - <a href="https://docs.converslabs.com/en" target="_blank" style="color:#2563eb;text-decoration:none;">Docs</a>
730 + <a href="https://docs.wpsubscription.co/en?utm_source=plugin&utm_medium=admin&utm_campaign=docs" target="_blank" style="color:#2563eb;text-decoration:none;">Docs</a>
534 731 </div>
535 732 </div>
536 733 <?php
537 734 }
@@ -536,8 +733,116 @@
536 733 <?php
537 734 }
538 735
539 736 /**
737 + * Render the standalone Subscription Details page.
738 + *
739 + * Replaces the legacy metabox edit screen. Mirrors the list-page design
740 + * shell (header + wpsubs components) and handles the status-change form.
741 + *
742 + * @return void
743 + */
744 + public function render_subscription_details_page() {
745 + $subscription_id = isset( $_GET['id'] ) ? absint( wp_unslash( $_GET['id'] ) ) : 0;
746 +
747 + if ( ! $subscription_id || 'subscrpt_order' !== get_post_type( $subscription_id ) ) {
748 + wp_die( esc_html__( 'Invalid subscription.', 'subscription' ) );
749 + }
750 +
751 + if ( ! current_user_can( 'edit_post', $subscription_id ) ) {
752 + wp_die( esc_html__( 'You do not have permission to view this subscription.', 'subscription' ) );
753 + }
754 +
755 + $list_url = admin_url( 'admin.php?page=wp-subscription-list' );
756 + $form_action = admin_url( 'admin.php?page=wp-subscription-details&id=' . $subscription_id );
757 +
758 + // Handle the status-change submission.
759 + $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
760 + if ( 'POST' === $request_method && isset( $_POST['subscrpt_order_action'] ) ) {
761 + $nonce = isset( $_POST['subscrpt_order_action_nonce_field'] ) ? sanitize_text_field( wp_unslash( $_POST['subscrpt_order_action_nonce_field'] ) ) : '';
762 + if ( ! wp_verify_nonce( $nonce, 'subscrpt_order_action_nonce' ) ) {
763 + wp_die( esc_html__( 'Security check failed.', 'subscription' ) );
764 + }
765 +
766 + $action = sanitize_text_field( wp_unslash( $_POST['subscrpt_order_action'] ) );
767 + if ( '' !== $action ) {
768 + Subscriptions::process_status_change( $subscription_id, $action );
769 + }
770 +
771 + wp_safe_redirect( $form_action );
772 + exit;
773 + }
774 +
775 + // Allowed actions for the current status (mirrors Subscriptions::subscrpt_order_save_post()).
776 + $actions_data = array(
777 + 'active' => array(
778 + 'label' => __( 'Activate Subscription', 'subscription' ),
779 + 'value' => 'active',
780 + ),
781 + 'pending' => array(
782 + 'label' => __( 'Pending Subscription', 'subscription' ),
783 + 'value' => 'pending',
784 + ),
785 + 'expire' => array(
786 + 'label' => __( 'Expire Subscription', 'subscription' ),
787 + 'value' => 'expired',
788 + ),
789 + 'pe_cancelled' => array(
790 + 'label' => __( 'Pending Cancel Subscription', 'subscription' ),
791 + 'value' => 'pe_cancelled',
792 + ),
793 + 'cancelled' => array(
794 + 'label' => __( 'Cancel Subscription', 'subscription' ),
795 + 'value' => 'cancelled',
796 + ),
797 + );
798 +
799 + $map_actions = array(
800 + 'pending' => array( 'active', 'cancelled' ),
801 + 'active' => array( 'pe_cancelled', 'cancelled' ),
802 + 'pe_cancelled' => array( 'active', 'cancelled' ),
803 + 'cancelled' => array( 'active' ),
804 + 'expired' => array( 'active', 'cancelled' ),
805 + 'completed' => array( 'cancelled' ),
806 + );
807 +
808 + $status = get_post_status( $subscription_id );
809 + $actions = $map_actions[ $status ] ?? array();
810 +
811 + // Gather subscription data for the view.
812 + $subscription_data = Helper::get_subscription_data( $subscription_id );
813 +
814 + $order_id = $subscription_data['order']['order_id'] ?? get_post_meta( $subscription_id, '_subscrpt_order_id', true );
815 + $order = $order_id ? wc_get_order( $order_id ) : null;
816 + $order_item_id = $subscription_data['order']['order_item_id'] ?? get_post_meta( $subscription_id, '_subscrpt_order_item_id', true );
817 + $order_item = ( $order && $order_item_id ) ? $order->get_item( $order_item_id ) : null;
818 +
819 + $rows = Subscriptions::get_info_rows( $subscription_id );
820 +
821 + // Related orders.
822 + $order_histories = Helper::get_related_orders( $subscription_id );
823 +
824 + $this->render_admin_header(
825 + '',
826 + '',
827 + array(
828 + array(
829 + 'label' => __( 'Subscriptions', 'subscription' ),
830 + 'url' => $list_url,
831 + ),
832 + array(
833 + 'label' => '#' . $subscription_id,
834 + 'url' => '',
835 + ),
836 + )
837 + );
838 +
839 + include __DIR__ . '/views/subscription-details.php';
840 +
841 + $this->render_admin_footer();
842 + }
843 +
844 + /**
540 845 * Render Stats page
541 846 */
542 847 public function render_stats_page() {
543 848 $this->render_admin_header( __( 'Reports', 'subscription' ), __( 'View your subscription analytics', 'subscription' ) );
@@ -599,17 +904,14 @@
599 904 * SPA-style: all sections rendered at once, JS controls visibility
600 905 * Initial load always shows page 1 (JS handles transitions from there)
601 906 */
602 907 public function render_onboarding_wizard() {
603 - // Start session if not already started
908 + // Start session if not already started (used by the wizard reset handler).
604 909 if ( ! session_id() && ! headers_sent() ) {
605 910 session_start();
606 911 }
607 912
608 - // Always start at page 1 on direct load (SPA behavior — JS drives page transitions)
609 - $GLOBALS['wizard_page'] = 1;
610 -
611 - $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' ) );
612 914 include __DIR__ . '/views/onboarding-wizard.php';
613 915 $this->render_admin_footer();
614 916 }
615 917