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 +436 -57 1.10.12.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,8 +83,50 @@
38 83 SUBSCRPT_VERSION,
39 84 true
40 85 );
41 86
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.
97 + wp_enqueue_script(
98 + 'subscrpt-onboarding-wizard',
99 + SUBSCRPT_ASSETS . '/js/admin/onboarding-wizard.js',
100 + array( 'jquery', 'subscrpt_admin_components' ),
101 + SUBSCRPT_VERSION,
102 + true
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 +
112 + wp_localize_script(
113 + 'subscrpt-onboarding-wizard',
114 + 'subscrpt_wizard',
115 + array(
116 + 'ajax_url' => admin_url( 'admin-ajax.php' ),
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' ),
123 + 'currency_symbol' => get_woocommerce_currency_symbol(),
124 + 'is_pro' => subscrpt_pro_activated(),
125 + 'has_products' => $subscrpt_wizard_has_products,
126 + )
127 + );
128 +
42 129 // Localize script for AJAX
43 130 wp_localize_script(
44 131 'sdevs_subscription_admin',
45 132 'wp_subscription_ajax',
@@ -61,9 +148,9 @@
61 148 ? SUBSCRPT_ASSETS . '/images/icons/subscription-20.png'
62 149 : SUBSCRPT_ASSETS . '/images/icons/subscription-20-gray.png';
63 150
64 151 $pro_text = __( 'WPSubscription Pro required', 'subscription' );
65 - $pro_badge = subscrpt_pro_activated() ? '' : ' <span title="' . $pro_text . '"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="#ff6a34" style="vertical-align:middle;margin-bottom:2.2px;flex-shrink:0;" aria-hidden="true"><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></span>';
152 + $pro_badge = subscrpt_pro_activated() ? '' : ' <span title="' . $pro_text . '"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" style="fill:var(--wpsubs-brand);vertical-align:middle;margin-bottom:2.2px;flex-shrink:0;" aria-hidden="true"><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></span>';
66 153
67 154 // Main menu
68 155 add_menu_page(
69 156 __( 'WPSubscription', 'subscription' ),
@@ -69,21 +156,56 @@
69 156 __( 'WPSubscription', 'subscription' ),
70 157 __( 'WPSubscription', 'subscription' ),
71 158 'manage_options',
72 159 $parent_slug,
73 - array( $this, 'render_subscriptions_page' ),
160 + array( $this, 'render_dashboard_page' ),
74 161 $icon_url,
75 162 40
76 163 );
77 164
78 - // Subscriptions List
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"]`
79 167 add_submenu_page(
80 168 $parent_slug,
169 + __( 'Setup Wizard', 'subscription' ),
170 + __( 'Setup Wizard', 'subscription' ),
171 + 'manage_options',
172 + 'wp-subscription-onboarding',
173 + array( $this, 'render_onboarding_wizard' )
174 + );
175 +
176 + // Overview. WordPress makes the first submenu entry share the parent
177 + // slug, so this is the page the top-level item opens.
178 + add_submenu_page(
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,
81 192 __( 'Subscriptions', 'subscription' ),
82 193 __( 'Subscriptions', 'subscription' ),
83 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(
84 202 $parent_slug,
85 - 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' )
86 208 );
87 209
88 210 // Stats Overview
89 211 add_submenu_page(
@@ -107,10 +229,10 @@
107 229
108 230 // Delivery Schedules
109 231 add_submenu_page(
110 232 $parent_slug,
111 - __( 'Delivery Schedules', 'subscription' ),
112 - __( 'Delivery Schedules', 'subscription' ) . $pro_badge,
233 + __( 'Delivery', 'subscription' ),
234 + __( 'Delivery', 'subscription' ) . $pro_badge,
113 235 'manage_options',
114 236 'wp-subscription-delivery',
115 237 array( $this, 'render_delivery_page' )
116 238 );
@@ -117,23 +239,31 @@
117 239
118 240 // Help & Resources
119 241 add_submenu_page(
120 242 $parent_slug,
121 - __( 'Help & Resources', 'subscription' ),
122 - __( 'Help & Resources', 'subscription' ),
243 + __( 'Help', 'subscription' ),
244 + __( 'Help', 'subscription' ),
123 245 'manage_options',
124 246 'wp-subscription-support',
125 247 array( $this, 'render_support_page' )
126 248 );
127 249
128 - // 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 + */
129 260 add_submenu_page(
130 261 'woocommerce',
131 - __( 'WPSubscription', 'subscription' ),
132 - __( 'WPSubscription', 'subscription' ),
262 + __( 'Subscriptions', 'subscription' ),
263 + __( 'Subscriptions', 'subscription' ),
133 264 'manage_options',
134 - 'wp-subscription',
135 - array( $this, 'render_subscriptions_page' )
265 + 'admin.php?page=wp-subscription-list'
136 266 );
137 267 }
138 268
139 269 /**
@@ -154,33 +284,24 @@
154 284 return;
155 285 }
156 286
157 287 // slug => position. Use gaps of 10 so extensions can insert between items.
158 - // When pro is not active, pro-only pages (Reports, Delivery, Health) move
159 - // to the bottom so free pages stay prominent.
160 - if ( subscrpt_pro_activated() ) {
161 - $default_order = [
162 - 'wp-subscription' => 10, // Subscriptions
163 - 'wp-subscription-stats' => 20, // Reports
164 - 'wp-subscription-delivery' => 30, // Delivery (pro)
165 - 'wp-subscription-settings' => 40, // Settings
166 - 'wp-subscription-health' => 50, // Health
167 - 'wp-subscription-integrations' => 60, // Integrations
168 - 'wp-subscription-support' => 70, // Help & Resources
169 - 'wp-subscription-license' => 80, // License (pro)
170 - ];
171 - } else {
172 - $default_order = [
173 - 'wp-subscription' => 10, // Subscriptions
174 - 'wp-subscription-settings' => 20, // Settings
175 - 'wp-subscription-integrations' => 30, // Integrations
176 - 'wp-subscription-support' => 40, // Help & Resources
177 - 'wp-subscription-stats' => 50, // Reports (pro preview)
178 - 'wp-subscription-delivery' => 60, // Delivery (pro preview)
179 - 'wp-subscription-health' => 70, // Health (pro preview)
180 - 'wp-subscription-license' => 80, // License (pro)
181 - ];
182 - }
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.
293 + $default_order = [
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
300 + 'wp-subscription-settings' => 998, // Settings
301 + 'wp-subscription-license' => 999, // License (pro)
302 + 'wp-subscription-support' => 1000, // Help & Resources
303 + ];
183 304
184 305 /**
185 306 * Filter the WPSubscription submenu slug order.
186 307 *
@@ -187,11 +308,11 @@
187 308 * Each entry is a slug => integer position pair. Lower positions appear
188 309 * first. Use gaps of 10 between built-in positions so extensions can
189 310 * insert their own slugs between existing items without renumbering.
190 311 *
191 - * Example (pro plugin adding Delivery at position 35):
312 + * Example (an add-on placing its page between Reports and Health):
192 313 * add_filter( 'subscrpt_submenu_order', function( $order ) {
193 - * $order['wp-subscription-delivery'] = 35;
314 + * $order['wp-subscription-my-addon'] = 45;
194 315 * return $order;
195 316 * } );
196 317 *
197 318 * @param array<string,int> $order Map of slug => position.
@@ -208,9 +329,9 @@
208 329 }
209 330
210 331 // Build sorted list from the ordered slugs.
211 332 $sorted = [];
212 - foreach ( array_keys( $order ) as $slug ) {
333 + foreach ( $order as $slug => $position ) {
213 334 if ( isset( $indexed[ $slug ] ) ) {
214 335 $sorted[] = $indexed[ $slug ];
215 336 unset( $indexed[ $slug ] );
216 337 }
@@ -231,11 +352,11 @@
231 352 ?>
232 353 <div style="text-align:center;margin:38px 0 0 0;font-size:14px;color:#888;">
233 354 Made with <span style="color:#e25555;font-size:1.1em;">♥</span> by the WPSubscription Team
234 355 <div style="margin-top:6px;">
235 - <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>
236 357 &nbsp;/&nbsp;
237 - <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>
238 359 </div>
239 360 </div>
240 361 <?php
241 362 }
@@ -241,17 +362,53 @@
241 362 }
242 363 /**
243 364 * Render the admin header.
244 365 *
245 - * @param string $title Page title shown on the left.
246 - * @param string $subtitle Optional subtitle shown below the title.
366 + * The breadcrumb after the home icon is built from $breadcrumbs when given —
367 + * an ordered trail supporting any number of levels — otherwise it falls back
368 + * to the single $title segment (backward compatible with existing callers).
369 + *
370 + * Each breadcrumb item is an array: `[ 'label' => string, 'url' => string ]`.
371 + * Items with a non-empty `url` render as links, except the last item, which is
372 + * always the current (non-linked) page.
373 + *
374 + * @param string $title Page title shown as the current segment when no
375 + * $breadcrumbs trail is supplied.
376 + * @param string $subtitle Optional subtitle (reserved; not rendered).
377 + * @param array $breadcrumbs Ordered trail of `[ 'label', 'url' ]` items.
247 378 */
248 - public function render_admin_header( string $title = '', string $subtitle = '' ) {
379 + public function render_admin_header( string $title = '', string $subtitle = '', array $breadcrumbs = [] ) {
249 380 $current = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : 'wp-subscription';
250 381
251 382 // Kept for backward compatibility — extensions may hook here for side-effects.
252 383 $menu_items = apply_filters( 'subscrpt_admin_header_menu_items', [], $current );
253 384 unset( $menu_items ); // Nav no longer rendered in header; navigation is in WP sidebar.
385 +
386 + // Normalize to a single trail. Explicit breadcrumbs win; otherwise the
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 +
399 + $trail = [];
400 + if ( ! empty( $breadcrumbs ) ) {
401 + foreach ( $breadcrumbs as $crumb ) {
402 + if ( is_array( $crumb ) && '' !== ( $crumb['label'] ?? '' ) ) {
403 + $trail[] = $make_crumb( $crumb['label'], $crumb['url'] ?? '' );
404 + }
405 + }
406 + } elseif ( '' !== $title ) {
407 + $trail[] = $make_crumb( $title, '' );
408 + }
409 +
410 + $last_index = count( $trail ) - 1;
254 411 ?>
255 412 <div class="wp-subscription-admin-header">
256 413 <div class="wp-subscription-admin-header-inner">
257 414 <div class="wp-subscription-admin-header-left">
@@ -258,17 +415,58 @@
258 415 <nav class="wp-subscription-breadcrumb" aria-label="<?php esc_attr_e( 'Breadcrumb', 'subscription' ); ?>">
259 416 <a href="<?php echo esc_url( admin_url( 'admin.php?page=wp-subscription' ) ); ?>" class="wp-subscription-breadcrumb-home" aria-label="<?php esc_attr_e( 'WPSubscription Home', 'subscription' ); ?>">
260 417 <span class="dashicons dashicons-admin-home"></span>
261 418 </a>
262 - <?php if ( $title ) : ?>
419 + <?php foreach ( $trail as $index => $crumb ) : ?>
263 420 <span class="wp-subscription-breadcrumb-sep" aria-hidden="true">/</span>
264 - <span class="wp-subscription-breadcrumb-current"><?php echo esc_html( $title ); ?></span>
265 - <?php endif; ?>
421 + <?php $crumb_title = $crumb['full'] !== $crumb['label'] ? ' title="' . esc_attr( $crumb['full'] ) . '"' : ''; ?>
422 + <?php if ( '' !== $crumb['url'] && $index < $last_index ) : ?>
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>
424 + <?php else : ?>
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>
426 + <?php endif; ?>
427 + <?php endforeach; ?>
266 428 </nav>
267 429 </div>
268 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 +
269 464 <?php if ( ! class_exists( 'Sdevs_Wc_Subscription_Pro' ) ) : ?>
270 - <a target="_blank" href="https://wpsubscription.co/?utm_source=plugin&utm_medium=admin&utm_campaign=upgrade_pro" class="wp-subscription-upgrade-btn"><?php esc_html_e( 'Upgrade to Pro', 'subscription' ); ?></a>
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">
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>
467 + <?php esc_html_e( 'Upgrade to Pro', 'subscription' ); ?>
468 + </a>
271 469 <?php endif; ?>
272 470 <img src="<?php echo esc_url( SUBSCRPT_ASSETS . '/images/logo-title.svg' ); ?>" alt="WPSubscription" class="wp-subscription-logo">
273 471 </div>
274 472 </div>
@@ -276,8 +474,46 @@
276 474 <?php
277 475 }
278 476
279 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 + /**
280 516 * Render Subscriptions page
281 517 */
282 518 public function render_subscriptions_page() {
283 519 $this->render_admin_header( __( 'Subscriptions', 'subscription' ), __( 'Manage your subscriptions', 'subscription' ) );
@@ -287,8 +523,9 @@
287 523 $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '';
288 524 $date_filter = isset( $_GET['date_filter'] ) ? sanitize_text_field( wp_unslash( $_GET['date_filter'] ) ) : '';
289 525 $per_page = isset( $_GET['per_page'] ) ? max( 1, intval( $_GET['per_page'] ) ) : 20;
290 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;
291 528
292 529 // Handle form submissions (both filters and bulk actions)
293 530 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
294 531 if ( 'POST' === $request_method ) {
@@ -318,9 +555,9 @@
318 555 wp_delete_post( $sub_id, true );
319 556 }
320 557 }
321 558
322 - wp_safe_redirect( admin_url( 'admin.php?page=wp-subscription' ) );
559 + wp_safe_redirect( admin_url( 'admin.php?page=wp-subscription-list' ) );
323 560 exit;
324 561 }
325 562 }
326 563
@@ -333,8 +570,11 @@
333 570 }
334 571 if ( ! empty( $_POST['date_filter'] ) ) {
335 572 $filter_params['date_filter'] = sanitize_text_field( wp_unslash( $_POST['date_filter'] ) );
336 573 }
574 + if ( ! empty( $_POST['renewal_due'] ) ) {
575 + $filter_params['renewal_due'] = absint( $_POST['renewal_due'] );
576 + }
337 577 if ( ! empty( $_POST['s'] ) ) {
338 578 $filter_params['s'] = sanitize_text_field( wp_unslash( $_POST['s'] ) );
339 579 }
340 580 if ( ! empty( $_POST['per_page'] ) ) {
@@ -340,9 +580,9 @@
340 580 if ( ! empty( $_POST['per_page'] ) ) {
341 581 $filter_params['per_page'] = intval( $_POST['per_page'] );
342 582 }
343 583
344 - $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' ) );
345 585 wp_safe_redirect( $redirect_url );
346 586 exit;
347 587 }
348 588 }
@@ -375,9 +615,9 @@
375 615 foreach ( $trash_posts as $trash_id ) {
376 616 wp_delete_post( $trash_id, true );
377 617 }
378 618
379 - 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' ) );
380 620 exit;
381 621 } else {
382 622 // For other actions, verify nonce with subscription ID.
383 623 $nonce_action = 'wpsubs_action_' . $sub_id;
@@ -385,9 +625,9 @@
385 625 echo '<div class="notice notice-error"><p>' . esc_html__( 'Security check failed. Please try again.', 'subscription' ) . '</p></div>';
386 626 wp_die();
387 627 }
388 628
389 - $redirect_url = admin_url( 'admin.php?page=wp-subscription' );
629 + $redirect_url = admin_url( 'admin.php?page=wp-subscription-list' );
390 630
391 631 switch ( $action ) {
392 632 case 'duplicate':
393 633 $post = get_post( $sub_id );
@@ -416,9 +656,9 @@
416 656 wp_untrash_post( $sub_id );
417 657 break;
418 658 case 'delete':
419 659 wp_delete_post( $sub_id, true );
420 - $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' );
421 661 break;
422 662 }
423 663
424 664 wp_safe_redirect( $redirect_url );
@@ -455,9 +695,24 @@
455 695 'year' => intval( $year ),
456 696 'month' => intval( $month ),
457 697 ];
458 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 );
459 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 +
460 715 $query = new \WP_Query( $args );
461 716 $subscriptions = $query->posts;
462 717 $total = $query->found_posts;
463 718 $max_num_pages = $query->max_num_pages;
@@ -469,11 +724,11 @@
469 724 ?>
470 725 <div style="text-align:center;margin:38px 0 0 0;font-size:14px;color:#888;">
471 726 Made with <span style="color:#e25555;font-size:1.1em;">♥</span> by the WPSubscription Team
472 727 <div style="margin-top:6px;">
473 - <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>
474 729 &nbsp;/&nbsp;
475 - <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>
476 731 </div>
477 732 </div>
478 733 <?php
479 734 }
@@ -478,8 +733,116 @@
478 733 <?php
479 734 }
480 735
481 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 + /**
482 845 * Render Stats page
483 846 */
484 847 public function render_stats_page() {
485 848 $this->render_admin_header( __( 'Reports', 'subscription' ), __( 'View your subscription analytics', 'subscription' ) );
@@ -532,8 +895,24 @@
532 895 */
533 896 public function render_support_page() {
534 897 $this->render_admin_header( __( 'Help & Resources', 'subscription' ), __( 'Documentation, community links, and ways to get help with WPSubscription.', 'subscription' ) );
535 898 include 'views/support.php';
899 + $this->render_admin_footer();
900 + }
901 +
902 + /**
903 + * Render Onboarding Wizard page
904 + * SPA-style: all sections rendered at once, JS controls visibility
905 + * Initial load always shows page 1 (JS handles transitions from there)
906 + */
907 + public function render_onboarding_wizard() {
908 + // Start session if not already started (used by the wizard reset handler).
909 + if ( ! session_id() && ! headers_sent() ) {
910 + session_start();
911 + }
912 +
913 + $this->render_admin_header( __( 'Setup Wizard', 'subscription' ), __( 'Create your first subscription plan', 'subscription' ) );
914 + include __DIR__ . '/views/onboarding-wizard.php';
536 915 $this->render_admin_footer();
537 916 }
538 917
539 918 /**