PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.8
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.8
4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 4.2.0 All 139 releases
learnpress / assets / src / js / admin / statistics / tab-orders.js

tab-orders.js in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.4.8, at assets/src/js/admin/statistics/tab-orders.js

434 lines 10.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Orders tab module.
3 *
4 * Fetches the `dashboard` payload and renders KPIs, completed-orders chart,
5 * top sold courses, recent exceptions, popups and CSV export.
6 *
7 * @since 4.4.2
8 * @version 1.0.0
9 */
10
11 import * as lpUtils from 'lpAssetsJsPath/utils.js';
12 import { LP_STATS_FILTER_CHANGED, LP_STATS_EXPORT_CSV } from './state.js';
13 import { lpStatsFetch, getStatsI18n } from './api.js';
14 import { renderKpi } from './kpi.js';
15 import { renderLineChart } from './chart.js';
16 import { renderDataTable } from './data-table.js';
17 import { lpStatsReportModal } from './report-modal.js';
18 import { exportCsv, buildCsvFilename } from './csv.js';
19
20 const sprintfLite = ( template, value ) =>
21 String( template ).replace( /%[ds]/, String( value ) ).replace( /%%/g, '%' );
22
23 export class LpStatsTabOrders {
24 static selectors = {
25 elContainer: '.lp-stats-tab-orders',
26 elChartCanvas: '#orders-chart-content',
27 elTableTopSold: '.lp-stats-table-top-sold-courses',
28 elTableExceptions: '.lp-stats-table-order-exceptions',
29 elBtnViewAllTopSold: '.lp-stats-view-all-top-sold',
30 elBtnViewAllExceptions: '.lp-stats-view-all-exceptions',
31 elPaymentHealthRow: '.lp-stats-payment-health__row',
32 elSkeleton: '.lp-skeleton-animation',
33 };
34
35 static kpiCards = {
36 net_sales: '.lp-kpi-net-sales',
37 completed_orders: '.lp-kpi-completed-orders',
38 processing: '.lp-kpi-processing',
39 pending: '.lp-kpi-pending',
40 cancelled_failed: '.lp-kpi-cancelled-failed',
41 paid_courses_sold: '.lp-kpi-paid-courses-sold',
42 };
43
44 static orderStatusAllowlist = [
45 'completed',
46 'processing',
47 'pending',
48 'cancelled',
49 'failed',
50 ];
51
52 constructor() {
53 this.elContainer = null;
54 this.isRequesting = false;
55 this.pendingReload = false;
56 this.tables = {};
57 this.orderStatusFilter = '';
58 }
59
60 init() {
61 this.elContainer = document.querySelector(
62 LpStatsTabOrders.selectors.elContainer
63 );
64 if ( ! this.elContainer ) {
65 return;
66 }
67
68 this.orderStatusFilter = this.readOrderStatus();
69 this.events();
70 this.loadData();
71 }
72
73 events() {
74 if ( LpStatsTabOrders._loadedEvents ) {
75 return;
76 }
77 LpStatsTabOrders._loadedEvents = this;
78
79 lpUtils.eventHandlers( 'click', [
80 {
81 selector: LpStatsTabOrders.selectors.elBtnViewAllTopSold,
82 class: this,
83 callBack: this.viewAllTopSold.name,
84 },
85 {
86 selector: LpStatsTabOrders.selectors.elBtnViewAllExceptions,
87 class: this,
88 callBack: this.viewAllExceptions.name,
89 },
90 ] );
91
92 document.addEventListener( LP_STATS_FILTER_CHANGED, () => this.loadData() );
93 document.addEventListener( LP_STATS_EXPORT_CSV, () => this.exportTables() );
94 }
95
96 readOrderStatus() {
97 const status = new URL( window.location.href ).searchParams.get(
98 'order_status'
99 );
100
101 return LpStatsTabOrders.orderStatusAllowlist.includes( status )
102 ? status
103 : '';
104 }
105
106 toggleSkeletons( show ) {
107 this.elContainer
108 .querySelectorAll( LpStatsTabOrders.selectors.elSkeleton )
109 .forEach( ( el ) => {
110 el.style.display = show ? 'block' : 'none';
111 } );
112 }
113
114 loadData() {
115 if ( this.isRequesting ) {
116 this.pendingReload = true;
117 return;
118 }
119 this.isRequesting = true;
120
121 lpStatsFetch(
122 'order-statistics',
123 {},
124 {
125 before: () => this.toggleSkeletons( true ),
126 success: ( response ) => this.render( response.data ),
127 error: ( err ) => {
128 console.error( 'LP Statistics orders:', err );
129 this.render( null );
130 },
131 completed: () => {
132 this.toggleSkeletons( false );
133 this.isRequesting = false;
134 if ( this.pendingReload ) {
135 this.pendingReload = false;
136 this.loadData();
137 }
138 },
139 }
140 );
141 }
142
143 render( data ) {
144 if ( ! data?.dashboard ) {
145 console.error( 'LP Statistics orders: dashboard payload missing.' );
146 data = { chart_data: {}, dashboard: {} };
147 }
148
149 const dashboard = data.dashboard || {};
150
151 this.renderKpis( dashboard.kpis || {} );
152 this.renderChart( data.chart_data || {} );
153 this.renderPaymentHealth( dashboard.order_health || {} );
154 this.renderTables( dashboard );
155 this.highlightStatus();
156 }
157
158 renderKpis( kpis ) {
159 Object.entries( LpStatsTabOrders.kpiCards ).forEach( ( [ key, selector ] ) => {
160 const elCard = this.elContainer.querySelector( selector );
161 const payload = { ...( kpis[ key ] || {} ) };
162
163 if ( 'completed_orders' === key && payload.aov_formatted ) {
164 payload.subline = sprintfLite(
165 getStatsI18n( 'aov', 'Avg. order value: %s' ),
166 payload.aov_formatted
167 );
168 }
169 if ( 'processing' === key ) {
170 payload.subline = getStatsI18n(
171 'needsFulfillmentReview',
172 'Needs fulfillment review'
173 );
174 }
175 if ( 'pending' === key ) {
176 payload.subline = getStatsI18n( 'awaitingPayment', 'Awaiting payment' );
177 }
178 if ( 'cancelled_failed' === key && null != payload.rate_pct ) {
179 payload.subline = sprintfLite(
180 getStatsI18n( 'exceptionRate', '%s%% of all orders' ),
181 payload.rate_pct
182 );
183 }
184
185 renderKpi( elCard, payload );
186 } );
187 }
188
189 renderPaymentHealth( orderHealth ) {
190 this.elContainer
191 .querySelectorAll( LpStatsTabOrders.selectors.elPaymentHealthRow )
192 .forEach( ( elRow ) => {
193 const status = elRow.dataset.status;
194 const elCount = elRow.querySelector(
195 '.lp-stats-payment-health__count'
196 );
197
198 if ( elCount ) {
199 elCount.textContent = String( orderHealth[ status ] ?? 0 );
200 }
201 } );
202 }
203
204 renderChart( chartData ) {
205 renderLineChart(
206 LpStatsTabOrders.selectors.elChartCanvas,
207 {
208 labels: chartData.labels || [],
209 datasets: [
210 {
211 label: chartData.line_label || getStatsI18n( 'orders', 'Orders' ),
212 data: chartData.data || [],
213 yAxisID: 'y',
214 },
215 ],
216 xLabel: chartData.x_label || '',
217 granularity: chartData.granularity || '',
218 },
219 { yCurrency: false }
220 );
221 }
222
223 statusLabel( slug ) {
224 const labels = {
225 healthy: getStatsI18n( 'healthy', 'Healthy' ),
226 watch_completion: getStatsI18n(
227 'watchCompletion',
228 'Watch completion'
229 ),
230 high_failed_quizzes: getStatsI18n(
231 'highFailedQuizzes',
232 'High failed quizzes'
233 ),
234 };
235
236 return labels[ slug ] || String( slug || '' );
237 }
238
239 statusBadge( slug ) {
240 if ( 'high_failed_quizzes' === slug ) {
241 return 'red';
242 }
243 if ( 'watch_completion' === slug ) {
244 return 'yellow';
245 }
246
247 return 'green';
248 }
249
250 severityLabel( severity ) {
251 const labels = {
252 high: getStatsI18n( 'high', 'High' ),
253 medium: getStatsI18n( 'medium', 'Medium' ),
254 low: getStatsI18n( 'low', 'Low' ),
255 };
256
257 return labels[ severity ] || String( severity || '' );
258 }
259
260 severityBadge( severity ) {
261 if ( 'high' === severity ) {
262 return 'red';
263 }
264 if ( 'medium' === severity ) {
265 return 'yellow';
266 }
267
268 return 'grey';
269 }
270
271 topSoldColumns() {
272 return [
273 { key: 'name', label: getStatsI18n( 'course', 'Course' ) },
274 {
275 key: 'revenue_formatted',
276 label: getStatsI18n( 'revenue', 'Revenue' ),
277 csv: ( row ) => row.revenue,
278 },
279 { key: 'orders', label: getStatsI18n( 'orders', 'Orders' ) },
280 {
281 key: 'aov_formatted',
282 label: getStatsI18n( 'aovShort', 'AOV' ),
283 csv: ( row ) => row.aov,
284 },
285 {
286 key: 'status_label',
287 label: getStatsI18n( 'status', 'Status' ),
288 format: ( value ) => this.statusLabel( value ),
289 badge: ( row ) => this.statusBadge( row.status_label ),
290 },
291 ];
292 }
293
294 exceptionColumns() {
295 return [
296 {
297 key: 'order_id',
298 label: getStatsI18n( 'orderId', 'Order ID' ),
299 format: ( value, row ) => {
300 if ( ! row.edit_link ) {
301 return value;
302 }
303
304 const link = document.createElement( 'a' );
305 link.href = row.edit_link;
306 link.textContent = `#${ value }`;
307 return link;
308 },
309 csv: ( row ) => row.order_id,
310 },
311 { key: 'student', label: getStatsI18n( 'student', 'Student' ) },
312 { key: 'course', label: getStatsI18n( 'course', 'Course' ) },
313 { key: 'issue', label: getStatsI18n( 'issue', 'Issue' ) },
314 { key: 'date', label: getStatsI18n( 'date', 'Date' ) },
315 {
316 key: 'severity',
317 label: getStatsI18n( 'severity', 'Severity' ),
318 format: ( value ) => this.severityLabel( value ),
319 badge: ( row ) => this.severityBadge( row.severity ),
320 },
321 ];
322 }
323
324 filterExceptions( rows = [] ) {
325 if ( ! [ 'cancelled', 'failed' ].includes( this.orderStatusFilter ) ) {
326 return rows;
327 }
328
329 return rows.filter( ( row ) => row.status === this.orderStatusFilter );
330 }
331
332 renderTables( dashboard ) {
333 this.tables[ 'top-sold-courses' ] = renderDataTable(
334 this.elContainer.querySelector( LpStatsTabOrders.selectors.elTableTopSold ),
335 this.topSoldColumns(),
336 dashboard.top_sold_courses || []
337 );
338
339 const exceptionRows = this.filterExceptions( dashboard.exceptions || [] );
340 const exceptionHandle = renderDataTable(
341 this.elContainer.querySelector(
342 LpStatsTabOrders.selectors.elTableExceptions
343 ),
344 this.exceptionColumns(),
345 exceptionRows,
346 {
347 emptyText: getStatsI18n(
348 'noOrderExceptions',
349 'No failed or cancelled orders in this period.'
350 ),
351 }
352 );
353
354 this.tables.exceptions = {
355 ...exceptionHandle,
356 rows: exceptionRows,
357 allRows: dashboard.exceptions || [],
358 };
359 }
360
361 highlightStatus() {
362 Object.values( LpStatsTabOrders.kpiCards ).forEach( ( selector ) => {
363 const elCard = this.elContainer.querySelector( selector );
364 if ( elCard ) {
365 elCard.classList.remove( 'is-highlighted' );
366 }
367 } );
368
369 const statusMap = {
370 completed: 'completed_orders',
371 processing: 'processing',
372 pending: 'pending',
373 cancelled: 'cancelled_failed',
374 failed: 'cancelled_failed',
375 };
376 const kpiKey = statusMap[ this.orderStatusFilter ];
377 const selector = kpiKey ? LpStatsTabOrders.kpiCards[ kpiKey ] : '';
378 const elCard = selector ? this.elContainer.querySelector( selector ) : null;
379
380 if ( elCard ) {
381 elCard.classList.add( 'is-highlighted' );
382 }
383 }
384
385 viewAllTopSold( args ) {
386 const btn = args.target.closest(
387 LpStatsTabOrders.selectors.elBtnViewAllTopSold
388 );
389 if ( ! btn || ! this.elContainer.contains( btn ) ) {
390 return;
391 }
392
393 lpStatsReportModal.open( {
394 report: 'top_sold_courses',
395 title: getStatsI18n( 'topSoldCourses', 'Top sold courses' ),
396 tableId: 'top-sold-courses',
397 } );
398 }
399
400 viewAllExceptions( args ) {
401 const btn = args.target.closest(
402 LpStatsTabOrders.selectors.elBtnViewAllExceptions
403 );
404 if ( ! btn || ! this.elContainer.contains( btn ) ) {
405 return;
406 }
407
408 // The cancelled/failed deep-link is pushed to the server so pagination
409 // totals match the rows shown ( no more client-side filterExceptions ).
410 lpStatsReportModal.open( {
411 report: 'exceptions',
412 title: getStatsI18n( 'orderExceptions', 'Recent order exceptions' ),
413 tableId: 'exceptions',
414 orderStatus: [ 'cancelled', 'failed' ].includes( this.orderStatusFilter )
415 ? this.orderStatusFilter
416 : '',
417 } );
418 }
419
420 exportTables() {
421 Object.entries( this.tables ).forEach( ( [ tableId, handle ] ) => {
422 if ( handle && handle.rows.length ) {
423 exportCsv(
424 buildCsvFilename( 'orders', tableId ),
425 handle.columns,
426 handle.rows
427 );
428 }
429 } );
430 }
431 }
432
433 export const lpStatsTabOrders = new LpStatsTabOrders();
434