PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / trunk
Subscriptions for WooCommerce with Stripe Recurring Payments vtrunk
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 / Dashboard.php

Dashboard.php in Subscriptions for WooCommerce with Stripe Recurring Payments trunk, at includes/Admin/Dashboard.php

572 lines 17.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Dashboard screen.
4 *
5 * @package SpringDevs\Subscription\Admin
6 */
7
8 namespace SpringDevs\Subscription\Admin;
9
10 use SpringDevs\Subscription\Illuminate\Plans\PlanRepository;
11 use SpringDevs\Subscription\Illuminate\Stats;
12
13 /**
14 * Builds the dashboard payload and renders its container.
15 *
16 * The screen is a small React app (src/dashboard/). Everything it shows is
17 * computed here and handed over preloaded — there is no REST round trip,
18 * because none of these figures change while the page is open.
19 */
20 class Dashboard {
21
22 /**
23 * Script and style handle.
24 */
25 const HANDLE = 'subscrpt-dashboard';
26
27 /**
28 * Hook the screen's assets.
29 */
30 public function __construct() {
31 add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue' ) );
32 }
33
34 /**
35 * Enqueue the bundle, but only on the dashboard screen.
36 *
37 * @param string $hook Current admin page hook.
38 * @return void
39 */
40 public function maybe_enqueue( $hook ) {
41 if ( 'toplevel_page_wp-subscription' !== $hook ) {
42 return;
43 }
44
45 $this->enqueue();
46 }
47
48 /**
49 * Render the dashboard page.
50 *
51 * No shared admin footer: the screen ends in its own footer row, which
52 * already links the docs and support, so the shared one only repeated them.
53 *
54 * @return void
55 */
56 public function render() {
57 ( new Menu() )->render_admin_header( __( 'Overview', 'subscription' ) );
58 include __DIR__ . '/views/dashboard.php';
59 }
60
61 /**
62 * Enqueue the dashboard bundle.
63 *
64 * @return void
65 */
66 public function enqueue() {
67 $asset_file = SUBSCRPT_PATH . '/build/dashboard.asset.php';
68
69 if ( ! file_exists( $asset_file ) ) {
70 return;
71 }
72
73 $asset = require $asset_file;
74
75 wp_enqueue_script(
76 self::HANDLE,
77 SUBSCRPT_URL . '/build/dashboard.js',
78 $asset['dependencies'],
79 $asset['version'],
80 true
81 );
82
83 /*
84 * WordPress ships the stylesheet for @wordpress/components separately
85 * from the script. Without this the components render as unstyled
86 * markup — which is why plugins that skip it end up reinventing every
87 * button in their own CSS.
88 */
89 wp_enqueue_style( 'wp-components' );
90
91 wp_enqueue_style(
92 self::HANDLE,
93 SUBSCRPT_URL . '/build/dashboard.css',
94 array( 'wp-components' ),
95 $asset['version']
96 );
97
98 // The build emits dashboard-rtl.css alongside dashboard.css; this is
99 // what makes WordPress pick it up for right-to-left locales.
100 wp_style_add_data( self::HANDLE, 'rtl', 'replace' );
101
102 wp_set_script_translations( self::HANDLE, 'subscription' );
103
104 wp_add_inline_script(
105 self::HANDLE,
106 'window.subscrptDashboard = ' . wp_json_encode( $this->get_data() ) . ';',
107 'before'
108 );
109 }
110
111 /**
112 * Everything the dashboard renders.
113 *
114 * @return array<string,mixed>
115 */
116 public function get_data(): array {
117 $counts = Stats::get_status_counts();
118 $setup = $this->get_setup();
119
120 return array(
121 'pulse' => $this->get_pulse( $counts ),
122 'chart' => $this->get_chart(),
123 'setup' => $setup,
124 'health' => $this->get_health( $counts, $setup ),
125 'build' => $this->get_build_cards(),
126 'footer' => $this->get_footer_links(),
127 'isPro' => subscrpt_pro_activated(),
128 );
129 }
130
131 /**
132 * The five figures.
133 *
134 * @param array<string,int> $counts Status counts.
135 * @return array<int,array<string,mixed>>
136 */
137 private function get_pulse( array $counts ): array {
138 $list = admin_url( 'admin.php?page=wp-subscription-list' );
139
140 // Each of these runs a query, so call them once.
141 $on_hold = (int) ( $counts['on_hold'] ?? 0 );
142 $failed = Stats::count_failed_renewals_since( 24 );
143
144 // This month rather than a rolling window: the list filters by calendar
145 // month, and a figure that opens the list must match the rows it shows.
146 $this_month = new \DateTimeImmutable( 'now', wp_timezone() );
147
148 return array(
149 array(
150 'key' => 'active',
151 'icon' => 'people',
152 'label' => __( 'Active subscriptions', 'subscription' ),
153 'value' => (int) ( $counts['active'] ?? 0 ),
154 'url' => self::list_url( 'active' ),
155 ),
156 array(
157 'key' => 'on_hold',
158 'icon' => 'pause',
159 'label' => __( 'On-hold subscriptions', 'subscription' ),
160 'value' => $on_hold,
161 'url' => self::list_url( 'on_hold' ),
162 'tone' => $on_hold > 0 ? 'warning' : '',
163 ),
164 array(
165 'key' => 'due',
166 'icon' => 'money',
167 'label' => __( 'Renewals due (next 7 days)', 'subscription' ),
168 'value' => Stats::count_renewals_due_within( 7 ),
169 'url' => add_query_arg( 'renewal_due', 7, $list ),
170 ),
171 array(
172 'key' => 'failed',
173 'icon' => 'alert',
174 'label' => __( 'Failed renewals (last 24h)', 'subscription' ),
175 'value' => $failed,
176 'url' => admin_url( 'edit.php?post_type=shop_order&post_status=wc-failed' ),
177 'tone' => $failed > 0 ? 'error' : '',
178 ),
179 array(
180 'key' => 'new',
181 'icon' => 'trend',
182 'label' => __( 'New subscriptions (this month)', 'subscription' ),
183 'value' => Stats::count_new_in_month( $this_month ),
184 'url' => add_query_arg( 'date_filter', $this_month->format( 'Y-m' ), $list ),
185 ),
186 );
187 }
188
189 /**
190 * Monthly subscription revenue for the chart.
191 *
192 * Values are pre-formatted here rather than in the browser: the store's
193 * currency, decimal separator and symbol position all live in WooCommerce
194 * settings, and reimplementing wc_price() in JavaScript gets them wrong for
195 * every locale that is not the developer's.
196 *
197 * @return array<string,mixed>
198 */
199 private function get_chart(): array {
200 $months = Stats::get_monthly_revenue( 6 );
201 $total = 0.0;
202
203 foreach ( $months as &$month ) {
204 $total += $month['total'];
205 $month['display'] = function_exists( 'wc_price' )
206 ? wp_strip_all_tags( html_entity_decode( wc_price( $month['total'] ), ENT_QUOTES, 'UTF-8' ) )
207 : number_format_i18n( $month['total'], 2 );
208 }
209 unset( $month );
210
211 return array(
212 'months' => $months,
213 'total' => $total,
214 'display' => function_exists( 'wc_price' )
215 ? wp_strip_all_tags( html_entity_decode( wc_price( $total ), ENT_QUOTES, 'UTF-8' ) )
216 : number_format_i18n( $total, 2 ),
217 'empty' => $total <= 0,
218 'url' => admin_url( 'admin.php?page=wp-subscription-stats' ),
219 // Without pro, Reports is a preview of the Pro screen; the link says so.
220 'pro' => ! subscrpt_pro_activated(),
221 );
222 }
223
224 /**
225 * The onboarding checklist.
226 *
227 * The three steps the onboarding wizard walks through, each carrying
228 * whether it is done and where to go to do it — a checklist that hides what
229 * you have finished gives no sense of progress.
230 *
231 * Done is read from the plan tables, not from a flag the wizard sets, so a
232 * store that built its plans on the Plans screen is counted too, and one
233 * that deleted them all is not.
234 *
235 * Only the first step goes to the wizard. The wizard always creates a new
236 * plan, so a store that already has one finishes the later steps on that
237 * plan's own screen instead of making a second.
238 *
239 * The gateway is not a step: the wizard does not touch it, and a store can
240 * finish onboarding without one. It rides along as a warning that outlives
241 * the steps, because without it no customer can pay.
242 *
243 * @return array<string,mixed>
244 */
245 private function get_setup(): array {
246 $wizard = admin_url( 'admin.php?page=wp-subscription-onboarding' );
247 $latest_plan = $this->latest_plan_group_id();
248 $selling_plan = $this->latest_plan_group_id_with_active_term();
249 $product_plan = $selling_plan ? $selling_plan : $latest_plan;
250
251 $items = array(
252 array(
253 'id' => 'plan',
254 'label' => __( 'Create a plan', 'subscription' ),
255 'done' => $latest_plan > 0,
256 'action' => array(
257 'label' => __( 'Create', 'subscription' ),
258 'url' => $wizard,
259 ),
260 ),
261 array(
262 'id' => 'durations',
263 'label' => __( 'Add billing durations', 'subscription' ),
264 'done' => $selling_plan > 0,
265 'action' => array(
266 'label' => __( 'Add', 'subscription' ),
267 'url' => $latest_plan ? self::plan_url( $latest_plan, 'plans' ) : $wizard,
268 ),
269 ),
270 array(
271 'id' => 'product',
272 'label' => __( 'Connect a product', 'subscription' ),
273 'done' => $this->has_active_product_relation(),
274 'action' => array(
275 'label' => __( 'Connect', 'subscription' ),
276 'url' => $product_plan ? self::plan_url( $product_plan, 'products' ) : $wizard,
277 ),
278 ),
279 );
280
281 $done = count( array_filter( array_column( $items, 'done' ) ) );
282
283 return array(
284 'items' => $items,
285 'done' => $done,
286 'total' => count( $items ),
287 'complete' => $done === count( $items ),
288 'gateway' => $this->has_enabled_gateway() ? null : array(
289 'title' => __( 'No payment gateway enabled', 'subscription' ),
290 'text' => __( 'Customers cannot pay for subscriptions yet.', 'subscription' ),
291 'action' => array(
292 'label' => __( 'Set up', 'subscription' ),
293 'url' => admin_url( 'admin.php?page=wp-subscription-integrations' ),
294 ),
295 ),
296 'wizard' => array(
297 'label' => __( 'Run setup wizard', 'subscription' ),
298 'url' => $wizard,
299 ),
300 );
301 }
302
303 /**
304 * The banner across the top of the page.
305 *
306 * One sentence answering "is anything wrong", most urgent first:
307 * unfinished onboarding, then no way to pay, then subscription states. A
308 * store with nothing to sell, or no way to be paid for it, has nothing to
309 * be healthy about.
310 *
311 * The wizard only opens by itself once, so this is where a store owner who
312 * skipped it finds the way back — for as long as a step is left.
313 *
314 * @param array<string,int> $counts Status counts.
315 * @param array<string,mixed> $setup Onboarding checklist.
316 * @return array<string,mixed>
317 */
318 private function get_health( array $counts, array $setup ): array {
319 if ( empty( $setup['complete'] ) ) {
320 $done = (int) $setup['done'];
321 $total = (int) $setup['total'];
322
323 return array(
324 'state' => 'attention',
325 'title' => __( 'Finish setting up', 'subscription' ),
326 /* translators: 1: onboarding steps done, 2: onboarding steps in total. */
327 'text' => sprintf( _n( '%1$d of %2$d onboarding step done.', '%1$d of %2$d onboarding steps done.', $total, 'subscription' ), $done, $total ),
328 'action' => array(
329 'label' => __( 'Start onboarding', 'subscription' ),
330 'url' => admin_url( 'admin.php?page=wp-subscription-onboarding' ),
331 ),
332 );
333 }
334
335 if ( ! empty( $setup['gateway'] ) ) {
336 return array(
337 'state' => 'attention',
338 'title' => __( 'Payments are not set up', 'subscription' ),
339 'text' => __( 'No payment gateway is enabled, so customers cannot pay for subscriptions.', 'subscription' ),
340 'action' => array(
341 'label' => __( 'Set up payments', 'subscription' ),
342 'url' => admin_url( 'admin.php?page=wp-subscription-integrations' ),
343 ),
344 );
345 }
346
347 $on_hold = (int) ( $counts['on_hold'] ?? 0 );
348
349 if ( $on_hold > 0 ) {
350 return array(
351 'state' => 'attention',
352 'title' => __( 'Some subscriptions need a look', 'subscription' ),
353 /* translators: %d: number of on-hold subscriptions. */
354 'text' => sprintf( _n( '%d subscription is on hold.', '%d subscriptions are on hold.', $on_hold, 'subscription' ), $on_hold ),
355 'action' => array(
356 'label' => __( 'Review them', 'subscription' ),
357 'url' => self::list_url( 'on_hold' ),
358 ),
359 );
360 }
361
362 $active = (int) ( $counts['active'] ?? 0 );
363
364 return array(
365 'state' => 'clear',
366 'title' => __( 'All subscriptions look healthy', 'subscription' ),
367 /* translators: %d: number of active subscriptions. */
368 'text' => sprintf( _n( '%d active subscription, nothing needs attention.', '%d active subscriptions, nothing needs attention.', $active, 'subscription' ), $active ),
369 );
370 }
371
372 /**
373 * The three cards along the bottom.
374 *
375 * @return array<int,array<string,mixed>>
376 */
377 private function get_build_cards(): array {
378 $is_pro = subscrpt_pro_activated();
379
380 return array(
381 array(
382 'tone' => 'insight',
383 'icon' => 'chart',
384 'eyebrow' => __( 'Insight', 'subscription' ),
385 'title' => __( 'Open reports', 'subscription' ),
386 'text' => __( 'Revenue, active subscriptions and growth over time.', 'subscription' ),
387 'link' => array(
388 'label' => __( 'View reports', 'subscription' ),
389 'url' => admin_url( 'admin.php?page=wp-subscription-stats' ),
390 ),
391 'pro' => ! $is_pro,
392 ),
393 array(
394 'tone' => 'setup',
395 'icon' => 'card',
396 'eyebrow' => __( 'Setup', 'subscription' ),
397 'title' => __( 'Configure payments', 'subscription' ),
398 'text' => __( 'Connect PayPal, Stripe, Paddle and more from one screen.', 'subscription' ),
399 'link' => array(
400 'label' => __( 'Open integrations', 'subscription' ),
401 'url' => admin_url( 'admin.php?page=wp-subscription-integrations' ),
402 ),
403 ),
404 $is_pro
405 ? array(
406 'tone' => 'extend',
407 'icon' => 'shield',
408 'eyebrow' => __( 'Extend', 'subscription' ),
409 'title' => __( 'Subscription health', 'subscription' ),
410 'text' => __( 'Find and recover subscriptions that need rescuing.', 'subscription' ),
411 'link' => array(
412 'label' => __( 'Open health', 'subscription' ),
413 'url' => admin_url( 'admin.php?page=wp-subscription-health' ),
414 ),
415 )
416 : array(
417 'tone' => 'extend',
418 'icon' => 'shield',
419 'eyebrow' => __( 'Extend', 'subscription' ),
420 'title' => __( 'WPSubscription Pro', 'subscription' ),
421 'text' => __( 'Payment retries, a health queue and revenue reporting.', 'subscription' ),
422 'link' => array(
423 'label' => __( 'See what Pro adds', 'subscription' ),
424 'url' => 'https://wpsubscription.co/?utm_source=plugin&utm_medium=admin&utm_campaign=dashboard',
425 'external' => true,
426 ),
427 ),
428 );
429 }
430
431 /**
432 * The centred link row at the very bottom.
433 *
434 * @return array<int,array<string,mixed>>
435 */
436 private function get_footer_links(): array {
437 return array(
438 array(
439 'label' => __( 'Documentation', 'subscription' ),
440 'url' => 'https://docs.wpsubscription.co/en?utm_source=plugin&utm_medium=admin&utm_campaign=dashboard',
441 ),
442 array(
443 'label' => __( 'Get support', 'subscription' ),
444 'url' => 'https://wpsubscription.co/contact?utm_source=plugin&utm_medium=admin&utm_campaign=dashboard',
445 ),
446 array(
447 'label' => __( 'My account', 'subscription' ),
448 'url' => 'https://my.wpsubscription.co/?utm_source=plugin&utm_medium=admin&utm_campaign=dashboard',
449 ),
450 );
451 }
452
453 /**
454 * The subscriptions list, filtered to one status.
455 *
456 * The list reads `subscrpt_status` — its dropdown is named that and its
457 * reset link clears that. Any other name, `post_status` included, is ignored
458 * without complaint and the list opens unfiltered.
459 *
460 * @param string $status A registered subscription status, e.g. `on_hold`.
461 * @return string
462 */
463 private static function list_url( string $status ): string {
464 return add_query_arg( 'subscrpt_status', $status, admin_url( 'admin.php?page=wp-subscription-list' ) );
465 }
466
467 /**
468 * A plan's screen, opened on one of its tabs.
469 *
470 * @param int $plan_id Plan group id.
471 * @param string $tab `plans` (its durations) or `products`.
472 * @return string
473 */
474 private static function plan_url( int $plan_id, string $tab ): string {
475 return add_query_arg(
476 array(
477 'view' => 'detail',
478 'plan' => $plan_id,
479 'tab' => $tab,
480 ),
481 admin_url( 'admin.php?page=' . Plans::SLUG )
482 );
483 }
484
485 /**
486 * The newest plan, in any status, or 0 when there is none.
487 *
488 * A draft plan has still been created. Any plan at all is the same test the
489 * first-visit redirect to the wizard uses.
490 *
491 * @return int
492 */
493 private function latest_plan_group_id(): int {
494 global $wpdb;
495
496 $table = PlanRepository::group_table();
497
498 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
499 $id = $wpdb->get_var( "SELECT id FROM {$table} ORDER BY id DESC LIMIT 1" );
500 // phpcs:enable
501
502 return (int) $id;
503 }
504
505 /**
506 * The plan that most recently gained an active billing duration, or 0.
507 *
508 * Drafts do not count: the Plans screen seeds a draft "Monthly" duration
509 * under every new plan, so counting it would tick this step the moment a
510 * plan exists, before anyone has chosen how customers are billed.
511 *
512 * @return int
513 */
514 private function latest_plan_group_id_with_active_term(): int {
515 global $wpdb;
516
517 $table = PlanRepository::plan_table();
518
519 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
520 $id = $wpdb->get_var( $wpdb->prepare( "SELECT plan_group_id FROM {$table} WHERE status = %s ORDER BY id DESC LIMIT 1", 'active' ) );
521 // phpcs:enable
522
523 return (int) $id;
524 }
525
526 /**
527 * Whether any gateway this plugin supports is switched on.
528 *
529 * @return bool
530 */
531 private function has_enabled_gateway(): bool {
532 foreach ( array( 'wp_subscription_paypal', 'stripe', 'smartpay_paddle' ) as $gateway ) {
533 if ( Integrations::is_gateway_enabled( $gateway ) ) {
534 return true;
535 }
536 }
537
538 return false;
539 }
540
541 /**
542 * Whether anything is connected to an active billing duration.
543 *
544 * A product attached only to draft durations cannot be bought, and an
545 * exclusion row takes a product away rather than connecting it, so
546 * neither counts.
547 *
548 * @return bool
549 */
550 private function has_active_product_relation(): bool {
551 global $wpdb;
552
553 $relations = PlanRepository::relation_table();
554 $plans = PlanRepository::plan_table();
555
556 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
557 $found = $wpdb->get_var(
558 $wpdb->prepare(
559 "SELECT 1 FROM {$relations} r
560 INNER JOIN {$plans} p ON p.id = r.plan_id
561 WHERE r.status = %s AND r.exclude = 0 AND p.status = %s
562 LIMIT 1",
563 'active',
564 'active'
565 )
566 );
567 // phpcs:enable
568
569 return (bool) $found;
570 }
571 }
572