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
subscription / includes / Admin / Menu.php

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

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