PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.4.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.4.0
2.4.0 2.3.0 2.2.0 2.1.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 All 65 releases
← All changes | includes/Admin/Menu.php +446 -60 1.10.1 → 2.4.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(
@@ -33,13 +78,56 @@
33 78 // Enqueue admin JavaScript for subscription list functionality
34 79 wp_enqueue_script(
35 80 'sdevs_subscription_admin',
36 81 SUBSCRPT_ASSETS . '/js/admin.js',
37 - array( 'jquery' ),
82 + array( 'jquery', 'wp-i18n' ),
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', 'wp-i18n' ),
101 + SUBSCRPT_VERSION,
102 + true
103 + );
104 + wp_set_script_translations( 'subscrpt-onboarding-wizard', 'subscription', SUBSCRPT_PATH . '/languages' );
105 + $subscrpt_wizard_has_products = (bool) wc_get_products(
106 + array(
107 + 'status' => array( 'publish', 'draft', 'pending', 'private' ),
108 + 'limit' => 1,
109 + 'return' => 'ids',
110 + )
111 + );
112 +
113 + wp_localize_script(
114 + 'subscrpt-onboarding-wizard',
115 + 'subscrpt_wizard',
116 + array(
117 + 'ajax_url' => admin_url( 'admin-ajax.php' ),
118 + 'subscriptions_url' => admin_url( 'admin.php?page=wp-subscription' ),
119 + 'dashboard_url' => admin_url( 'admin.php?page=wp-subscription' ),
120 + 'products_url' => admin_url( 'edit.php?post_type=product' ),
121 + 'plans_url' => admin_url( 'admin.php?page=wp-subscription-plans' ),
122 + 'rest_url' => rest_url( 'wpsubscription/v1/plans' ),
123 + 'rest_nonce' => wp_create_nonce( 'wp_rest' ),
124 + 'currency_symbol' => get_woocommerce_currency_symbol(),
125 + 'is_pro' => subscrpt_pro_activated(),
126 + 'has_products' => $subscrpt_wizard_has_products,
127 + )
128 + );
129 +
42 130 // Localize script for AJAX
43 131 wp_localize_script(
44 132 'sdevs_subscription_admin',
45 133 'wp_subscription_ajax',
@@ -61,9 +149,9 @@
61 149 ? SUBSCRPT_ASSETS . '/images/icons/subscription-20.png'
62 150 : SUBSCRPT_ASSETS . '/images/icons/subscription-20-gray.png';
63 151
64 152 $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>';
153 + $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 154
67 155 // Main menu
68 156 add_menu_page(
69 157 __( 'WPSubscription', 'subscription' ),
@@ -69,21 +157,56 @@
69 157 __( 'WPSubscription', 'subscription' ),
70 158 __( 'WPSubscription', 'subscription' ),
71 159 'manage_options',
72 160 $parent_slug,
73 - array( $this, 'render_subscriptions_page' ),
161 + array( $this, 'render_dashboard_page' ),
74 162 $icon_url,
75 163 40
76 164 );
77 165
78 - // Subscriptions List
166 + // Onboarding Wizard (hidden from menu with CSS. can be accessed via direct URL: admin.php?page=wp-subscription-onboarding)
167 + // CSS Record: admin.css -> `.wp-submenu a[href*="page=wp-subscription-onboarding"]`
79 168 add_submenu_page(
80 169 $parent_slug,
170 + __( 'Setup Wizard', 'subscription' ),
171 + __( 'Setup Wizard', 'subscription' ),
172 + 'manage_options',
173 + 'wp-subscription-onboarding',
174 + array( $this, 'render_onboarding_wizard' )
175 + );
176 +
177 + // Overview. WordPress makes the first submenu entry share the parent
178 + // slug, so this is the page the top-level item opens.
179 + add_submenu_page(
180 + $parent_slug,
181 + __( 'Overview', 'subscription' ),
182 + __( 'Overview', 'subscription' ),
183 + 'manage_options',
184 + $parent_slug,
185 + array( $this, 'render_dashboard_page' )
186 + );
187 +
188 + // Subscriptions List. Moved off the parent slug when the dashboard took
189 + // it; render_dashboard_page() redirects here when the request carries
190 + // list-only query arguments, so old bookmarks still work.
191 + add_submenu_page(
192 + $parent_slug,
81 193 __( 'Subscriptions', 'subscription' ),
82 194 __( 'Subscriptions', 'subscription' ),
83 195 'manage_options',
196 + 'wp-subscription-list',
197 + array( $this, 'render_subscriptions_page' )
198 + );
199 +
200 + // Subscription Details (hidden from menu with CSS — accessed via admin.php?page=wp-subscription-details&id=ID).
201 + // CSS Record: admin.css -> `.wp-submenu a[href*="page=wp-subscription-details"]`
202 + add_submenu_page(
84 203 $parent_slug,
85 - array( $this, 'render_subscriptions_page' )
204 + __( 'Subscription Details', 'subscription' ),
205 + __( 'Subscription Details', 'subscription' ),
206 + 'manage_options',
207 + 'wp-subscription-details',
208 + array( $this, 'render_subscription_details_page' )
86 209 );
87 210
88 211 // Stats Overview
89 212 add_submenu_page(
@@ -107,10 +230,10 @@
107 230
108 231 // Delivery Schedules
109 232 add_submenu_page(
110 233 $parent_slug,
111 - __( 'Delivery Schedules', 'subscription' ),
112 - __( 'Delivery Schedules', 'subscription' ) . $pro_badge,
234 + __( 'Delivery', 'subscription' ),
235 + __( 'Delivery', 'subscription' ) . $pro_badge,
113 236 'manage_options',
114 237 'wp-subscription-delivery',
115 238 array( $this, 'render_delivery_page' )
116 239 );
@@ -117,23 +240,31 @@
117 240
118 241 // Help & Resources
119 242 add_submenu_page(
120 243 $parent_slug,
121 - __( 'Help & Resources', 'subscription' ),
122 - __( 'Help & Resources', 'subscription' ),
244 + __( 'Help', 'subscription' ),
245 + __( 'Help', 'subscription' ),
123 246 'manage_options',
124 247 'wp-subscription-support',
125 248 array( $this, 'render_support_page' )
126 249 );
127 250
128 - // Add WPSubscription link under WooCommerce menu
251 + /*
252 + * Subscriptions link under the WooCommerce menu, opening the list.
253 + *
254 + * A link, not a page: the slug is the list's URL and there is no
255 + * callback, so WordPress registers no page and no hook for it.
256 + * Registering `wp-subscription-list` under `woocommerce` as well would
257 + * give the list a second hookname (`woocommerce_page_…` beside
258 + * `wpsubscription_page_…`), and whichever parent WordPress found first
259 + * would set the page's $hook_suffix, which its asset loading keys on.
260 + */
129 261 add_submenu_page(
130 262 'woocommerce',
131 - __( 'WPSubscription', 'subscription' ),
132 - __( 'WPSubscription', 'subscription' ),
263 + __( 'Subscriptions', 'subscription' ),
264 + __( 'Subscriptions', 'subscription' ),
133 265 'manage_options',
134 - 'wp-subscription',
135 - array( $this, 'render_subscriptions_page' )
266 + 'admin.php?page=wp-subscription-list'
136 267 );
137 268 }
138 269
139 270 /**
@@ -154,33 +285,24 @@
154 285 return;
155 286 }
156 287
157 288 // 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 - }
289 + //
290 + // The order is the same whether or not pro is active: a locked page
291 + // carries a pro badge but keeps its place, so the menu does not
292 + // rearrange itself the moment a licence is activated. Plans and
293 + // Cancellation Flow position themselves through the filter below.
294 + $default_order = [
295 + 'wp-subscription' => 5, // Overview
296 + 'wp-subscription-delivery' => 20, // Delivery (pro)
297 + 'wp-subscription-list' => 30, // Subscriptions
298 + 'wp-subscription-stats' => 40, // Reports
299 + 'wp-subscription-health' => 50, // Health
300 + 'wp-subscription-integrations' => 60, // Integrations
301 + 'wp-subscription-settings' => 998, // Settings
302 + 'wp-subscription-license' => 999, // License (pro)
303 + 'wp-subscription-support' => 1000, // Help & Resources
304 + ];
183 305
184 306 /**
185 307 * Filter the WPSubscription submenu slug order.
186 308 *
@@ -187,11 +309,11 @@
187 309 * Each entry is a slug => integer position pair. Lower positions appear
188 310 * first. Use gaps of 10 between built-in positions so extensions can
189 311 * insert their own slugs between existing items without renumbering.
190 312 *
191 - * Example (pro plugin adding Delivery at position 35):
313 + * Example (an add-on placing its page between Reports and Health):
192 314 * add_filter( 'subscrpt_submenu_order', function( $order ) {
193 - * $order['wp-subscription-delivery'] = 35;
315 + * $order['wp-subscription-my-addon'] = 45;
194 316 * return $order;
195 317 * } );
196 318 *
197 319 * @param array<string,int> $order Map of slug => position.
@@ -208,9 +330,9 @@
208 330 }
209 331
210 332 // Build sorted list from the ordered slugs.
211 333 $sorted = [];
212 - foreach ( array_keys( $order ) as $slug ) {
334 + foreach ( $order as $slug => $position ) {
213 335 if ( isset( $indexed[ $slug ] ) ) {
214 336 $sorted[] = $indexed[ $slug ];
215 337 unset( $indexed[ $slug ] );
216 338 }
@@ -229,13 +351,16 @@
229 351 */
230 352 public function render_admin_footer() {
231 353 ?>
232 354 <div style="text-align:center;margin:38px 0 0 0;font-size:14px;color:#888;">
233 - Made with <span style="color:#e25555;font-size:1.1em;">♥</span> by the WPSubscription Team
355 + <?php
356 + /* translators: %s: a heart symbol. */
357 + printf( esc_html__( 'Made with %s by the WPSubscription Team', 'subscription' ), '<span style="color:#e25555;font-size:1.1em;">♥</span>' );
358 + ?>
234 359 <div style="margin-top:6px;">
235 - <a href="https://wpsubscription.co/contact" target="_blank" style="color:#2563eb;text-decoration:none;">Support</a>
360 + <a href="https://wpsubscription.co/contact?utm_source=plugin&utm_medium=admin&utm_campaign=support" target="_blank" style="color:#2563eb;text-decoration:none;"><?php esc_html_e( 'Support', 'subscription' ); ?></a>
236 361 &nbsp;/&nbsp;
237 - <a href="https://docs.converslabs.com/en" target="_blank" style="color:#2563eb;text-decoration:none;">Docs</a>
362 + <a href="https://docs.wpsubscription.co/en?utm_source=plugin&utm_medium=admin&utm_campaign=docs" target="_blank" style="color:#2563eb;text-decoration:none;"><?php esc_html_e( 'Docs', 'subscription' ); ?></a>
238 363 </div>
239 364 </div>
240 365 <?php
241 366 }
@@ -241,17 +366,53 @@
241 366 }
242 367 /**
243 368 * Render the admin header.
244 369 *
245 - * @param string $title Page title shown on the left.
246 - * @param string $subtitle Optional subtitle shown below the title.
370 + * The breadcrumb after the home icon is built from $breadcrumbs when given —
371 + * an ordered trail supporting any number of levels — otherwise it falls back
372 + * to the single $title segment (backward compatible with existing callers).
373 + *
374 + * Each breadcrumb item is an array: `[ 'label' => string, 'url' => string ]`.
375 + * Items with a non-empty `url` render as links, except the last item, which is
376 + * always the current (non-linked) page.
377 + *
378 + * @param string $title Page title shown as the current segment when no
379 + * $breadcrumbs trail is supplied.
380 + * @param string $subtitle Optional subtitle (reserved; not rendered).
381 + * @param array $breadcrumbs Ordered trail of `[ 'label', 'url' ]` items.
247 382 */
248 - public function render_admin_header( string $title = '', string $subtitle = '' ) {
383 + public function render_admin_header( string $title = '', string $subtitle = '', array $breadcrumbs = [] ) {
249 384 $current = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : 'wp-subscription';
250 385
251 386 // Kept for backward compatibility — extensions may hook here for side-effects.
252 387 $menu_items = apply_filters( 'subscrpt_admin_header_menu_items', [], $current );
253 388 unset( $menu_items ); // Nav no longer rendered in header; navigation is in WP sidebar.
389 +
390 + // Normalize to a single trail. Explicit breadcrumbs win; otherwise the
391 + // legacy single $title segment is used.
392 + // A long breadcrumb label is truncated for display; the full text is kept
393 + // so the renderer can add it as a title attribute.
394 + $make_crumb = static function ( $label, $url ) {
395 + $label = (string) $label;
396 + return [
397 + 'label' => subscrpt_truncate_text( $label ),
398 + 'full' => $label,
399 + 'url' => (string) $url,
400 + ];
401 + };
402 +
403 + $trail = [];
404 + if ( ! empty( $breadcrumbs ) ) {
405 + foreach ( $breadcrumbs as $crumb ) {
406 + if ( is_array( $crumb ) && '' !== ( $crumb['label'] ?? '' ) ) {
407 + $trail[] = $make_crumb( $crumb['label'], $crumb['url'] ?? '' );
408 + }
409 + }
410 + } elseif ( '' !== $title ) {
411 + $trail[] = $make_crumb( $title, '' );
412 + }
413 +
414 + $last_index = count( $trail ) - 1;
254 415 ?>
255 416 <div class="wp-subscription-admin-header">
256 417 <div class="wp-subscription-admin-header-inner">
257 418 <div class="wp-subscription-admin-header-left">
@@ -258,17 +419,58 @@
258 419 <nav class="wp-subscription-breadcrumb" aria-label="<?php esc_attr_e( 'Breadcrumb', 'subscription' ); ?>">
259 420 <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 421 <span class="dashicons dashicons-admin-home"></span>
261 422 </a>
262 - <?php if ( $title ) : ?>
423 + <?php foreach ( $trail as $index => $crumb ) : ?>
263 424 <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; ?>
425 + <?php $crumb_title = $crumb['full'] !== $crumb['label'] ? ' title="' . esc_attr( $crumb['full'] ) . '"' : ''; ?>
426 + <?php if ( '' !== $crumb['url'] && $index < $last_index ) : ?>
427 + <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>
428 + <?php else : ?>
429 + <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>
430 + <?php endif; ?>
431 + <?php endforeach; ?>
266 432 </nav>
267 433 </div>
268 434 <div class="wp-subscription-admin-header-right">
435 + <?php
436 + /**
437 + * Filters the pro licence state shown in the admin header.
438 + *
439 + * This plugin cannot ask the pro plugin directly — it runs alone
440 + * on nearly every install, so naming a symbol pro declares would
441 + * fatal there. Pro answers this filter when it is present; when
442 + * it is not, the value stays null and no badge is rendered.
443 + *
444 + * @since 1.11.3
445 + *
446 + * @param array|null $license {
447 + * Licence state, or null when pro is not installed.
448 + *
449 + * @type bool $active Whether the licence is valid.
450 + * @type string $url Admin URL of the licence page.
451 + * }
452 + */
453 + $license = apply_filters( 'subscrpt_admin_header_license', null );
454 +
455 + // Only the "Activate license" badge is shown; the "License active"
456 + // badge is intentionally hidden from the header.
457 + if ( is_array( $license ) && isset( $license['active'] ) && ! $license['active'] ) :
458 + $license_url = isset( $license['url'] ) ? (string) $license['url'] : '';
459 + ?>
460 + <a href="<?php echo esc_url( $license_url ); ?>" class="wpsubs-badge wpsubs-badge--warning wp-subscription-license-badge">
461 + <span class="wpsubs-badge__dot"></span>
462 + <?php esc_html_e( 'Activate license', 'subscription' ); ?>
463 + </a>
464 + <?php
465 + endif;
466 + ?>
467 +
269 468 <?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>
469 + <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">
470 + <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>
471 + <?php esc_html_e( 'Upgrade to Pro', 'subscription' ); ?>
472 + </a>
271 473 <?php endif; ?>
272 474 <img src="<?php echo esc_url( SUBSCRPT_ASSETS . '/images/logo-title.svg' ); ?>" alt="WPSubscription" class="wp-subscription-logo">
273 475 </div>
274 476 </div>
@@ -276,8 +478,46 @@
276 478 <?php
277 479 }
278 480
279 481 /**
482 + * Render the dashboard, or hand off to the list.
483 + *
484 + * The subscriptions list used to live on this slug. Anything still linking
485 + * here with a list-only argument — a saved filter, a bookmarked search, a
486 + * pagination link — means the list, so it is sent there with its arguments
487 + * intact rather than landing on a dashboard that ignores them. The one
488 + * exception is `post_status`, renamed to the `subscrpt_status` the list reads.
489 + *
490 + * @return void
491 + */
492 + public function render_dashboard_page() {
493 + // phpcs:disable WordPress.Security.NonceVerification.Recommended
494 + $list_args = array( 'post_status', 's', 'paged', 'filter_action', 'orderby', 'order', 'subscrpt_status', 'date_filter', 'per_page', 'renewal_due' );
495 +
496 + foreach ( $list_args as $arg ) {
497 + if ( isset( $_GET[ $arg ] ) && '' !== $_GET[ $arg ] ) {
498 + $query = wp_unslash( $_GET );
499 + $query['page'] = 'wp-subscription-list';
500 +
501 + // The list filters on `subscrpt_status` and ignores `post_status`,
502 + // so forwarding the old name as-is lands on an unfiltered list.
503 + if ( isset( $query['post_status'] ) ) {
504 + if ( empty( $query['subscrpt_status'] ) ) {
505 + $query['subscrpt_status'] = $query['post_status'];
506 + }
507 + unset( $query['post_status'] );
508 + }
509 +
510 + wp_safe_redirect( add_query_arg( array_map( 'sanitize_text_field', $query ), admin_url( 'admin.php' ) ) );
511 + exit;
512 + }
513 + }
514 + // phpcs:enable WordPress.Security.NonceVerification.Recommended
515 +
516 + ( new Dashboard() )->render();
517 + }
518 +
519 + /**
280 520 * Render Subscriptions page
281 521 */
282 522 public function render_subscriptions_page() {
283 523 $this->render_admin_header( __( 'Subscriptions', 'subscription' ), __( 'Manage your subscriptions', 'subscription' ) );
@@ -287,8 +527,9 @@
287 527 $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '';
288 528 $date_filter = isset( $_GET['date_filter'] ) ? sanitize_text_field( wp_unslash( $_GET['date_filter'] ) ) : '';
289 529 $per_page = isset( $_GET['per_page'] ) ? max( 1, intval( $_GET['per_page'] ) ) : 20;
290 530 $paged = isset( $_GET['paged'] ) ? max( 1, intval( $_GET['paged'] ) ) : 1;
531 + $renewal_due = isset( $_GET['renewal_due'] ) ? min( 366, absint( $_GET['renewal_due'] ) ) : 0;
291 532
292 533 // Handle form submissions (both filters and bulk actions)
293 534 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
294 535 if ( 'POST' === $request_method ) {
@@ -318,9 +559,9 @@
318 559 wp_delete_post( $sub_id, true );
319 560 }
320 561 }
321 562
322 - wp_safe_redirect( admin_url( 'admin.php?page=wp-subscription' ) );
563 + wp_safe_redirect( admin_url( 'admin.php?page=wp-subscription-list' ) );
323 564 exit;
324 565 }
325 566 }
326 567
@@ -333,8 +574,11 @@
333 574 }
334 575 if ( ! empty( $_POST['date_filter'] ) ) {
335 576 $filter_params['date_filter'] = sanitize_text_field( wp_unslash( $_POST['date_filter'] ) );
336 577 }
578 + if ( ! empty( $_POST['renewal_due'] ) ) {
579 + $filter_params['renewal_due'] = absint( $_POST['renewal_due'] );
580 + }
337 581 if ( ! empty( $_POST['s'] ) ) {
338 582 $filter_params['s'] = sanitize_text_field( wp_unslash( $_POST['s'] ) );
339 583 }
340 584 if ( ! empty( $_POST['per_page'] ) ) {
@@ -340,9 +584,9 @@
340 584 if ( ! empty( $_POST['per_page'] ) ) {
341 585 $filter_params['per_page'] = intval( $_POST['per_page'] );
342 586 }
343 587
344 - $redirect_url = add_query_arg( $filter_params, admin_url( 'admin.php?page=wp-subscription' ) );
588 + $redirect_url = add_query_arg( $filter_params, admin_url( 'admin.php?page=wp-subscription-list' ) );
345 589 wp_safe_redirect( $redirect_url );
346 590 exit;
347 591 }
348 592 }
@@ -375,9 +619,9 @@
375 619 foreach ( $trash_posts as $trash_id ) {
376 620 wp_delete_post( $trash_id, true );
377 621 }
378 622
379 - wp_safe_redirect( admin_url( 'admin.php?page=wp-subscription&subscrpt_status=trash' ) );
623 + wp_safe_redirect( admin_url( 'admin.php?page=wp-subscription-list&subscrpt_status=trash' ) );
380 624 exit;
381 625 } else {
382 626 // For other actions, verify nonce with subscription ID.
383 627 $nonce_action = 'wpsubs_action_' . $sub_id;
@@ -385,9 +629,9 @@
385 629 echo '<div class="notice notice-error"><p>' . esc_html__( 'Security check failed. Please try again.', 'subscription' ) . '</p></div>';
386 630 wp_die();
387 631 }
388 632
389 - $redirect_url = admin_url( 'admin.php?page=wp-subscription' );
633 + $redirect_url = admin_url( 'admin.php?page=wp-subscription-list' );
390 634
391 635 switch ( $action ) {
392 636 case 'duplicate':
393 637 $post = get_post( $sub_id );
@@ -416,9 +660,9 @@
416 660 wp_untrash_post( $sub_id );
417 661 break;
418 662 case 'delete':
419 663 wp_delete_post( $sub_id, true );
420 - $redirect_url = admin_url( 'admin.php?page=wp-subscription&subscrpt_status=trash' );
664 + $redirect_url = admin_url( 'admin.php?page=wp-subscription-list&subscrpt_status=trash' );
421 665 break;
422 666 }
423 667
424 668 wp_safe_redirect( $redirect_url );
@@ -455,9 +699,24 @@
455 699 'year' => intval( $year ),
456 700 'month' => intval( $month ),
457 701 ];
458 702 }
703 + // Next-renewal window, soonest first. The same arguments as the
704 + // Overview's "Renewals due" figure, so its 7-day rows equal its count.
705 + if ( $renewal_due ) {
706 + $due_args = Stats::renewals_due_args( $renewal_due );
459 707
708 + // Only an active subscription renews, so a renewal window combined
709 + // with any other status matches nothing — the filters AND together.
710 + if ( $status && $due_args['post_status'] !== $status ) {
711 + $args['post__in'] = array( 0 );
712 + }
713 +
714 + $args['post_status'] = $due_args['post_status'];
715 + $args['meta_query'] = $due_args['meta_query']; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
716 + $args['orderby'] = [ 'subscrpt_next_date' => 'ASC' ];
717 + }
718 +
460 719 $query = new \WP_Query( $args );
461 720 $subscriptions = $query->posts;
462 721 $total = $query->found_posts;
463 722 $max_num_pages = $query->max_num_pages;
@@ -467,13 +726,16 @@
467 726
468 727 include __DIR__ . '/views/subscription-list.php';
469 728 ?>
470 729 <div style="text-align:center;margin:38px 0 0 0;font-size:14px;color:#888;">
471 - Made with <span style="color:#e25555;font-size:1.1em;">♥</span> by the WPSubscription Team
730 + <?php
731 + /* translators: %s: a heart symbol. */
732 + printf( esc_html__( 'Made with %s by the WPSubscription Team', 'subscription' ), '<span style="color:#e25555;font-size:1.1em;">♥</span>' );
733 + ?>
472 734 <div style="margin-top:6px;">
473 - <a href="https://wpsubscription.co/contact" target="_blank" style="color:#2563eb;text-decoration:none;">Support</a>
735 + <a href="https://wpsubscription.co/contact?utm_source=plugin&utm_medium=admin&utm_campaign=support" target="_blank" style="color:#2563eb;text-decoration:none;"><?php esc_html_e( 'Support', 'subscription' ); ?></a>
474 736 &nbsp;/&nbsp;
475 - <a href="https://docs.converslabs.com/en" target="_blank" style="color:#2563eb;text-decoration:none;">Docs</a>
737 + <a href="https://docs.wpsubscription.co/en?utm_source=plugin&utm_medium=admin&utm_campaign=docs" target="_blank" style="color:#2563eb;text-decoration:none;"><?php esc_html_e( 'Docs', 'subscription' ); ?></a>
476 738 </div>
477 739 </div>
478 740 <?php
479 741 }
@@ -478,8 +740,116 @@
478 740 <?php
479 741 }
480 742
481 743 /**
744 + * Render the standalone Subscription Details page.
745 + *
746 + * Replaces the legacy metabox edit screen. Mirrors the list-page design
747 + * shell (header + wpsubs components) and handles the status-change form.
748 + *
749 + * @return void
750 + */
751 + public function render_subscription_details_page() {
752 + $subscription_id = isset( $_GET['id'] ) ? absint( wp_unslash( $_GET['id'] ) ) : 0;
753 +
754 + if ( ! $subscription_id || 'subscrpt_order' !== get_post_type( $subscription_id ) ) {
755 + wp_die( esc_html__( 'Invalid subscription.', 'subscription' ) );
756 + }
757 +
758 + if ( ! current_user_can( 'edit_post', $subscription_id ) ) {
759 + wp_die( esc_html__( 'You do not have permission to view this subscription.', 'subscription' ) );
760 + }
761 +
762 + $list_url = admin_url( 'admin.php?page=wp-subscription-list' );
763 + $form_action = admin_url( 'admin.php?page=wp-subscription-details&id=' . $subscription_id );
764 +
765 + // Handle the status-change submission.
766 + $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
767 + if ( 'POST' === $request_method && isset( $_POST['subscrpt_order_action'] ) ) {
768 + $nonce = isset( $_POST['subscrpt_order_action_nonce_field'] ) ? sanitize_text_field( wp_unslash( $_POST['subscrpt_order_action_nonce_field'] ) ) : '';
769 + if ( ! wp_verify_nonce( $nonce, 'subscrpt_order_action_nonce' ) ) {
770 + wp_die( esc_html__( 'Security check failed.', 'subscription' ) );
771 + }
772 +
773 + $action = sanitize_text_field( wp_unslash( $_POST['subscrpt_order_action'] ) );
774 + if ( '' !== $action ) {
775 + Subscriptions::process_status_change( $subscription_id, $action );
776 + }
777 +
778 + wp_safe_redirect( $form_action );
779 + exit;
780 + }
781 +
782 + // Allowed actions for the current status (mirrors Subscriptions::subscrpt_order_save_post()).
783 + $actions_data = array(
784 + 'active' => array(
785 + 'label' => __( 'Activate Subscription', 'subscription' ),
786 + 'value' => 'active',
787 + ),
788 + 'pending' => array(
789 + 'label' => __( 'Pending Subscription', 'subscription' ),
790 + 'value' => 'pending',
791 + ),
792 + 'expire' => array(
793 + 'label' => __( 'Expire Subscription', 'subscription' ),
794 + 'value' => 'expired',
795 + ),
796 + 'pe_cancelled' => array(
797 + 'label' => __( 'Pending Cancel Subscription', 'subscription' ),
798 + 'value' => 'pe_cancelled',
799 + ),
800 + 'cancelled' => array(
801 + 'label' => __( 'Cancel Subscription', 'subscription' ),
802 + 'value' => 'cancelled',
803 + ),
804 + );
805 +
806 + $map_actions = array(
807 + 'pending' => array( 'active', 'cancelled' ),
808 + 'active' => array( 'pe_cancelled', 'cancelled' ),
809 + 'pe_cancelled' => array( 'active', 'cancelled' ),
810 + 'cancelled' => array( 'active' ),
811 + 'expired' => array( 'active', 'cancelled' ),
812 + 'completed' => array( 'cancelled' ),
813 + );
814 +
815 + $status = get_post_status( $subscription_id );
816 + $actions = $map_actions[ $status ] ?? array();
817 +
818 + // Gather subscription data for the view.
819 + $subscription_data = Helper::get_subscription_data( $subscription_id );
820 +
821 + $order_id = $subscription_data['order']['order_id'] ?? get_post_meta( $subscription_id, '_subscrpt_order_id', true );
822 + $order = $order_id ? wc_get_order( $order_id ) : null;
823 + $order_item_id = $subscription_data['order']['order_item_id'] ?? get_post_meta( $subscription_id, '_subscrpt_order_item_id', true );
824 + $order_item = ( $order && $order_item_id ) ? $order->get_item( $order_item_id ) : null;
825 +
826 + $rows = Subscriptions::get_info_rows( $subscription_id );
827 +
828 + // Related orders.
829 + $order_histories = Helper::get_related_orders( $subscription_id );
830 +
831 + $this->render_admin_header(
832 + '',
833 + '',
834 + array(
835 + array(
836 + 'label' => __( 'Subscriptions', 'subscription' ),
837 + 'url' => $list_url,
838 + ),
839 + array(
840 + 'label' => '#' . $subscription_id,
841 + 'url' => '',
842 + ),
843 + )
844 + );
845 +
846 + include __DIR__ . '/views/subscription-details.php';
847 +
848 + $this->render_admin_footer();
849 + }
850 +
851 + /**
482 852 * Render Stats page
483 853 */
484 854 public function render_stats_page() {
485 855 $this->render_admin_header( __( 'Reports', 'subscription' ), __( 'View your subscription analytics', 'subscription' ) );
@@ -532,8 +902,24 @@
532 902 */
533 903 public function render_support_page() {
534 904 $this->render_admin_header( __( 'Help & Resources', 'subscription' ), __( 'Documentation, community links, and ways to get help with WPSubscription.', 'subscription' ) );
535 905 include 'views/support.php';
906 + $this->render_admin_footer();
907 + }
908 +
909 + /**
910 + * Render Onboarding Wizard page
911 + * SPA-style: all sections rendered at once, JS controls visibility
912 + * Initial load always shows page 1 (JS handles transitions from there)
913 + */
914 + public function render_onboarding_wizard() {
915 + // Start session if not already started (used by the wizard reset handler).
916 + if ( ! session_id() && ! headers_sent() ) {
917 + session_start();
918 + }
919 +
920 + $this->render_admin_header( __( 'Setup Wizard', 'subscription' ), __( 'Create your first subscription plan', 'subscription' ) );
921 + include __DIR__ . '/views/onboarding-wizard.php';
536 922 $this->render_admin_footer();
537 923 }
538 924
539 925 /**