PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.0.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.0.0
2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 All 61 releases
subscription / includes / Admin / Menu.php

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

1,055 lines 37.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Admin menu + shared admin header/breadcrumb renderer.
4 *
5 * @package SpringDevs\Subscription\Admin
6 */
7
8 namespace SpringDevs\Subscription\Admin;
9
10 use SpringDevs\Subscription\Illuminate\Helper;
11 use SpringDevs\Subscription\Illuminate\Stats;
12
13 /**
14 * Menu class
15 *
16 * @package SpringDevs\Subscription\Admin
17 */
18 class Menu {
19
20 /**
21 * Initialize the class
22 */
23 public function __construct() {
24 add_action( 'admin_menu', array( $this, 'create_admin_menu' ) );
25 add_action( 'admin_menu', array( $this, 'reorder_submenu' ), 999 );
26 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) );
27 add_action( 'wp_ajax_subscrpt_bulk_action', array( $this, 'handle_bulk_action_ajax' ) );
28 add_action( 'admin_init', array( $this, 'maybe_onboarding_redirect' ) );
29 }
30
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 /**
68 * Enqueue admin assets
69 */
70 public function enqueue_admin_assets() {
71 wp_enqueue_style(
72 'wp-subscription-admin',
73 SUBSCRPT_ASSETS . '/css/admin.css',
74 array(),
75 SUBSCRPT_VERSION
76 );
77
78 // Enqueue admin JavaScript for subscription list functionality
79 wp_enqueue_script(
80 'sdevs_subscription_admin',
81 SUBSCRPT_ASSETS . '/js/admin.js',
82 array( 'jquery' ),
83 SUBSCRPT_VERSION,
84 true
85 );
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
129 // Localize script for AJAX
130 wp_localize_script(
131 'sdevs_subscription_admin',
132 'wp_subscription_ajax',
133 array(
134 'nonce' => wp_create_nonce( 'subscrpt_bulk_action_nonce' ),
135 'ajaxurl' => admin_url( 'admin-ajax.php' ),
136 )
137 );
138 }
139
140 /**
141 * Create Subscriptions Menu.
142 */
143 public function create_admin_menu() {
144 $parent_slug = 'wp-subscription';
145 // Determine if the menu is active
146 $is_active = isset( $_GET['page'] ) && strpos( sanitize_text_field( wp_unslash( $_GET['page'] ) ), 'wp-subscription' ) === 0;
147 $icon_url = $is_active
148 ? SUBSCRPT_ASSETS . '/images/icons/subscription-20.png'
149 : SUBSCRPT_ASSETS . '/images/icons/subscription-20-gray.png';
150
151 $pro_text = __( 'WPSubscription Pro required', 'subscription' );
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>';
153
154 // Main menu
155 add_menu_page(
156 __( 'WPSubscription', 'subscription' ),
157 __( 'WPSubscription', 'subscription' ),
158 'manage_options',
159 $parent_slug,
160 array( $this, 'render_dashboard_page' ),
161 $icon_url,
162 40
163 );
164
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"]`
167 add_submenu_page(
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,
192 __( 'Subscriptions', 'subscription' ),
193 __( 'Subscriptions', 'subscription' ),
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(
202 $parent_slug,
203 __( 'Subscription Details', 'subscription' ),
204 __( 'Subscription Details', 'subscription' ),
205 'manage_options',
206 'wp-subscription-details',
207 array( $this, 'render_subscription_details_page' )
208 );
209
210 // Stats Overview
211 add_submenu_page(
212 $parent_slug,
213 __( 'Reports', 'subscription' ),
214 __( 'Reports', 'subscription' ) . $pro_badge,
215 'manage_options',
216 'wp-subscription-stats',
217 array( $this, 'render_stats_page' )
218 );
219
220 // Subscription Health
221 add_submenu_page(
222 $parent_slug,
223 __( 'Health', 'subscription' ),
224 __( 'Health', 'subscription' ) . $pro_badge,
225 'manage_options',
226 'wp-subscription-health',
227 array( $this, 'render_health_page' )
228 );
229
230 // Delivery Schedules
231 add_submenu_page(
232 $parent_slug,
233 __( 'Delivery', 'subscription' ),
234 __( 'Delivery', 'subscription' ) . $pro_badge,
235 'manage_options',
236 'wp-subscription-delivery',
237 array( $this, 'render_delivery_page' )
238 );
239
240 // Help & Resources
241 add_submenu_page(
242 $parent_slug,
243 __( 'Help', 'subscription' ),
244 __( 'Help', 'subscription' ),
245 'manage_options',
246 'wp-subscription-support',
247 array( $this, 'render_support_page' )
248 );
249
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 */
260 add_submenu_page(
261 'woocommerce',
262 __( 'Subscriptions', 'subscription' ),
263 __( 'Subscriptions', 'subscription' ),
264 'manage_options',
265 'admin.php?page=wp-subscription-list'
266 );
267 }
268
269 /**
270 * Reorder the WPSubscription submenu after all items are registered.
271 *
272 * Runs at admin_menu priority 999 so every plugin has already inserted
273 * its items. A filter lets the Pro plugin (or any extension) adjust the
274 * slug order before sorting is applied.
275 *
276 * @do_action subscrpt_submenu_order {string[]} $order Ordered list of submenu page slugs.
277 */
278 public function reorder_submenu() {
279 $parent = 'wp-subscription';
280
281 global $submenu;
282
283 if ( empty( $submenu[ $parent ] ) ) {
284 return;
285 }
286
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.
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 ];
304
305 /**
306 * Filter the WPSubscription submenu slug order.
307 *
308 * Each entry is a slug => integer position pair. Lower positions appear
309 * first. Use gaps of 10 between built-in positions so extensions can
310 * insert their own slugs between existing items without renumbering.
311 *
312 * Example (an add-on placing its page between Reports and Health):
313 * add_filter( 'subscrpt_submenu_order', function( $order ) {
314 * $order['wp-subscription-my-addon'] = 45;
315 * return $order;
316 * } );
317 *
318 * @param array<string,int> $order Map of slug => position.
319 */
320 $order = apply_filters( 'subscrpt_submenu_order', $default_order );
321
322 // Sort by position value, preserving slug keys.
323 asort( $order );
324
325 // Index current items by slug for fast lookup.
326 $indexed = [];
327 foreach ( $submenu[ $parent ] as $item ) {
328 $indexed[ $item[2] ] = $item;
329 }
330
331 // Build sorted list from the ordered slugs.
332 $sorted = [];
333 foreach ( $order as $slug => $position ) {
334 if ( isset( $indexed[ $slug ] ) ) {
335 $sorted[] = $indexed[ $slug ];
336 unset( $indexed[ $slug ] );
337 }
338 }
339
340 // Append any remaining items not covered by the order list.
341 foreach ( $indexed as $item ) {
342 $sorted[] = $item;
343 }
344
345 $submenu[ $parent ] = $sorted; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Intentional reorder of submenu items.
346 }
347
348 /**
349 * Render the admin header
350 */
351 public function render_admin_footer() {
352 ?>
353 <div style="text-align:center;margin:38px 0 0 0;font-size:14px;color:#888;">
354 Made with <span style="color:#e25555;font-size:1.1em;">♥</span> by the WPSubscription Team
355 <div style="margin-top:6px;">
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>
357 &nbsp;/&nbsp;
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>
359 </div>
360 </div>
361 <?php
362 }
363 /**
364 * Render the admin header.
365 *
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.
378 */
379 public function render_admin_header( string $title = '', string $subtitle = '', array $breadcrumbs = [] ) {
380 $current = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : 'wp-subscription';
381
382 // Kept for backward compatibility — extensions may hook here for side-effects.
383 $menu_items = apply_filters( 'subscrpt_admin_header_menu_items', [], $current );
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;
411 ?>
412 <div class="wp-subscription-admin-header">
413 <div class="wp-subscription-admin-header-inner">
414 <div class="wp-subscription-admin-header-left">
415 <nav class="wp-subscription-breadcrumb" aria-label="<?php esc_attr_e( 'Breadcrumb', 'subscription' ); ?>">
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' ); ?>">
417 <span class="dashicons dashicons-admin-home"></span>
418 </a>
419 <?php foreach ( $trail as $index => $crumb ) : ?>
420 <span class="wp-subscription-breadcrumb-sep" aria-hidden="true">/</span>
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; ?>
428 </nav>
429 </div>
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
464 <?php if ( ! class_exists( 'Sdevs_Wc_Subscription_Pro' ) ) : ?>
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>
469 <?php endif; ?>
470 <img src="<?php echo esc_url( SUBSCRPT_ASSETS . '/images/logo-title.svg' ); ?>" alt="WPSubscription" class="wp-subscription-logo">
471 </div>
472 </div>
473 </div>
474 <?php
475 }
476
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 /**
516 * Render Subscriptions page
517 */
518 public function render_subscriptions_page() {
519 $this->render_admin_header( __( 'Subscriptions', 'subscription' ), __( 'Manage your subscriptions', 'subscription' ) );
520
521 // Handle filters
522 $status = isset( $_GET['subscrpt_status'] ) ? sanitize_text_field( wp_unslash( $_GET['subscrpt_status'] ) ) : '';
523 $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '';
524 $date_filter = isset( $_GET['date_filter'] ) ? sanitize_text_field( wp_unslash( $_GET['date_filter'] ) ) : '';
525 $per_page = isset( $_GET['per_page'] ) ? max( 1, intval( $_GET['per_page'] ) ) : 20;
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;
528
529 // Handle form submissions (both filters and bulk actions)
530 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
531 if ( 'POST' === $request_method ) {
532 // Verify nonce before processing any POST data.
533 $nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
534 if ( ! wp_verify_nonce( $nonce, 'subscrpt_list_action' ) ) {
535 wp_die( esc_html__( 'Security check failed.', 'subscription' ) );
536 }
537 // Handle bulk actions
538 if ( isset( $_POST['bulk_action'] ) || isset( $_POST['bulk_action2'] ) ) {
539 $bulk_action = isset( $_POST['bulk_action'] ) ? sanitize_text_field( wp_unslash( $_POST['bulk_action'] ) ) : sanitize_text_field( wp_unslash( $_POST['bulk_action2'] ?? '' ) );
540 $action = isset( $_POST['action'] ) ? sanitize_text_field( wp_unslash( $_POST['action'] ) ) : sanitize_text_field( wp_unslash( $_POST['action2'] ?? '' ) );
541
542 if ( $bulk_action && $action && $action !== '-1' && isset( $_POST['subscription_ids'] ) && is_array( $_POST['subscription_ids'] ) ) {
543 $subscription_ids = array_map( 'intval', $_POST['subscription_ids'] );
544
545 if ( $action === 'trash' ) {
546 foreach ( $subscription_ids as $sub_id ) {
547 wp_trash_post( $sub_id );
548 }
549 } elseif ( $action === 'restore' ) {
550 foreach ( $subscription_ids as $sub_id ) {
551 wp_untrash_post( $sub_id );
552 }
553 } elseif ( $action === 'delete' ) {
554 foreach ( $subscription_ids as $sub_id ) {
555 wp_delete_post( $sub_id, true );
556 }
557 }
558
559 wp_safe_redirect( admin_url( 'admin.php?page=wp-subscription-list' ) );
560 exit;
561 }
562 }
563
564 // Handle filter form submission
565 if ( isset( $_POST['filter_action'] ) ) {
566 $filter_params = array();
567
568 if ( ! empty( $_POST['subscrpt_status'] ) ) {
569 $filter_params['subscrpt_status'] = sanitize_text_field( wp_unslash( $_POST['subscrpt_status'] ) );
570 }
571 if ( ! empty( $_POST['date_filter'] ) ) {
572 $filter_params['date_filter'] = sanitize_text_field( wp_unslash( $_POST['date_filter'] ) );
573 }
574 if ( ! empty( $_POST['renewal_due'] ) ) {
575 $filter_params['renewal_due'] = absint( $_POST['renewal_due'] );
576 }
577 if ( ! empty( $_POST['s'] ) ) {
578 $filter_params['s'] = sanitize_text_field( wp_unslash( $_POST['s'] ) );
579 }
580 if ( ! empty( $_POST['per_page'] ) ) {
581 $filter_params['per_page'] = intval( $_POST['per_page'] );
582 }
583
584 $redirect_url = add_query_arg( $filter_params, admin_url( 'admin.php?page=wp-subscription-list' ) );
585 wp_safe_redirect( $redirect_url );
586 exit;
587 }
588 }
589
590 // Handle individual actions
591 if ( isset( $_GET['action'] ) && ! empty( $_GET['sub_id'] ) ) {
592 $sub_id = intval( $_GET['sub_id'] );
593 $action = sanitize_text_field( wp_unslash( $_GET['action'] ) );
594 $nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
595
596 // Clean trash action.
597 if ( $action === 'clean_trash' ) {
598 // Verify nonce for security.
599 $nonce_action = 'wpsubs_action_clean_trash';
600 if ( ! wp_verify_nonce( $nonce, $nonce_action ) ) {
601 echo '<div class="notice notice-error"><p>' . esc_html__( 'Security check failed. Please try again.', 'subscription' ) . '</p></div>';
602 wp_die();
603 }
604
605 // Clean all trash items.
606 $trash_posts = get_posts(
607 [
608 'post_type' => 'subscrpt_order',
609 'post_status' => 'trash',
610 'numberposts' => -1,
611 'fields' => 'ids',
612 ]
613 );
614
615 foreach ( $trash_posts as $trash_id ) {
616 wp_delete_post( $trash_id, true );
617 }
618
619 wp_safe_redirect( admin_url( 'admin.php?page=wp-subscription-list&subscrpt_status=trash' ) );
620 exit;
621 } else {
622 // For other actions, verify nonce with subscription ID.
623 $nonce_action = 'wpsubs_action_' . $sub_id;
624 if ( ! wp_verify_nonce( $nonce, $nonce_action ) ) {
625 echo '<div class="notice notice-error"><p>' . esc_html__( 'Security check failed. Please try again.', 'subscription' ) . '</p></div>';
626 wp_die();
627 }
628
629 $redirect_url = admin_url( 'admin.php?page=wp-subscription-list' );
630
631 switch ( $action ) {
632 case 'duplicate':
633 $post = get_post( $sub_id );
634 if ( $post && $post->post_type === 'subscrpt_order' ) {
635 $new_post = [
636 'post_title' => $post->post_title . ' (Copy)',
637 'post_content' => $post->post_content,
638 'post_status' => 'draft',
639 'post_type' => 'subscrpt_order',
640 ];
641 $new_id = wp_insert_post( $new_post );
642 if ( $new_id ) {
643 $meta = get_post_meta( $sub_id );
644 foreach ( $meta as $key => $values ) {
645 foreach ( $values as $value ) {
646 add_post_meta( $new_id, $key, maybe_unserialize( $value ) );
647 }
648 }
649 }
650 }
651 break;
652 case 'trash':
653 wp_trash_post( $sub_id );
654 break;
655 case 'restore':
656 wp_untrash_post( $sub_id );
657 break;
658 case 'delete':
659 wp_delete_post( $sub_id, true );
660 $redirect_url = admin_url( 'admin.php?page=wp-subscription-list&subscrpt_status=trash' );
661 break;
662 }
663
664 wp_safe_redirect( $redirect_url );
665 exit;
666 }
667 }
668
669 $args = [
670 'post_type' => 'subscrpt_order',
671 'post_status' => 'any',
672 'posts_per_page' => $per_page,
673 'paged' => $paged,
674 'orderby' => 'date',
675 'order' => 'DESC',
676 ];
677
678 if ( $status ) {
679 $args['post_status'] = $status;
680 }
681 // Search only by subscription ID
682 if ( $search !== '' ) {
683 if ( is_numeric( $search ) ) {
684 $args['p'] = intval( $search );
685 } else {
686 // If not numeric, return no results
687 $args['post__in'] = array( 0 );
688 }
689 }
690 // Dynamic date filter (YYYY-MM)
691 if ( $date_filter && preg_match( '/^\d{4}-\d{2}$/', $date_filter ) ) {
692 $year = substr( $date_filter, 0, 4 );
693 $month = substr( $date_filter, 5, 2 );
694 $args['date_query'][] = [
695 'year' => intval( $year ),
696 'month' => intval( $month ),
697 ];
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 );
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
715 $query = new \WP_Query( $args );
716 $subscriptions = $query->posts;
717 $total = $query->found_posts;
718 $max_num_pages = $query->max_num_pages;
719
720 // Get all possible statuses for filter dropdown
721 $all_statuses = get_post_stati( [ 'show_in_admin_all_list' => true ], 'objects' );
722
723 include __DIR__ . '/views/subscription-list.php';
724 ?>
725 <div style="text-align:center;margin:38px 0 0 0;font-size:14px;color:#888;">
726 Made with <span style="color:#e25555;font-size:1.1em;">♥</span> by the WPSubscription Team
727 <div style="margin-top:6px;">
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>
729 &nbsp;/&nbsp;
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>
731 </div>
732 </div>
733 <?php
734 }
735
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 /**
845 * Render Stats page
846 */
847 public function render_stats_page() {
848 $this->render_admin_header( __( 'Reports', 'subscription' ), __( 'View your subscription analytics', 'subscription' ) );
849
850 if ( ! subscrpt_pro_activated() ) {
851 include 'views/reports-preview.php';
852 } else {
853 // Allow pro plugin to override the entire stats page content.
854 do_action( 'subscrpt_render_stats_page' );
855 }
856
857 $this->render_admin_footer();
858 }
859
860 /**
861 * Render Health page
862 */
863 public function render_health_page() {
864 $this->render_admin_header( __( 'Health', 'subscription' ), __( 'Monitor your subscription health', 'subscription' ) );
865
866 if ( ! subscrpt_pro_activated() ) {
867 include 'views/health-preview.php';
868 } else {
869 // Allow pro plugin to render the full health page content.
870 do_action( 'subscrpt_render_health_page' );
871 }
872
873 $this->render_admin_footer();
874 }
875
876 /**
877 * Render Delivery Schedules page.
878 * When pro is active, fires subscrpt_render_delivery_page for pro to handle.
879 */
880 public function render_delivery_page() {
881 $this->render_admin_header( __( 'Delivery Schedules', 'subscription' ), __( 'Track and manage subscription delivery schedules.', 'subscription' ) );
882
883 if ( ! subscrpt_pro_activated() ) {
884 include 'views/delivery-preview.php';
885 } else {
886 // Allow pro plugin to render the full delivery page content.
887 do_action( 'subscrpt_render_delivery_page' );
888 }
889
890 $this->render_admin_footer();
891 }
892
893 /**
894 * Render Support page
895 */
896 public function render_support_page() {
897 $this->render_admin_header( __( 'Help & Resources', 'subscription' ), __( 'Documentation, community links, and ways to get help with WPSubscription.', 'subscription' ) );
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';
915 $this->render_admin_footer();
916 }
917
918 /**
919 * Render the legacy subscriptions list page (WP_List_Table based)
920 */
921 public function render_legacy_subscriptions_page() {
922 // No longer needed, as the menu now links directly to the post type list.
923 }
924
925 /**
926 * Handle bulk action AJAX
927 */
928 public function handle_bulk_action_ajax() {
929 // Verify nonce
930 $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
931 if ( ! wp_verify_nonce( $nonce, 'subscrpt_bulk_action_nonce' ) ) {
932 wp_send_json_error( array( 'message' => __( 'Security check failed.', 'subscription' ) ) );
933 }
934
935 // Check permissions
936 if ( ! current_user_can( 'manage_woocommerce' ) ) {
937 wp_send_json_error( array( 'message' => __( 'You do not have permission to perform this action.', 'subscription' ) ) );
938 }
939
940 // Get action and subscription IDs
941 $bulk_action = isset( $_POST['bulk_action'] ) ? sanitize_text_field( wp_unslash( $_POST['bulk_action'] ) ) : '';
942 $subscription_ids = isset( $_POST['subscription_ids'] ) ? array_map( 'intval', $_POST['subscription_ids'] ) : array();
943
944 if ( empty( $subscription_ids ) ) {
945 wp_send_json_error( array( 'message' => __( 'No subscriptions selected.', 'subscription' ) ) );
946 }
947
948 $processed_count = 0;
949 $errors = array();
950
951 foreach ( $subscription_ids as $subscription_id ) {
952 $post = get_post( $subscription_id );
953
954 if ( ! $post || $post->post_type !== 'subscrpt_order' ) {
955 // translators: Subscription ID.
956 $errors[] = sprintf( __( 'Subscription #%d not found.', 'subscription' ), $subscription_id );
957 continue;
958 }
959
960 try {
961 switch ( $bulk_action ) {
962 case 'trash':
963 if ( wp_trash_post( $subscription_id ) ) {
964 ++$processed_count;
965 } else {
966 $errors[] = sprintf(
967 // translators: Subscription ID.
968 __( 'Failed to move subscription #%d to trash.', 'subscription' ),
969 $subscription_id
970 );
971 }
972 break;
973
974 case 'restore':
975 if ( wp_untrash_post( $subscription_id ) ) {
976 ++$processed_count;
977 } else {
978 $errors[] = sprintf(
979 // translators: Subscription ID.
980 __( 'Failed to restore subscription #%d.', 'subscription' ),
981 $subscription_id
982 );
983 }
984 break;
985
986 case 'delete':
987 if ( wp_delete_post( $subscription_id, true ) ) {
988 ++$processed_count;
989 } else {
990 $errors[] = sprintf(
991 // translators: Subscription ID.
992 __( 'Failed to delete subscription #%d.', 'subscription' ),
993 $subscription_id
994 );
995 }
996 break;
997
998 default:
999 $errors[] = sprintf(
1000 // translators: Bulk action.
1001 __( 'Unknown action: %s', 'subscription' ),
1002 $bulk_action
1003 );
1004 break;
1005 }
1006 } catch ( Exception $e ) {
1007 $errors[] = sprintf(
1008 // translators: Subscription ID, Error message.
1009 __( 'Error processing subscription #%1$d: %2$s', 'subscription' ),
1010 $subscription_id,
1011 $e->getMessage()
1012 );
1013 }
1014 }
1015
1016 // Prepare response message
1017 $message = '';
1018 if ( $processed_count > 0 ) {
1019 switch ( $bulk_action ) {
1020 case 'trash':
1021 $message = sprintf(
1022 // translators: Number of subscriptions.
1023 _n( '%d subscription moved to trash.', '%d subscriptions moved to trash.', $processed_count, 'subscription' ),
1024 $processed_count
1025 );
1026 break;
1027 case 'restore':
1028 $message = sprintf(
1029 // translators: Number of subscriptions.
1030 _n( '%d subscription restored.', '%d subscriptions restored.', $processed_count, 'subscription' ),
1031 $processed_count
1032 );
1033 break;
1034 case 'delete':
1035 $message = sprintf(
1036 // translators: Number of subscriptions.
1037 _n( '%d subscription permanently deleted.', '%d subscriptions permanently deleted.', $processed_count, 'subscription' ),
1038 $processed_count
1039 );
1040 break;
1041 }
1042 }
1043
1044 if ( ! empty( $errors ) ) {
1045 $message .= ' ' . __( 'Some errors occurred:', 'subscription' ) . ' ' . implode( ', ', $errors );
1046 }
1047
1048 if ( $processed_count > 0 ) {
1049 wp_send_json_success( array( 'message' => $message ) );
1050 } else {
1051 wp_send_json_error( array( 'message' => $message ? $message : __( 'No subscriptions were processed.', 'subscription' ) ) );
1052 }
1053 }
1054 }
1055