diff --git a/assets/core/ts/components/calendar.ts b/assets/core/ts/components/calendar.ts index e541a334d2..5eed9d54d5 100644 --- a/assets/core/ts/components/calendar.ts +++ b/assets/core/ts/components/calendar.ts @@ -2,6 +2,7 @@ import { __ } from '@wordpress/i18n'; import dayjs from 'dayjs'; import { type Calendar, Calendar as VanillaCalendar, type Options } from 'vanilla-calendar-pro'; +import { TUTOR_CUSTOM_EVENTS } from '@Core/ts/constant'; import { DateFormats } from '@Core/ts/date-formats'; import { type AlpineComponentMeta } from '@Core/ts/types'; @@ -481,7 +482,7 @@ export function calendar({ options, hidePopover }: { options: Options; hidePopov }); }, - navigateWithParams(params: Record) { + navigateWithParams(params: Record, presetKey?: string, presetTitle?: string) { const url = new URL(window.location.href); // Always reset pagination when the date filter changes. @@ -502,6 +503,44 @@ export function calendar({ options, hidePopover }: { options: Options; hidePopov } }); + const isAjax = Boolean((options as Record)?.ajaxMode); + if (isAjax) { + window.history.pushState({}, '', url.toString()); + + const startDate = params[TUTOR_CALENDAR_QUERY_PARAMS.startDate] || ''; + const endDate = params[TUTOR_CALENDAR_QUERY_PARAMS.endDate] || ''; + const date = params[TUTOR_CALENDAR_QUERY_PARAMS.date] || ''; + + if (startDate && endDate) { + this.calendar?.set({ selectedDates: [startDate, endDate] }); + } else if (date) { + this.calendar?.set({ selectedDates: [date] }); + } else { + this.calendar?.set({ selectedDates: [] }); + } + + this.updateActivePreset(); + + const formattedLabel = + presetTitle || + (startDate && endDate ? (startDate === endDate ? startDate : `${startDate} - ${endDate}`) : date || ''); + + window.dispatchEvent( + new CustomEvent(TUTOR_CUSTOM_EVENTS.DATE_FILTER_CHANGED, { + detail: { + startDate, + endDate, + date, + label: formattedLabel, + preset: presetKey || '', + presetTitle: presetTitle || '', + url: url.toString(), + }, + }), + ); + return; + } + window.location.href = url.toString(); }, @@ -553,18 +592,40 @@ export function calendar({ options, hidePopover }: { options: Options; hidePopov applyPreset(preset: Preset) { if (!this.calendar) return; + hidePopover?.(); + const dates = this.getPresetDates(preset); + const presetLabels: Record = { + [PRESETS.ALL_TIME]: __('All Time', 'tutor'), + [PRESETS.YESTERDAY]: __('Yesterday', 'tutor'), + [PRESETS.LAST_7]: __('Last 7 Days', 'tutor'), + [PRESETS.LAST_14]: __('Last 14 Days', 'tutor'), + [PRESETS.LAST_30]: __('Last 30 Days', 'tutor'), + [PRESETS.THIS_MONTH]: __('This Month', 'tutor'), + [PRESETS.LAST_MONTH]: __('Last Month', 'tutor'), + [PRESETS.LAST_YEAR]: __('Last Year', 'tutor'), + }; + + const presetTitle = presetLabels[preset] || ''; if (dates.length) { - this.navigateWithParams({ - [TUTOR_CALENDAR_QUERY_PARAMS.startDate]: dates[0], - [TUTOR_CALENDAR_QUERY_PARAMS.endDate]: dates[1], - }); + this.navigateWithParams( + { + [TUTOR_CALENDAR_QUERY_PARAMS.startDate]: dates[0], + [TUTOR_CALENDAR_QUERY_PARAMS.endDate]: dates[1], + }, + preset, + presetTitle, + ); } else { - this.navigateWithParams({ - [TUTOR_CALENDAR_QUERY_PARAMS.startDate]: null, - [TUTOR_CALENDAR_QUERY_PARAMS.endDate]: null, - }); + this.navigateWithParams( + { + [TUTOR_CALENDAR_QUERY_PARAMS.startDate]: null, + [TUTOR_CALENDAR_QUERY_PARAMS.endDate]: null, + }, + preset, + presetTitle, + ); } }, diff --git a/assets/core/ts/constant.ts b/assets/core/ts/constant.ts index faacca10d1..d2f70ddaaf 100644 --- a/assets/core/ts/constant.ts +++ b/assets/core/ts/constant.ts @@ -18,4 +18,6 @@ export const TUTOR_CUSTOM_EVENTS = { QUIZ_ABANDON_REQUESTED: 'tutor-quiz-abandon-requested', QUIZ_ATTEMPT_COMPLETED: 'tutor-quiz-attempt-completed', CONTENT_CHANGED: 'tutor_content_changed_event', + DATE_FILTER_CHANGED: 'tutor:date-filter-changed', + SORT_CHANGED: 'tutor:sort-changed', }; diff --git a/assets/src/js/frontend/dashboard/index.ts b/assets/src/js/frontend/dashboard/index.ts index 1aefede2f4..da971fcd1b 100644 --- a/assets/src/js/frontend/dashboard/index.ts +++ b/assets/src/js/frontend/dashboard/index.ts @@ -8,6 +8,7 @@ import { initializeSiteShell } from '@FrontendServices/site-shell'; import { initializeConfetti } from './confetti'; import { initializeHeader } from './header'; +import { initializeLazySection } from './lazy-section'; import { initializeAnnouncements } from './pages/announcements'; import { initBillingCsvExport } from './pages/billing'; import { initializeDiscussions } from './pages/discussions'; @@ -79,6 +80,7 @@ const initializeDashboard = () => { initializeHeader(); initializeCommon(); initializeTour(); + initializeLazySection(); const currentPage = getCurrentPage(); diff --git a/assets/src/js/frontend/dashboard/lazy-section.ts b/assets/src/js/frontend/dashboard/lazy-section.ts new file mode 100644 index 0000000000..24bcf1be81 --- /dev/null +++ b/assets/src/js/frontend/dashboard/lazy-section.ts @@ -0,0 +1,278 @@ +import { __ } from '@wordpress/i18n'; + +import { TUTOR_CUSTOM_EVENTS } from '@Core/ts/constant'; +import { type QueryState } from '@Core/ts/services/Query'; +import { type AjaxResponse } from '@Core/ts/types'; + +export interface LazySectionProps { + section: string; + dateDependent?: boolean; + sortDependent?: boolean; + type?: string; +} + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | { [key: string]: JsonValue } | JsonValue[]; + +export interface SectionResponseData { + html?: string; + chart_data?: Record; + has_data?: boolean; +} + +export interface LazySectionRefs { + contentContainer?: HTMLElement; + [key: string]: HTMLElement | undefined; +} + +export interface AlpineMagics< + TRefs extends Record = Record, +> { + $el: HTMLElement; + $refs: TRefs; + $nextTick: (callback: () => void) => void; + $dispatch: (event: string, detail?: JsonValue) => void; + $watch: (property: string | (() => T), callback: (value: T, oldValue?: T) => void) => void; +} + +export interface LazySectionState { + section: string; + dateDependent: boolean; + sortDependent: boolean; + startDate: string; + endDate: string; + sortType: string; + lastFetchedKey: string; + query: QueryState> | null; + dateChangeHandler: ((e: Event) => void) | null; + sortChangeHandler: ((e: Event) => void) | null; + watch?: (name: string) => boolean | undefined; + get isVisible(): boolean; + get isLoading(): boolean; + get hasError(): boolean; + get hasData(): boolean; + get content(): string; +} + +export interface LazySectionMethods { + init(this: LazySectionContext): void; + destroy(this: LazySectionContext): void; + fetchSection(this: LazySectionContext): void; + checkAndFetch(this: LazySectionContext): void; + reinitTargetTree(this: LazySectionContext): void; +} + +export type LazySectionComponent = LazySectionState & LazySectionMethods; + +export type LazySectionContext = LazySectionState & LazySectionMethods & AlpineMagics; + +/** + * Type guard for checking if data conforms to SectionResponseData + */ +function isSectionResponseData(data: unknown): data is SectionResponseData { + return typeof data === 'object' && data !== null && 'html' in data; +} + +export const lazySection = ({ + section, + dateDependent = false, + sortDependent = false, + type = 'revenue', +}: LazySectionProps): LazySectionComponent => { + const { wpPost } = window.TutorCore.api; + const { toast } = window.TutorCore; + const { convertToErrorMessage } = window.TutorCore.error; + + return { + section, + dateDependent, + sortDependent, + startDate: '', + endDate: '', + sortType: type, + lastFetchedKey: '', + query: null, + dateChangeHandler: null, + sortChangeHandler: null, + + get isVisible(): boolean { + if (typeof this.watch === 'function') { + return !!this.watch(this.section); + } + return true; + }, + + get isLoading(): boolean { + if (!this.isVisible) return false; + return this.query ? this.query.isLoading || this.query.isFetching : true; + }, + + get hasError(): boolean { + return !!this.query?.error; + }, + + get hasData(): boolean { + const res = this.query?.data; + if (!res) return false; + if ('data' in res && res.data) return res.data.has_data !== false; + if (isSectionResponseData(res)) return res.has_data !== false; + return true; + }, + + get content(): string { + const res = this.query?.data; + if (!res) return ''; + if ('data' in res && res.data) return res.data.html || ''; + if (isSectionResponseData(res)) return res.html || ''; + return ''; + }, + + reinitTargetTree(this: LazySectionContext) { + this.$nextTick(() => { + const target = this.$refs.contentContainer || this.$el; + if (target && window.Alpine) { + window.Alpine.initTree(target); + } + }); + }, + + checkAndFetch(this: LazySectionContext) { + if (!this.isVisible) { + return; + } + + const currentKey = `${this.startDate}_${this.endDate}_${this.sortType}`; + if (this.lastFetchedKey !== currentKey || !this.query?.data) { + this.lastFetchedKey = currentKey; + this.fetchSection(); + } + }, + + init(this: LazySectionContext) { + const params = new URLSearchParams(window.location.search); + this.startDate = params.get('start_date') || ''; + this.endDate = params.get('end_date') || ''; + this.sortType = params.get('top_performing_course') || params.get('type') || type; + + // Watch for content updates from QueryService and re-initialize Alpine child tree + this.$watch('content', (newContent: string) => { + if (newContent) { + this.reinitTargetTree(); + } + }); + + // Watch for query errors and display toast notifications + this.$watch('hasError', (isErr: boolean) => { + if (isErr && this.query?.error) { + const errorMessage = convertToErrorMessage(this.query.error); + toast.error(errorMessage || __('Failed to load section data.', 'tutor')); + } + }); + + // Watch visibility changes (e.g. toggled in Customize View popover) + this.$watch( + () => this.isVisible, + (visible?: boolean) => { + if (visible) { + this.checkAndFetch(); + } + }, + ); + + // Only fetch on initial load if the section is currently visible + if (this.isVisible) { + this.checkAndFetch(); + } + + if (this.dateDependent) { + this.dateChangeHandler = (e: Event) => { + const detail = + e instanceof CustomEvent ? (e.detail as { startDate?: string; endDate?: string } | undefined) : undefined; + const newStart = detail?.startDate || ''; + const newEnd = detail?.endDate || ''; + + // Guard: Avoid refetch if the filter dates have not changed + if (this.startDate === newStart && this.endDate === newEnd) { + return; + } + + this.startDate = newStart; + this.endDate = newEnd; + + // If currently visible, fetch immediately. If hidden, checkAndFetch() will run when made visible. + if (this.isVisible) { + this.checkAndFetch(); + } + }; + window.addEventListener(TUTOR_CUSTOM_EVENTS.DATE_FILTER_CHANGED, this.dateChangeHandler); + } + + if (this.sortDependent) { + this.sortChangeHandler = (e: Event) => { + const detail = e instanceof CustomEvent ? (e.detail as { type?: string } | undefined) : undefined; + const newType = detail?.type || type; + + // Guard: Avoid refetch if sort type has not changed + if (this.sortType === newType) { + return; + } + + this.sortType = newType; + + // If currently visible, fetch immediately. If hidden, checkAndFetch() will run when made visible. + if (this.isVisible) { + this.checkAndFetch(); + } + }; + window.addEventListener(TUTOR_CUSTOM_EVENTS.SORT_CHANGED, this.sortChangeHandler); + } + }, + + destroy(this: LazySectionContext) { + if (this.dateChangeHandler) { + window.removeEventListener(TUTOR_CUSTOM_EVENTS.DATE_FILTER_CHANGED, this.dateChangeHandler); + } + if (this.sortChangeHandler) { + window.removeEventListener(TUTOR_CUSTOM_EVENTS.SORT_CHANGED, this.sortChangeHandler); + } + }, + + fetchSection(this: LazySectionContext) { + const queryKey = ['dashboard-section', this.section, this.startDate, this.endDate, this.sortType]; + const queryService = window.TutorCore.query; + + if (queryService) { + this.query = queryService.useQuery>( + queryKey, + () => + wpPost>('tutor_get_dashboard_section', { + section: this.section, + start_date: this.startDate, + end_date: this.endDate, + top_performing_course: this.sortType, + type: this.sortType, + }), + { staleTime: 5 * 60 * 1000 }, + ); + + // If data is already available from fresh cache, trigger tree init immediately + if (this.query.data) { + this.reinitTargetTree(); + } + } + }, + }; +}; + +export const initializeLazySection = () => { + if (window.TutorComponentRegistry) { + window.TutorComponentRegistry.register({ + type: 'component', + meta: { + name: 'lazySection', + component: lazySection, + }, + }); + window.TutorComponentRegistry.initWithAlpine(window.Alpine); + } +}; diff --git a/assets/src/js/frontend/dashboard/pages/instructor/sort-sections.ts b/assets/src/js/frontend/dashboard/pages/instructor/sort-sections.ts index 5a7ddb7e35..9b733f83d2 100644 --- a/assets/src/js/frontend/dashboard/pages/instructor/sort-sections.ts +++ b/assets/src/js/frontend/dashboard/pages/instructor/sort-sections.ts @@ -164,7 +164,8 @@ export const sortSections = (sectionsIds: string[]) => { const existingOrder = Array.from( new Set( - Array.from(parentContainer.querySelectorAll('[data-section-id]')) + Array.from(parentContainer.children) + .filter((el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute('data-section-id')) .map((el) => el.dataset.sectionId) .filter((id): id is string => id !== undefined), ), @@ -174,17 +175,12 @@ export const sortSections = (sectionsIds: string[]) => { return; } - const fragment = document.createDocumentFragment(); - for (const id of newOrder) { const section = sectionMap[id]; if (section) { - section.remove(); - fragment.appendChild(section); + parentContainer.appendChild(section); } } - - parentContainer.appendChild(fragment); }, getOrder() { @@ -200,9 +196,17 @@ export const sortSections = (sectionsIds: string[]) => { }, getSortableSections() { - const sections = document.querySelectorAll('[data-section-id]'); + const firstSection = document.querySelector('[data-section-id]'); + const parentContainer = firstSection?.parentElement; + if (!parentContainer) { + return {}; + } + + const sections = Array.from(parentContainer.children).filter( + (el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute('data-section-id'), + ); - return Array.from(sections).reduce( + return sections.reduce( (acc, section) => { const sectionId = section.dataset.sectionId; if (sectionId) { diff --git a/classes/Dashboard.php b/classes/Dashboard.php index 0601397703..193874e6ab 100644 --- a/classes/Dashboard.php +++ b/classes/Dashboard.php @@ -48,7 +48,8 @@ class Dashboard { * @return void */ public function __construct() { - add_action( 'tutor_load_template_after', array( $this, 'tutor_load_template_after' ), 10, 2 ); + new DashboardSectionManager(); + add_action( 'tutor_load_template_after', array( $this, 'tutor_load_template_after' ) ); add_filter( 'should_tutor_load_template', array( $this, 'should_tutor_load_template' ), 10, 2 ); add_action( 'template_redirect', array( $this, 'redirect_old_dashboard_pages' ) ); add_filter( 'tutor_dashboard_back_url', array( $this, 'filter_dashboard_back_url' ) ); diff --git a/classes/DashboardSectionManager.php b/classes/DashboardSectionManager.php new file mode 100644 index 0000000000..61ee017f9d --- /dev/null +++ b/classes/DashboardSectionManager.php @@ -0,0 +1,532 @@ + + * @link https://themeum.com + * @since 4.0.8 + */ + +namespace TUTOR; + +defined( 'ABSPATH' ) || exit; + +use TUTOR\InstructorMetricsAdapter; +use Tutor\Traits\JsonResponse; + +/** + * Class DashboardSectionManager + * + * @since 4.0.8 + */ +class DashboardSectionManager { + use JsonResponse; + + /** + * Constructor. + * + * @since 4.0.8 + */ + public function __construct() { + add_action( 'wp_ajax_tutor_get_dashboard_section', array( $this, 'ajax_get_dashboard_section' ) ); + } + + /** + * Get all registered dashboard and analytics sections metadata. + * + * @since 4.0.8 + * + * @return array + */ + public static function get_registered_sections(): array { + $sections = array( + 'current_stats' => array( + 'title' => __( 'Current Stats', 'tutor' ), + 'date_dependent' => true, + ), + 'overview_chart' => array( + 'title' => __( 'Earnings Over Time', 'tutor' ), + 'date_dependent' => true, + ), + 'course_completion_and_leader' => array( + 'title' => __( 'Course Completion Rate', 'tutor' ), + 'date_dependent' => false, + ), + 'top_performing_courses' => array( + 'title' => __( 'Top Performing Courses', 'tutor' ), + 'date_dependent' => true, + 'sort_dependent' => true, + ), + 'upcoming_tasks_and_activity' => array( + 'title' => __( 'Upcoming Tasks', 'tutor' ), + 'date_dependent' => false, + ), + 'recent_reviews' => array( + 'title' => __( 'Recent Student Reviews', 'tutor' ), + 'date_dependent' => true, + ), + ); + + return apply_filters( 'tutor_dashboard_sections', $sections ); + } + + /** + * Get raw normalized domain data for a section via the Adapter. + * + * @since 4.0.8 + * + * @param string $section_id Section identifier. + * @param array $params Context parameters (user_id, start_date, end_date, type, limit, etc.). + * + * @return mixed + */ + public static function get_section_data( string $section_id, array $params = array() ) { + $user_id = isset( $params['user_id'] ) ? (int) $params['user_id'] : get_current_user_id(); + $start_date = ! empty( $params['start_date'] ) ? sanitize_text_field( $params['start_date'] ) : ''; + $end_date = ! empty( $params['end_date'] ) ? sanitize_text_field( $params['end_date'] ) : ''; + $type = ! empty( $params['type'] ) ? sanitize_text_field( $params['type'] ) : 'revenue'; + $type = in_array( $type, array( 'revenue', 'student' ), true ) ? $type : 'revenue'; + $limit = isset( $params['limit'] ) ? (int) $params['limit'] : 3; + + switch ( $section_id ) { + case 'current_stats': + return InstructorMetricsAdapter::get_stat_cards( $user_id, $start_date, $end_date ); + + case 'overview_chart': + return InstructorMetricsAdapter::get_overview_chart_data( $user_id, $start_date, $end_date ); + + case 'course_completion_and_leader': + return InstructorMetricsAdapter::get_course_completion_distribution( $user_id ); + + case 'top_performing_courses': + return InstructorMetricsAdapter::get_top_performing_courses( $user_id, $type, $start_date, $end_date ); + + case 'upcoming_tasks_and_activity': + return InstructorMetricsAdapter::get_upcoming_tasks( $user_id ); + + case 'recent_reviews': + return InstructorMetricsAdapter::get_recent_reviews( $user_id, $limit, $start_date, $end_date ); + + default: + return apply_filters( 'tutor_dashboard_section_data', null, $section_id, $params ); + } + } + + /** + * Get rendered HTML partial for a given section. + * + * @since 4.0.8 + * + * @param string $section_id Section identifier. + * @param array $params Context parameters. + * + * @return string + */ + public static function get_section_html( string $section_id, array $params = array() ): string { + $result = self::render_section( $section_id, $params ); + return $result['html'] ?? ''; + } + + /** + * Render a dashboard section, returning HTML, chart_data, and status payload. + * + * @since 4.0.8 + * + * @param string $section_id Section identifier. + * @param array $params Context parameters. + * + * @return array + */ + public static function render_section( string $section_id, array $params = array() ): array { + $registered = self::get_registered_sections(); + if ( ! isset( $registered[ $section_id ] ) ) { + return array( + 'html' => '', + 'has_data' => false, + ); + } + + $params['user_id'] = $params['user_id'] ?? get_current_user_id(); + + // Custom filter hook for third-party or Pro sections. + $custom_render = apply_filters( 'tutor_dashboard_section_render', null, $section_id, $params ); + if ( ! is_null( $custom_render ) ) { + if ( is_array( $custom_render ) ) { + return $custom_render; + } + return array( + 'html' => (string) $custom_render, + 'has_data' => ! empty( $custom_render ), + ); + } + + // If registered section has a custom callback (e.g. legacy/Pro registrations). + if ( isset( $registered[ $section_id ]['callback'] ) && is_callable( $registered[ $section_id ]['callback'] ) ) { + return call_user_func( $registered[ $section_id ]['callback'], $params ); + } + + $data = self::get_section_data( $section_id, $params ); + + switch ( $section_id ) { + case 'current_stats': + return self::render_current_stats( $data, $params ); + + case 'overview_chart': + return self::render_overview_chart( $data ); + + case 'course_completion_and_leader': + return self::render_course_completion( $data ); + + case 'top_performing_courses': + return self::render_top_performing_courses( $data, $params ); + + case 'upcoming_tasks_and_activity': + return self::render_upcoming_tasks( $data ); + + case 'recent_reviews': + return self::render_recent_reviews( $data ); + + default: + return array( + 'html' => '', + 'has_data' => ! empty( $data ), + ); + } + } + + /** + * Handle AJAX request to lazyload a dashboard section. + * + * @since 4.0.8 + * + * @return void + */ + public function ajax_get_dashboard_section() { + tutor_utils()->check_nonce(); + + if ( ! User::is_admin() && ! tutor_utils()->is_instructor() ) { + $this->response_bad_request( tutor_utils()->error_message() ); + } + + $section_id = Input::post( 'section', '', Input::TYPE_STRING ); + if ( empty( $section_id ) ) { + $this->response_bad_request( __( 'Section identifier is required.', 'tutor' ) ); + } + + $registered = self::get_registered_sections(); + if ( ! isset( $registered[ $section_id ] ) ) { + $this->response_bad_request( __( 'Invalid dashboard section.', 'tutor' ) ); + } + + $user_id = get_current_user_id(); + $start_date = Input::post( 'start_date', '', Input::TYPE_STRING ); + $end_date = Input::post( 'end_date', '', Input::TYPE_STRING ); + $type = Input::post( 'top_performing_course', Input::post( 'type', 'revenue', Input::TYPE_STRING ), Input::TYPE_STRING ); + $type = in_array( $type, array( 'revenue', 'student' ), true ) ? $type : 'revenue'; + + $start_date = $start_date ? tutor_get_formated_date( 'Y-m-d', $start_date ) : ''; + $end_date = $end_date ? tutor_get_formated_date( 'Y-m-d', $end_date ) : ''; + + $params = array( + 'user_id' => $user_id, + 'start_date' => $start_date, + 'end_date' => $end_date, + 'top_performing_course' => $type, + 'type' => $type, + ); + + $result = self::render_section( $section_id, $params ); + $this->response_data( $result ); + } + + /* + ========================================================================= + SECTION VIEW RENDERERS + ========================================================================= + */ + + /** + * Render Current Stats View. + * + * @since 4.0.8 + * + * @param array $data Stats cards from adapter. + * @param array $params Context parameters. + * + * @return array + */ + protected static function render_current_stats( $data, array $params ): array { + $start_date = ! empty( $params['start_date'] ) ? sanitize_text_field( $params['start_date'] ) : ''; + $end_date = ! empty( $params['end_date'] ) ? sanitize_text_field( $params['end_date'] ) : ''; + $cards = is_array( $data ) ? $data : array(); + + ob_start(); + ?> +
+ +
+ $card['variation'] ?? 'enrolled', + 'card_title' => $card['title'] ?? '', + 'icon' => $card['icon'] ?? '', + 'icon_size' => $card['icon_size'] ?? 20, + 'value' => $card['value'] ?? '', + 'content' => $card['content'] ?? '', + 'hover_content' => $card['hover_content'] ?? array(), + 'start_date' => $start_date, + 'end_date' => $end_date, + ) + ); + ?> +
+ +
+ $html, + 'has_data' => ! empty( $cards ), + ); + } + + /** + * Render Overview Chart View. + * + * @since 4.0.8 + * + * @param array $data Chart data from adapter. + * + * @return array + */ + protected static function render_overview_chart( $data ): array { + if ( empty( $data ) ) { + return array( + 'html' => '', + 'has_data' => false, + ); + } + + ob_start(); + tutor_load_template( + 'dashboard.instructor.home.overview-chart', + array( + 'overview_chart_data' => $data, + ) + ); + $html = ob_get_clean(); + + return array( + 'html' => $html, + 'chart_data' => $data, + 'has_data' => true, + ); + } + + /** + * Render Course Completion View. + * + * @since 4.0.8 + * + * @param array $data Distribution data from adapter. + * + * @return array + */ + protected static function render_course_completion( $data ): array { + if ( empty( $data ) ) { + return array( + 'html' => '', + 'has_data' => false, + ); + } + + ob_start(); + ?> +
+ $data, + ) + ); + ?> +
+ $html, + 'chart_data' => $data, + 'has_data' => true, + ); + } + + /** + * Render Top Performing Courses View. + * + * @since 4.0.8 + * + * @param array $data Top courses data from adapter. + * @param array $params Context parameters. + * + * @return array + */ + protected static function render_top_performing_courses( $data, array $params ): array { + $type = $params['top_performing_course'] ?? $params['type'] ?? 'revenue'; + $type = sanitize_text_field( $type ); + $type = in_array( $type, array( 'revenue', 'student' ), true ) ? $type : 'revenue'; + $top_courses = is_array( $data ) ? $data : array(); + + if ( empty( $top_courses ) ) { + return array( + 'html' => '', + 'has_data' => false, + ); + } + + ob_start(); + ?> +
+
+
+ +
+ + array( + 'revenue' => __( 'Revenue', 'tutor' ), + 'student' => __( 'Student', 'tutor' ), + ), + 'selected' => $type, + ); + tutor_load_template( + 'dashboard.instructor.home.top-performing-course-filter', + $filter_data + ); + ?> +
+ +
+ $item ) : ?> + $item_key, + 'item' => $item, + ) + ); + ?> + +
+
+ $html, + 'has_data' => true, + ); + } + + /** + * Render Upcoming Tasks View. + * + * @since 4.0.8 + * + * @param array $data Tasks data from adapter. + * + * @return array + */ + protected static function render_upcoming_tasks( $data ): array { + $tasks = is_array( $data ) ? $data : array(); + if ( empty( $tasks ) ) { + return array( + 'html' => '', + 'has_data' => false, + ); + } + + ob_start(); + ?> +
+
+
+ +
+ +
+ + $item ) + ); + ?> + +
+
+
+ $html, + 'has_data' => true, + ); + } + + /** + * Render Recent Reviews View. + * + * @since 4.0.8 + * + * @param array $data Reviews data from adapter. + * + * @return array + */ + protected static function render_recent_reviews( $data ): array { + $reviews = is_array( $data ) ? $data : array(); + if ( empty( $reviews ) ) { + return array( + 'html' => '', + 'has_data' => false, + ); + } + + ob_start(); + ?> +
+
+ +
+ +
+ + $review ) + ); + ?> + +
+
+ $html, + 'has_data' => true, + ); + } +} diff --git a/classes/Instructor.php b/classes/Instructor.php index e5b1700d65..557f93a528 100644 --- a/classes/Instructor.php +++ b/classes/Instructor.php @@ -53,7 +53,7 @@ class Instructor { /** * Error message * - * @var string + * @var array|string */ protected $error_msgs = ''; @@ -107,7 +107,7 @@ public function __construct( $register_hook = true ) { * For Register new user and mark him as instructor * * @since 1.0.0 - * @return void|null + * @return void */ public function register_instructor() { // Here tutor_action checking required before nonce checking. @@ -237,7 +237,7 @@ public function tutor_instructor_form_validation_errors() { * for instructor applying when a user already logged in * * @since 1.0.0 - * @return void|null + * @return void */ public function apply_instructor() { // Here tutor_action checking required before nonce checking. @@ -1069,6 +1069,7 @@ public static function get_stat_card_details( float $current_data, float $previo 'percentage' => '', 'icon' => Icon::MINUS, 'class' => 'tutor-text-primary', + 'icon_class' => '', ); } diff --git a/classes/InstructorMetricsAdapter.php b/classes/InstructorMetricsAdapter.php new file mode 100644 index 0000000000..7aff9d3df0 --- /dev/null +++ b/classes/InstructorMetricsAdapter.php @@ -0,0 +1,382 @@ + + * @link https://themeum.com + * @since 4.0.8 + */ + +namespace TUTOR; + +defined( 'ABSPATH' ) || exit; + +use TUTOR_REPORT\Analytics; +use Tutor\Models\CourseModel; +use Tutor\Models\WithdrawModel; + +/** + * Class InstructorMetricsAdapter + * + * @since 4.0.8 + */ +class InstructorMetricsAdapter { + + /** + * Date range array helper. + * + * @since 4.0.8 + * + * @param string $from Start date. + * @param string $to End date. + * + * @return array + */ + public static function date_range( string $from, string $to ): array { + return array( + 'from' => sanitize_text_field( $from ), + 'to' => sanitize_text_field( $to ), + ); + } + + /** + * Get total earnings for an instructor within a date range. + * + * @since 4.0.8 + * + * @param int $user_id Instructor user ID. + * @param string $start_date Start date (Y-m-d). + * @param string $end_date End date (Y-m-d). + * + * @return float + */ + public static function get_total_earnings( int $user_id, string $start_date = '', string $end_date = '' ): float { + $start_date = sanitize_text_field( $start_date ); + $end_date = sanitize_text_field( $end_date ); + $tutor_pro_enabled = tutor_utils()->is_plugin_active( 'tutor-pro/tutor-pro.php' ); + $is_pro_reports = $tutor_pro_enabled && tutor_utils()->is_addon_enabled( 'tutor-report' ); + + if ( $is_pro_reports && class_exists( 'TUTOR_REPORT\Analytics' ) ) { + $earnings = Analytics::get_earnings_by_user( $user_id, '', $start_date, $end_date ); + $total = (float) ( $earnings['total_earnings'] ?? 0 ); + } else { + $date_arg = ( ! empty( $start_date ) && ! empty( $end_date ) ) ? self::date_range( $start_date, $end_date ) : null; + $summary = WithdrawModel::get_withdraw_summary( $user_id, $date_arg ); + $total = (float) ( $summary->total_income ?? 0 ); + } + + return apply_filters( 'tutor_instructor_metrics_total_earnings', $total, $user_id, $start_date, $end_date ); + } + + /** + * Get total courses count for an instructor. + * + * @since 4.0.8 + * + * @param int $user_id Instructor user ID. + * @param string $start_date Start date (Y-m-d). + * @param string $end_date End date (Y-m-d). + * + * @return int + */ + public static function get_total_courses( int $user_id, string $start_date = '', string $end_date = '' ): int { + $start_date = sanitize_text_field( $start_date ); + $end_date = sanitize_text_field( $end_date ); + + if ( empty( $start_date ) && empty( $end_date ) ) { + return (int) CourseModel::get_courses_by_instructor( $user_id, array( 'publish', 'private' ), 0, PHP_INT_MAX, true ); + } + return (int) CourseModel::get_course_count_by_date( $start_date, $end_date, $user_id ); + } + + /** + * Get total students for an instructor. + * + * @since 4.0.8 + * + * @param int $user_id Instructor user ID. + * @param string $start_date Start date (Y-m-d). + * @param string $end_date End date (Y-m-d). + * + * @return int + */ + public static function get_total_students( int $user_id, string $start_date = '', string $end_date = '' ): int { + $start_date = sanitize_text_field( $start_date ); + $end_date = sanitize_text_field( $end_date ); + $date_arg = ( ! empty( $start_date ) && ! empty( $end_date ) ) ? self::date_range( $start_date, $end_date ) : array(); + + return (int) tutor_utils()->get_total_students_by_instructor( $user_id, $date_arg ); + } + + /** + * Get instructor average rating and review counts. + * + * @since 4.0.8 + * + * @param int $user_id Instructor user ID. + * @param string $start_date Start date (Y-m-d). + * @param string $end_date End date (Y-m-d). + * + * @return object + */ + public static function get_instructor_ratings( int $user_id, string $start_date = '', string $end_date = '' ): object { + $start_date = sanitize_text_field( $start_date ); + $end_date = sanitize_text_field( $end_date ); + $date_arg = ( ! empty( $start_date ) && ! empty( $end_date ) ) ? self::date_range( $start_date, $end_date ) : array(); + $ratings = tutor_utils()->get_instructor_ratings( $user_id, $date_arg ); + + if ( ! is_object( $ratings ) ) { + $ratings = (object) array( + 'rating_avg' => 0, + 'rating_count' => 0, + ); + } + return $ratings; + } + + /** + * Adapt and format Current Stats cards with comparison data. + * + * @since 4.0.8 + * + * @param int $user_id Instructor user ID. + * @param string $start_date Start date (Y-m-d). + * @param string $end_date End date (Y-m-d). + * + * @return array + */ + public static function get_stat_cards( int $user_id, string $start_date = '', string $end_date = '' ): array { + $start_date = sanitize_text_field( $start_date ); + $end_date = sanitize_text_field( $end_date ); + $is_all_time = empty( $start_date ) && empty( $end_date ); + $previous_dates = $is_all_time ? array() : Instructor::get_comparison_date_range( $start_date, $end_date ); + + $total_earnings = self::get_total_earnings( $user_id, $start_date, $end_date ); + $total_courses = self::get_total_courses( $user_id, $start_date, $end_date ); + $total_students = self::get_total_students( $user_id, $start_date, $end_date ); + $total_ratings = self::get_instructor_ratings( $user_id, $start_date, $end_date ); + + $total_earnings_details = array(); + $total_courses_details = array(); + $total_students_details = array(); + $total_ratings_details = array(); + + $tutor_pro_enabled = tutor_utils()->is_plugin_active( 'tutor-pro/tutor-pro.php' ); + + if ( $tutor_pro_enabled && ! $is_all_time && ! empty( $previous_dates ) ) { + $prev_start = $previous_dates['previous_start_date'] ?? ''; + $prev_end = $previous_dates['previous_end_date'] ?? ''; + + $prev_earnings = self::get_total_earnings( $user_id, $prev_start, $prev_end ); + $prev_courses = self::get_total_courses( $user_id, $prev_start, $prev_end ); + $prev_students = self::get_total_students( $user_id, $prev_start, $prev_end ); + $prev_ratings = self::get_instructor_ratings( $user_id, $prev_start, $prev_end ); + + $stat = function ( $current, $previous ) use ( $previous_dates ) { + return array_merge( $previous_dates, Instructor::get_stat_card_details( (float) $current, (float) $previous ) ); + }; + + $total_earnings_details = $stat( $total_earnings, $prev_earnings ); + $total_courses_details = $stat( $total_courses, $prev_courses ); + $total_students_details = $stat( $total_students, $prev_students ); + $total_ratings_details = $stat( $total_ratings->rating_avg, $prev_ratings->rating_avg ); + } + + return array( + array( + 'variation' => 'brand', + 'title' => esc_html__( 'Total Earnings', 'tutor' ), + 'icon' => Icon::EARNING, + 'value' => tutor_utils()->tutor_price( $total_earnings ), + 'hover_content' => $total_earnings_details, + ), + array( + 'variation' => 'exception1', + 'title' => esc_html__( 'Total Courses', 'tutor' ), + 'icon' => Icon::COURSES, + 'value' => $total_courses, + 'hover_content' => $total_courses_details, + ), + array( + 'variation' => 'exception5', + 'title' => esc_html__( 'Total Students', 'tutor' ), + 'icon' => Icon::PASSED, + 'value' => $total_students, + 'hover_content' => $total_students_details, + ), + array( + 'variation' => 'exception4', + 'title' => esc_html__( 'Avg. Rating', 'tutor' ), + 'icon' => Icon::STAR_LINE, + 'value' => $total_ratings->rating_avg, + 'hover_content' => $total_ratings_details, + ), + ); + } + + /** + * Adapt Overview Chart data. + * + * @since 4.0.8 + * + * @param int $user_id Instructor user ID. + * @param string $start_date Start date (Y-m-d). + * @param string $end_date End date (Y-m-d). + * + * @return array + */ + public static function get_overview_chart_data( int $user_id, string $start_date = '', string $end_date = '' ): array { + $start_date = sanitize_text_field( $start_date ); + $end_date = sanitize_text_field( $end_date ); + $tutor_pro_enabled = tutor_utils()->is_plugin_active( 'tutor-pro/tutor-pro.php' ); + $is_pro_reports = $tutor_pro_enabled && tutor_utils()->is_addon_enabled( 'tutor-report' ); + + if ( ! $is_pro_reports || ! class_exists( 'TUTOR_REPORT\Analytics' ) ) { + return array(); + } + + $earnings = Analytics::get_earnings_by_user( $user_id, '', $start_date, $end_date ); + $enrollments = Analytics::get_total_students_by_user( $user_id, '', $start_date, $end_date ); + + $overview_chart_data = array( + 'earnings' => array( 0 ), + 'enrolled' => array( 0 ), + 'labels' => array( '' ), + 'currency' => tutor_utils()->get_monetization_currency_config(), + 'enrollment_date' => array( '' ), + 'earning_date' => array( '' ), + ); + + foreach ( ( $earnings['earnings'] ?? array() ) as $item ) { + $overview_chart_data['earnings'][] = (float) ( $item->total ?? 0 ); + $overview_chart_data['labels'][] = $item->label_name ?? ''; + $overview_chart_data['earning_date'][] = ! empty( $item->date_format ) ? wp_date( 'M d', strtotime( $item->date_format ) ) : ''; + } + + foreach ( ( $enrollments['enrollments'] ?? array() ) as $item ) { + $overview_chart_data['enrolled'][] = (float) ( $item->total ?? 0 ); + $overview_chart_data['enrollment_date'][] = ! empty( $item->date_format ) ? wp_date( 'M d', strtotime( $item->date_format ) ) : ''; + } + + $overview_chart_data['earnings'][] = 0; + $overview_chart_data['enrolled'][] = 0; + $overview_chart_data['labels'][] = ''; + $overview_chart_data['earning_date'][] = ''; + $overview_chart_data['enrollment_date'][] = ''; + + return $overview_chart_data; + } + + /** + * Adapt Course Completion Distribution data. + * + * @since 4.0.8 + * + * @param int $user_id Instructor user ID. + * + * @return array + */ + public static function get_course_completion_distribution( int $user_id ): array { + $distribution = Instructor::get_course_completion_distribution_data_by_instructor( $user_id ); + + return array( + 'enrolled' => array( + 'label' => esc_html__( 'Enrolled', 'tutor' ), + 'value' => $distribution['enrolled'] ?? 0, + ), + 'completed' => array( + 'label' => esc_html__( 'Completed', 'tutor' ), + 'value' => $distribution['completed'] ?? 0, + ), + 'in_progress' => array( + 'label' => esc_html__( 'In Progress', 'tutor' ), + 'value' => $distribution['inprogress'] ?? 0, + ), + 'inactive' => array( + 'label' => esc_html__( 'Inactive', 'tutor' ), + 'value' => $distribution['inactive'] ?? 0, + ), + 'cancelled' => array( + 'label' => esc_html__( 'Cancelled', 'tutor' ), + 'value' => $distribution['cancelled'] ?? 0, + ), + ); + } + + /** + * Adapt Top Performing Courses data. + * + * @since 4.0.8 + * + * @param int $user_id Instructor user ID. + * @param string $type Sort type ('revenue' or 'student'). + * @param string $start_date Start date (Y-m-d). + * @param string $end_date End date (Y-m-d). + * + * @return array + */ + public static function get_top_performing_courses( int $user_id, string $type = 'revenue', string $start_date = '', string $end_date = '' ): array { + $type = sanitize_text_field( $type ); + $start_date = sanitize_text_field( $start_date ); + $end_date = sanitize_text_field( $end_date ); + + $args = array( + 'start_date' => $start_date, + 'end_date' => $end_date, + 'order_by' => in_array( $type, array( 'revenue', 'student' ), true ) ? $type : 'revenue', + ); + + return Instructor::format_instructor_top_performing_courses( + Instructor::get_top_performing_courses_by_instructor( $user_id, $args ) + ); + } + + /** + * Adapt Upcoming Tasks data. + * + * @since 4.0.8 + * + * @param int $user_id Instructor user ID. + * + * @return array + */ + public static function get_upcoming_tasks( int $user_id ): array { + $tutor_pro_enabled = tutor_utils()->is_plugin_active( 'tutor-pro/tutor-pro.php' ); + if ( ! $tutor_pro_enabled ) { + return array(); + } + + return Instructor::format_instructor_upcoming_live_tasks( + Instructor::get_instructor_upcoming_live_tasks( $user_id ) + ); + } + + /** + * Adapt Recent Reviews data. + * + * @since 4.0.8 + * + * @param int $user_id Instructor user ID. + * @param int $limit Max count. + * @param string $start_date Start date (Y-m-d). + * @param string $end_date End date (Y-m-d). + * + * @return array + */ + public static function get_recent_reviews( int $user_id, int $limit = 3, string $start_date = '', string $end_date = '' ): array { + $start_date = sanitize_text_field( $start_date ); + $end_date = sanitize_text_field( $end_date ); + $review_args = array( 'comment_approved' => 'approved' ); + + if ( ! empty( $start_date ) && ! empty( $end_date ) ) { + $review_args = self::date_range( $start_date, $end_date ); + } + + $reviews = tutor_utils()->get_reviews_by_instructor( $user_id, 0, $limit, '', '', $review_args ); + return Instructor::format_instructor_recent_reviews( $reviews->results ?? array() ); + } +} diff --git a/components/DateFilter.php b/components/DateFilter.php index 143ea8dfdd..267078fdbd 100644 --- a/components/DateFilter.php +++ b/components/DateFilter.php @@ -163,6 +163,29 @@ class DateFilter extends BaseComponent { */ protected $clear_params = array(); + /** + * Whether to operate in AJAX mode (dispatch event instead of full page reload). + * + * @since 4.0.8 + * + * @var bool + */ + protected $ajax_mode = false; + + /** + * Set AJAX mode. + * + * @since 4.0.8 + * + * @param bool $ajax True to dispatch events without reloading. + * + * @return self + */ + public function ajax_mode( bool $ajax = true ): self { + $this->ajax_mode = $ajax; + return $this; + } + /** * Set filter type. * @@ -289,6 +312,7 @@ public function get(): string { $calendar_options = array( 'type' => 'default', 'clearParams' => $this->clear_params, + 'ajaxMode' => $this->ajax_mode, ); $button_classes = 'tutor-btn tutor-btn-outline'; @@ -305,6 +329,7 @@ public function get(): string { 'type' => 'multiple', 'selectionDatesMode' => 'multiple-ranged', 'clearParams' => $this->clear_params, + 'ajaxMode' => $this->ajax_mode, ); $popover_classes .= ' tutor-range-calendar-popover'; } @@ -319,29 +344,42 @@ public function get(): string { $origin = Popover::TRANSFORM_ORIGIN_MAP[ $this->placement ] ?? 'center.top'; + $default_label = $this->label; + $has_selection = $this->has_selection(); + ob_start(); ?> -
get_attributes_string(); //phpcs:ignore -- Sanitization is performed inside get_attributes_string. ?>> +
ajax_mode ) : ?> + @tutor:date-filter-changed.window=" + hasSelection = Boolean( $event.detail.startDate || $event.detail.endDate || $event.detail.date ); + label = $event.detail.label || hide_initial_label ? '' : __( 'All Time', 'tutor' ) ) ); ?>; + " + + get_attributes_string(); //phpcs:ignore -- Sanitization is performed inside get_attributes_string. ?> + >
hide_initial_label ? '' : __( 'All Time', 'tutor' ); diff --git a/components/Skeleton.php b/components/Skeleton.php new file mode 100644 index 0000000000..e33ea19453 --- /dev/null +++ b/components/Skeleton.php @@ -0,0 +1,549 @@ +render(); + * + * // Custom width & height + * Skeleton::make()->width( '60%' )->height( 20 )->render(); + * + * // Stat cards skeleton (4 cards) + * Skeleton::make()->type( 'stat-card' )->count( 4 )->render(); + * + * // Overview chart skeleton + * Skeleton::make()->type( 'chart' )->height( 240 )->render(); + * + * // Table rows skeleton + * Skeleton::make()->type( 'table' )->count( 5 )->render(); + * + * // Top courses skeleton list + * Skeleton::make()->type( 'top-courses' )->count( 3 )->render(); + * + * // Recent reviews skeleton list + * Skeleton::make()->type( 'reviews' )->count( 3 )->render(); + * ``` + * + * @since 4.0.8 + */ +class Skeleton extends BaseComponent { + + /** + * Skeleton Type Constants + */ + const TYPE_LINE = 'line'; + const TYPE_AVATAR = 'avatar'; + const TYPE_STAT_CARD = 'stat-card'; + const TYPE_CHART = 'chart'; + const TYPE_COMPLETION_CHART = 'completion-chart'; + const TYPE_TOP_COURSES = 'top-courses'; + const TYPE_UPCOMING_TASKS = 'upcoming-tasks'; + const TYPE_REVIEWS = 'reviews'; + const TYPE_TABLE = 'table'; + const TYPE_BOX_CARD = 'box-card'; + + /** + * Type of skeleton + * + * @var string + */ + protected $type = self::TYPE_LINE; + + /** + * Width of skeleton + * + * @var string|int + */ + protected $width = '100%'; + + /** + * Height of skeleton + * + * @var string|int + */ + protected $height = ''; + + /** + * Repetition count + * + * @var int + */ + protected $count = 1; + + /** + * Number of text lines + * + * @var int + */ + protected $lines = 1; + + /** + * Border radius style + * + * @var string + */ + protected $radius = ''; + + /** + * Set the skeleton type. + * + * @since 4.0.8 + * + * @param string $type Type name. + * + * @return self + */ + public function type( string $type ): self { + $this->type = $type; + return $this; + } + + /** + * Set width. + * + * @since 4.0.8 + * + * @param string|int $width Width value. + * + * @return self + */ + public function width( $width ): self { + $this->width = is_numeric( $width ) ? "{$width}px" : $width; + return $this; + } + + /** + * Set height. + * + * @since 4.0.8 + * + * @param string|int $height Height value. + * + * @return self + */ + public function height( $height ): self { + $this->height = is_numeric( $height ) ? "{$height}px" : $height; + return $this; + } + + /** + * Set repetition count. + * + * @since 4.0.8 + * + * @param int $count Number of items. + * + * @return self + */ + public function count( int $count ): self { + $this->count = max( 1, $count ); + return $this; + } + + /** + * Set number of lines. + * + * @since 4.0.8 + * + * @param int $lines Number of lines. + * + * @return self + */ + public function lines( int $lines ): self { + $this->lines = max( 1, $lines ); + return $this; + } + + /** + * Set rounded radius. + * + * @since 4.0.8 + * + * @param string $radius (circle|full|md|sm). + * + * @return self + */ + public function rounded( string $radius ): self { + $this->radius = $radius; + return $this; + } + + /** + * Get the component output as an HTML string. + * + * @since 4.0.8 + * + * @return string + */ + public function get(): string { + ob_start(); + + switch ( $this->type ) { + case self::TYPE_STAT_CARD: + $this->render_stat_cards(); + break; + + case self::TYPE_BOX_CARD: + $this->render_box_cards(); + break; + + case self::TYPE_CHART: + $this->render_chart(); + break; + + case self::TYPE_COMPLETION_CHART: + $this->render_completion_chart(); + break; + + case self::TYPE_TOP_COURSES: + $this->render_top_courses(); + break; + + case self::TYPE_UPCOMING_TASKS: + $this->render_upcoming_tasks(); + break; + + case self::TYPE_REVIEWS: + $this->render_reviews(); + break; + + case self::TYPE_TABLE: + $this->render_table(); + break; + + case self::TYPE_AVATAR: + $this->render_avatar(); + break; + + case self::TYPE_LINE: + default: + $this->render_lines(); + break; + } + + return ob_get_clean(); + } + + /** + * Render single or multi lines. + * + * @since 4.0.8 + * + * @return void + */ + protected function render_lines(): void { + $height = $this->height ? $this->height : '16px'; + $class = 'tutor-skeleton' . ( 'circle' === $this->radius || 'full' === $this->radius ? ' tutor-skeleton-round' : '' ); + + for ( $i = 0; $i < $this->count; $i++ ) { + for ( $l = 0; $l < $this->lines; $l++ ) { + $width = ( $this->lines > 1 && $l === $this->lines - 1 ) ? '60%' : $this->width; + ?> + render_attributes(); ?>> + height ? $this->height : ( $this->width ? $this->width : '40px' ); + for ( $i = 0; $i < $this->count; $i++ ) { + ?> + render_attributes(); ?>> + +
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+ +
render_attributes(); ?>> + count; $i++ ) : ?> +
+ render_single_stat_card(); ?> +
+ +
+ +
render_attributes(); ?>> + count; $i++ ) : ?> + render_single_stat_card(); ?> + +
+ height ? $this->height : '179px'; + ?> +
render_attributes(); ?>> +
+
+ +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ +
render_attributes(); ?>> +
+
+ +
+
+
+ +
+
+ + +
+
+ +
+
+
+ +
render_attributes(); ?>> +
+
+ +
+ +
+
+ count; $i++ ) : ?> +
+
+
+ +
+
+ +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+ +
render_attributes(); ?>> +
+ +
+
+ count; $i++ ) : ?> +
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+ +
render_attributes(); ?>> +
+ +
+
+ count; $i++ ) : ?> +
+
+ +
+ + +
+
+ +
+ +
+
+ +
render_attributes(); ?>> +
+ + + +
+
+ count; $i++ ) : ?> +
+
+ + +
+ + +
+ +
+
+ path . 'templates/dashboard/instructor/analytics/stat-card-hover.php'; $hover_template = get_template_buffer( $template_path, @@ -71,12 +72,12 @@ - +
- +
diff --git a/templates/dashboard/instructor/home.php b/templates/dashboard/instructor/home.php index 8296c69a53..477809af49 100644 --- a/templates/dashboard/instructor/home.php +++ b/templates/dashboard/instructor/home.php @@ -10,303 +10,103 @@ defined( 'ABSPATH' ) || exit; +use TUTOR\DashboardSectionManager; use TUTOR\Icon; -use TUTOR\Input; -use TUTOR\Instructor; -use TUTOR_REPORT\Analytics; -use Tutor\Models\CourseModel; -use Tutor\Models\WithdrawModel; +use Tutor\Components\Constants\InputType; use Tutor\Components\DateFilter; use Tutor\Components\InputField; -use Tutor\Components\Constants\InputType; +use Tutor\Components\Skeleton; use Tutor\Components\SvgIcon; -$upcoming_tasks = array(); -$get_upcoming_live_tasks = array(); -$overview_chart_data = array(); -$recent_reviews = array(); -$course_completion_data = array(); -$sortable_sections = array(); - -$user = wp_get_current_user(); - - $tutor_pro_enabled = tutor_utils()->is_plugin_active( 'tutor-pro/tutor-pro.php' ); $is_pro_reports = $tutor_pro_enabled && tutor_utils()->is_addon_enabled( 'tutor-report' ); -$start_date = Input::has( 'start_date' ) ? tutor_get_formated_date( 'Y-m-d', Input::get( 'start_date' ) ) : ''; -$end_date = Input::has( 'end_date' ) ? tutor_get_formated_date( 'Y-m-d', Input::get( 'end_date' ) ) : ''; -$is_all_time = empty( $start_date ) && empty( $end_date ); -$previous_dates = $is_all_time ? array() : Instructor::get_comparison_date_range( $start_date, $end_date ); - -$date_range = fn( $from, $to ): array => array( - 'from' => $from, - 'to' => $to, -); - -$stat = function ( $current, $previous, $previous_dates ) { - return array_merge( $previous_dates, Instructor::get_stat_card_details( (float) $current, (float) $previous ) ); -}; - -// Total Earnings. -if ( $is_pro_reports ) { - $earnings = Analytics::get_earnings_by_user( $user->ID, '', $start_date, $end_date ); - $total_earnings = $earnings['total_earnings'] ?? 0; - - if ( ! $is_all_time ) { - $previous_period_earnings = Analytics::get_earnings_by_user( - $user->ID, - '', - $previous_dates['previous_start_date'], - $previous_dates['previous_end_date'] - )['total_earnings'] ?? 0; - } -} else { - $total_earnings = WithdrawModel::get_withdraw_summary( $user->ID )->total_income ?? 0; - - if ( ! $is_all_time ) { - $previous_period_earnings = WithdrawModel::get_withdraw_summary( - $user->ID, - $date_range( $previous_dates['previous_start_date'], $previous_dates['previous_end_date'] ) - )->total_income ?? 0; - } -} - -// Total Courses. -$total_courses = CourseModel::get_course_count_by_date( $start_date, $end_date, $user->ID ); -$previous_period_courses = ! $is_all_time - ? CourseModel::get_course_count_by_date( $previous_dates['previous_start_date'], $previous_dates['previous_end_date'], $user->ID ) - : 0; - -// Total Students. -$total_students = tutor_utils()->get_total_students_by_instructor( $user->ID, $date_range( $start_date, $end_date ) ); -$previous_period_students = ! $is_all_time - ? tutor_utils()->get_total_students_by_instructor( $user->ID, $date_range( $previous_dates['previous_start_date'], $previous_dates['previous_end_date'] ) ) - : 0; - - -// Total Ratings. -$total_ratings_where = ! $is_all_time ? $date_range( $start_date, $end_date ) : array(); -$total_ratings = tutor_utils()->get_instructor_ratings( $user->ID, $total_ratings_where ); -$previous_period_ratings = ! $is_all_time - ? tutor_utils()->get_instructor_ratings( $user->ID, $date_range( $previous_dates['previous_start_date'], $previous_dates['previous_end_date'] ) ) - : (object) array( 'rating_avg' => 0 ); - -/** - * ------------------------- - * Hover (comparison) data - * Only for Pro Reports and “All Time” is not chosen. - * ------------------------- - */ -$total_earnings_state_card_details = array(); -$total_courses_state_card_details = array(); -$total_students_state_card_details = array(); -$total_ratings_state_card_details = array(); +$user_id = get_current_user_id(); +$saved_order = get_user_meta( $user_id, '_tutor_instructor_home_sections_order', true ); +$saved_visibility = get_user_meta( $user_id, '_tutor_instructor_home_sections_visibility', true ); -if ( $tutor_pro_enabled && ! $is_all_time ) { - $total_earnings_state_card_details = $stat( $total_earnings, $previous_period_earnings, $previous_dates ); - $total_courses_state_card_details = $stat( $total_courses, $previous_period_courses, $previous_dates ); - $total_students_state_card_details = $stat( $total_students, $previous_period_students, $previous_dates ); - $total_ratings_state_card_details = $stat( $total_ratings->rating_avg, $previous_period_ratings->rating_avg, $previous_dates ); -} - -/** - * ------------------------- - * Stat cards - * ------------------------- - */ -$stat_cards = array( - array( - 'variation' => 'brand', - 'title' => esc_html__( 'Total Earnings', 'tutor' ), - 'icon' => Icon::EARNING, - 'value' => tutor_utils()->tutor_price( $total_earnings ?? 0 ), - 'hover_content' => $total_earnings_state_card_details, - ), - array( - 'variation' => 'exception1', - 'title' => esc_html__( 'Total Courses', 'tutor' ), - 'icon' => Icon::COURSES, - 'value' => $total_courses, - 'hover_content' => $total_courses_state_card_details, - ), - array( - 'variation' => 'exception5', - 'title' => esc_html__( 'Total Students', 'tutor' ), - 'icon' => Icon::PASSED, - 'value' => $total_students, - 'hover_content' => $total_students_state_card_details, - ), - array( - 'variation' => 'exception4', - 'title' => esc_html__( 'Avg. Rating', 'tutor' ), - 'icon' => Icon::STAR_LINE, - 'value' => $total_ratings->rating_avg, - 'hover_content' => $total_ratings_state_card_details, - ), -); - - -/** - * ------------------------- - * Graph data (only for pro) - * ------------------------- - */ -if ( $is_pro_reports ) { - $enrollments = Analytics::get_total_students_by_user( $user->ID, '', $start_date, $end_date ); - - $overview_chart_data = array( - 'earnings' => array( 0 ), - 'enrolled' => array( 0 ), - 'labels' => array( '' ), - 'currency' => tutor_utils()->get_monetization_currency_config(), - 'enrollment_date' => array( '' ), - 'earning_date' => array( '' ), - ); - - foreach ( $earnings['earnings'] as $item ) { - $overview_chart_data['earnings'][] = (float) ( $item->total ?? 0 ); - $overview_chart_data['labels'][] = $item->label_name ?? ''; - $overview_chart_data['earning_date'][] = ! empty( $item->date_format ) - ? wp_date( 'M d', strtotime( $item->date_format ) ) : ''; - } - - foreach ( $enrollments['enrollments'] as $item ) { - $overview_chart_data['enrolled'][] = (float) ( $item->total ?? 0 ); - $overview_chart_data['enrollment_date'][] = ! empty( $item->date_format ) - ? wp_date( 'M d', strtotime( $item->date_format ) ) : ''; - } - - $overview_chart_data['earnings'][] = 0; - $overview_chart_data['enrolled'][] = 0; - $overview_chart_data['labels'][] = ''; - $overview_chart_data['earning_date'][] = ''; - $overview_chart_data['enrollment_date'][] = ''; -} - -/** - * --------------------------------------------- - * Course Completion Distribution (For All Time) - * --------------------------------------------- - */ - -if ( $is_all_time ) { - $distribution = Instructor::get_course_completion_distribution_data_by_instructor(); - - $course_completion_data = array( - 'enrolled' => array( - 'label' => esc_html__( 'Enrolled', 'tutor' ), - 'value' => $distribution['enrolled'], - ), - 'completed' => array( - 'label' => esc_html__( 'Completed', 'tutor' ), - 'value' => $distribution['completed'], - ), - 'in_progress' => array( - 'label' => esc_html__( 'In Progress', 'tutor' ), - 'value' => $distribution['inprogress'], - ), - 'inactive' => array( - 'label' => esc_html__( 'Inactive', 'tutor' ), - 'value' => $distribution['inactive'], - ), - 'cancelled' => array( - 'label' => esc_html__( 'Cancelled', 'tutor' ), - 'value' => $distribution['cancelled'], - ), - ); -} - -// Top Performing Courses. -$args = array( - 'start_date' => $start_date, - 'end_date' => $end_date, - 'order_by' => Input::get( 'type', 'revenue' ), -); - -$top_performing_courses = Instructor::format_instructor_top_performing_courses( - Instructor::get_top_performing_courses_by_instructor( $user->ID, $args ) -); - -// Upcoming Live Tasks (all-time + pro only). -if ( $is_all_time && $tutor_pro_enabled ) { - $upcoming_tasks = Instructor::format_instructor_upcoming_live_tasks( - Instructor::get_instructor_upcoming_live_tasks( $user->ID ) - ); -} - -// Recent Reviews. -$review_args = array( 'comment_approved' => 'approved' ); -if ( ! $is_all_time ) { - $review_args = $date_range( $start_date, $end_date ); -} -$reviews = tutor_utils()->get_reviews_by_instructor( $user->ID, 0, 3, '', '', $review_args ); -$recent_reviews = Instructor::format_instructor_recent_reviews( $reviews->results ); - - -/** - * ------------------------------------ - * Sortable sections data preparation - * ------------------------------------ - */ -$saved_order = get_user_meta( get_current_user_id(), '_tutor_instructor_home_sections_order', true ); -$saved_visibility = get_user_meta( get_current_user_id(), '_tutor_instructor_home_sections_visibility', true ); +$saved_order = is_array( $saved_order ) ? $saved_order : array(); +$saved_visibility = is_array( $saved_visibility ) ? $saved_visibility : array(); $sortable_sections = array( array( - 'id' => 'current_stats', - 'label' => esc_html__( 'Current Stats', 'tutor' ), - 'is_active' => $saved_visibility['current_stats'] ?? true, - 'order' => $saved_order['current_stats'] ?? 0, - 'data' => true, + 'id' => 'current_stats', + 'label' => esc_html__( 'Current Stats', 'tutor' ), + 'is_active' => isset( $saved_visibility['current_stats'] ) ? (bool) $saved_visibility['current_stats'] : true, + 'order' => $saved_order['current_stats'] ?? 0, + 'skeleton' => Skeleton::TYPE_STAT_CARD, + 'count' => 4, + 'date_dependent' => true, + 'sort_dependent' => false, + 'condition' => true, ), array( - 'id' => 'overview_chart', - 'label' => esc_html__( 'Earnings Over Time', 'tutor' ), - 'is_active' => $saved_visibility['overview_chart'] ?? true, - 'order' => $saved_order['overview_chart'] ?? 1, - 'data' => ! empty( $overview_chart_data ), + 'id' => 'overview_chart', + 'label' => esc_html__( 'Earnings Over Time', 'tutor' ), + 'is_active' => isset( $saved_visibility['overview_chart'] ) ? (bool) $saved_visibility['overview_chart'] : true, + 'order' => $saved_order['overview_chart'] ?? 1, + 'skeleton' => Skeleton::TYPE_CHART, + 'count' => 1, + 'date_dependent' => true, + 'sort_dependent' => false, + 'condition' => $is_pro_reports, ), array( - 'id' => 'course_completion_and_leader', - 'label' => esc_html__( 'Course Completion Rate', 'tutor' ), - 'is_active' => $saved_visibility['course_completion_and_leader'] ?? true, - 'order' => $saved_order['course_completion_and_leader'] ?? 2, - 'data' => ! empty( $course_completion_data ), + 'id' => 'course_completion_and_leader', + 'label' => esc_html__( 'Course Completion Rate', 'tutor' ), + 'is_active' => isset( $saved_visibility['course_completion_and_leader'] ) ? (bool) $saved_visibility['course_completion_and_leader'] : true, + 'order' => $saved_order['course_completion_and_leader'] ?? 2, + 'skeleton' => Skeleton::TYPE_COMPLETION_CHART, + 'count' => 1, + 'date_dependent' => false, + 'sort_dependent' => false, + 'condition' => true, ), array( - 'id' => 'top_performing_courses', - 'label' => esc_html__( 'Top Performing Courses', 'tutor' ), - 'is_active' => $saved_visibility['top_performing_courses'] ?? true, - 'order' => $saved_order['top_performing_courses'] ?? 3, - 'data' => ! empty( $top_performing_courses ), + 'id' => 'top_performing_courses', + 'label' => esc_html__( 'Top Performing Courses', 'tutor' ), + 'is_active' => isset( $saved_visibility['top_performing_courses'] ) ? (bool) $saved_visibility['top_performing_courses'] : true, + 'order' => $saved_order['top_performing_courses'] ?? 3, + 'skeleton' => Skeleton::TYPE_TOP_COURSES, + 'count' => 4, + 'date_dependent' => true, + 'sort_dependent' => true, + 'condition' => true, ), array( - 'id' => 'upcoming_tasks_and_activity', - 'label' => esc_html__( 'Upcoming Tasks', 'tutor' ), - 'is_active' => $saved_visibility['upcoming_tasks_and_activity'] ?? true, - 'order' => $saved_order['upcoming_tasks_and_activity'] ?? 4, - 'data' => ! empty( $upcoming_tasks ), + 'id' => 'upcoming_tasks_and_activity', + 'label' => esc_html__( 'Upcoming Tasks', 'tutor' ), + 'is_active' => isset( $saved_visibility['upcoming_tasks_and_activity'] ) ? (bool) $saved_visibility['upcoming_tasks_and_activity'] : true, + 'order' => $saved_order['upcoming_tasks_and_activity'] ?? 4, + 'skeleton' => Skeleton::TYPE_UPCOMING_TASKS, + 'count' => 3, + 'date_dependent' => false, + 'sort_dependent' => false, + 'condition' => $tutor_pro_enabled, ), array( - 'id' => 'recent_reviews', - 'label' => esc_html__( 'Recent Student Reviews', 'tutor' ), - 'is_active' => $saved_visibility['recent_reviews'] ?? true, - 'order' => $saved_order['recent_reviews'] ?? 5, - 'data' => ! empty( $recent_reviews ), + 'id' => 'recent_reviews', + 'label' => esc_html__( 'Recent Student Reviews', 'tutor' ), + 'is_active' => isset( $saved_visibility['recent_reviews'] ) ? (bool) $saved_visibility['recent_reviews'] : true, + 'order' => $saved_order['recent_reviews'] ?? 5, + 'skeleton' => Skeleton::TYPE_REVIEWS, + 'count' => 3, + 'date_dependent' => true, + 'sort_dependent' => false, + 'condition' => true, ), ); -// Remove sections which don't have data to show. +// Filter out sections where condition is not met (e.g. Pro required). $sortable_sections = array_filter( $sortable_sections, - fn( $section ) => $section['data'] + fn( $section ) => $section['condition'] ); usort( $sortable_sections, function ( $a, $b ) { - return $a['order'] <=> $b['order']; + return ( $a['order'] ?? 0 ) <=> ( $b['order'] ?? 0 ); } ); @@ -319,16 +119,8 @@ function ( $carry, $section ) { array() ); -$sortable_sections_ids = array_reduce( - $sortable_sections, - function ( $carry, $section ) { - $carry[ $section['order'] ] = $section['id']; - return $carry; - }, - array() -); +$sortable_sections_ids = array_values( array_column( $sortable_sections, 'id' ) ); ?> -
- type( DateFilter::TYPE_RANGE )->render(); ?> + type( DateFilter::TYPE_RANGE ) + ->ajax_mode( true ) + ->render(); + ?> -
+
type( InputType::CHECKBOX ) ->name( "$section[id]" ) ->label( $section['label'] ) + ->checked( ! empty( $section['is_active'] ) ) ->attr( 'x-bind', "\$el.closest('[data-dnd-placeholder]') ? {} : register('{$section['id']}')" ) ->attr( '@click.stop', 'handleCheckboxClick(event)' ) ->render(); @@ -391,163 +191,30 @@ class="tutor-popover-menu-item"
+ - - -
- -
- $card['variation'] ?? 'enrolled', - 'card_title' => $card['title'] ?? '', - 'icon' => $card['icon'] ?? '', - 'icon_size' => $card['icon_size'] ?? 20, - 'value' => $card['value'] ?? '', - 'content' => $card['content'] ?? '', - 'hover_content' => $card['hover_content'] ?? array(), - ) - ); - ?> -
- -
- - - - $overview_chart_data, - ) - ); - endif; - ?> - - -
- +
+ +
$course_completion_data, - ) - ); + Skeleton::make() + ->type( $section['skeleton'] ) + ->count( $section['count'] ?? 1 ) + ->render(); ?>
- - - - -
-
-
- -
- - - array( - 'revenue' => __( 'Revenue', 'tutor' ), - 'student' => __( 'Student', 'tutor' ), - ), - 'selected' => Input::get( 'type', 'revenue' ), - ); - tutor_load_template( - 'dashboard.instructor.home.top-performing-course-filter', - $data, - ); - ?> -
- -
- $item ) : ?> - $item_key, - 'item' => $item, - ), - ) - ?> - -
-
- - - -
- -
-
- -
- -
- - $item ) - ); - ?> - -
-
-
- - - - -
-
- -
- -
- - $review ), - ); - ?> - -
-
- + +
+
diff --git a/templates/dashboard/instructor/home/overview-chart.php b/templates/dashboard/instructor/home/overview-chart.php index e614d33e10..e0b4fd3eb0 100644 --- a/templates/dashboard/instructor/home/overview-chart.php +++ b/templates/dashboard/instructor/home/overview-chart.php @@ -9,16 +9,11 @@ */ defined( 'ABSPATH' ) || exit; -?> - +$overview_chart_data = $overview_chart_data ?? array(); +?> -
+
diff --git a/templates/dashboard/instructor/home/top-performing-course-filter.php b/templates/dashboard/instructor/home/top-performing-course-filter.php index 65c1be2ef0..f4430cb16c 100644 --- a/templates/dashboard/instructor/home/top-performing-course-filter.php +++ b/templates/dashboard/instructor/home/top-performing-course-filter.php @@ -14,6 +14,9 @@ use TUTOR\Icon; use Tutor\Components\SvgIcon; use Tutor\Components\Constants\Color; + +$options = $options ?? array(); +$selected = $selected ?? 'revenue'; ?>
$option ) : ?> - +