PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.1
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.1
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 4.2.1 All 138 releases
learnpress / inc / TemplateHooks / Course / CourseAIAssistantTemplate.php

CourseAIAssistantTemplate.php in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.4.1, at inc/TemplateHooks/Course/CourseAIAssistantTemplate.php

589 lines 17.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Template hook: AI Assistant floating chat panel on curriculum pages.
4 *
5 * Two rendering contexts:
6 * - Lesson pages: Show quick actions (Summarize, Explain, Mini Quiz) + optional free chat.
7 * - Quiz pages: Show ONLY after user completed the quiz → Smart Review button only.
8 *
9 * @since 4.3.5
10 * @version 1.1.0
11 * @package LearnPress\TemplateHooks\Course
12 */
13
14 namespace LearnPress\TemplateHooks\Course;
15
16 use LearnPress\Helpers\Template;
17 use LearnPress\Models\UserItems\UserQuizModel;
18 use LP_Global;
19 use LP_Page_Controller;
20 use LP_Settings;
21 use LearnPress\AI\Assistant\AIAssistantController;
22 use Throwable;
23
24 defined( 'ABSPATH' ) || exit;
25
26 class CourseAIAssistantTemplate {
27
28 /**
29 * Shared footer action used to collect launcher buttons inside one wrapper.
30 */
31 const FOOTER_LAUNCHERS_HOOK = 'learn-press/course-item-footer-launchers';
32
33 /**
34 * Cached render state for the current request.
35 *
36 * @var array|false
37 */
38 protected $render_state = false;
39
40 /**
41 * Whether the render state has already been resolved.
42 *
43 * @var bool
44 */
45 protected $render_state_resolved = false;
46
47 public static function instance() {
48 static $instance = null;
49
50 if ( is_null( $instance ) ) {
51 $instance = new self();
52 }
53
54 return $instance;
55 }
56
57 protected function __construct() {
58 add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) );
59 add_action( 'wp_footer', array( $this, 'render_launcher_wrapper' ), 5 );
60 add_action( self::FOOTER_LAUNCHERS_HOOK, array( $this, 'render_launcher' ), 20 );
61 add_action( 'wp_footer', array( $this, 'render_panel' ), 10 );
62 }
63
64 /**
65 * Enqueue frontend assets early so launcher markup does not rely on inline styles.
66 */
67 public function enqueue_assets() {
68 if ( ! $this->get_render_state() ) {
69 return;
70 }
71
72 wp_enqueue_script( 'lp-ai-assistant' );
73 wp_enqueue_style( 'lp-ai-assistant' );
74 }
75
76 /**
77 * Gate checks — all must pass before rendering.
78 *
79 * Allows both lesson pages AND quiz item pages (quiz pages only when
80 * the user has completed the quiz — checked later in render_widget).
81 *
82 * @return bool
83 */
84 protected function should_render(): bool {
85
86 $current_page = LP_Page_Controller::page_current();
87 if ( ! in_array( $current_page, array( LP_PAGE_SINGLE_COURSE_CURRICULUM, LP_PAGE_QUIZ ), true ) ) {
88 return false;
89 }
90
91 if ( ! AIAssistantController::is_enabled() ) {
92 return false;
93 }
94
95 if ( ! is_user_logged_in() ) {
96 return false;
97 }
98
99 return true;
100 }
101
102 /**
103 * Detect the rendering context.
104 *
105 * @return string 'quiz' | 'lesson'
106 */
107 protected function detect_context(): string {
108 return LP_Global::course_item_quiz() ? 'quiz' : 'lesson';
109 }
110
111 /**
112 * Resolve and cache the render state for the current request.
113 *
114 * @return array|false
115 */
116 protected function get_render_state() {
117 if ( $this->render_state_resolved ) {
118 return $this->render_state;
119 }
120
121 $this->render_state_resolved = true;
122
123 if ( ! $this->should_render() ) {
124 return $this->render_state = false;
125 }
126
127 $context = $this->detect_context();
128 $item = LP_Global::course_item();
129 $item_id = $item ? absint( $item->get_id() ) : 0;
130 $course_id = $item ? absint( $item->get_course_id() ) : 0;
131 $user_id = get_current_user_id();
132
133 $enabled_actions = AIAssistantController::get_enabled_actions();
134 $free_chat_enabled = LP_Settings::get_option( 'ai_assistant_free_chat', 'no' ) === 'yes';
135
136 if ( $context === 'quiz' ) {
137 if ( ! ( $enabled_actions['smart_review'] ?? true ) ) {
138 return $this->render_state = false;
139 }
140
141 $quiz_result = $this->get_completed_quiz_result( $user_id, $item_id, $course_id );
142 if ( $quiz_result === false ) {
143 return $this->render_state = false;
144 }
145
146 $enabled_actions = array(
147 'summarize' => false,
148 'explain' => false,
149 'quick_quiz' => false,
150 'smart_review' => true,
151 );
152 $free_chat_enabled = false;
153 } else {
154 $enabled_actions['smart_review'] = false;
155
156 if ( ! $free_chat_enabled && ! in_array( true, $enabled_actions, true ) ) {
157 return $this->render_state = false;
158 }
159
160 $quiz_result = null;
161 }
162
163 return $this->render_state = array(
164 'context' => $context,
165 'item_id' => $item_id,
166 'course_id' => $course_id,
167 'enabled_actions' => $enabled_actions,
168 'free_chat_enabled' => $free_chat_enabled,
169 'quiz_result' => $quiz_result,
170 );
171 }
172
173 /**
174 * Enqueue assets and localize runtime data for the frontend widget.
175 *
176 * @param array $render_state Computed render state.
177 */
178 protected function localize_script_data( array $render_state ) {
179 $js_data = wp_json_encode(
180 array(
181 'ajaxUrl' => LP_Settings::url_handle_lp_ajax(),
182 'nonce' => wp_create_nonce( 'wp_rest' ),
183 'lessonId' => $render_state['item_id'],
184 'itemId' => $render_state['item_id'],
185 'courseId' => $render_state['course_id'],
186 'context' => $render_state['context'],
187 'quizCompleted' => $render_state['context'] === 'quiz',
188 'quizResult' => $render_state['quiz_result'],
189 'enabled' => true,
190 'freeChatEnabled' => $render_state['free_chat_enabled'],
191 'enabledActions' => $render_state['enabled_actions'],
192 'i18n' => array(
193 'you' => __( 'You', 'learnpress' ),
194 'assistant' => __( 'AI Assistant', 'learnpress' ),
195 'thinking' => __( 'Thinking...', 'learnpress' ),
196 'sendError' => __( 'An error occurred. Please try again.', 'learnpress' ),
197 'clearConfirm' => __( 'Clear chat history?', 'learnpress' ),
198 'quizPrompt' => __( 'Create a quick quiz from this lesson.', 'learnpress' ),
199 'explainPrompt' => __( 'Explain a concept from this lesson.', 'learnpress' ),
200 'summarizePrompt' => __( 'Summarize this lesson with key points.', 'learnpress' ),
201 'smartReviewPrompt' => __( 'Give me a smart review of my quiz results.', 'learnpress' ),
202 'quizCorrectTitle' => __( 'Correct', 'learnpress' ),
203 'quizWrongTitle' => __( 'Incorrect', 'learnpress' ),
204 ),
205 )
206 );
207
208 wp_add_inline_script( 'lp-ai-assistant', 'window.lpAIAssistant = ' . $js_data . ';', 'before' );
209 }
210
211 /**
212 * Backward-compatible entrypoint kept for external callers.
213 */
214 public function render_widget() {
215 $this->render_panel();
216 }
217
218 /**
219 * Render the shared footer wrapper for launcher buttons.
220 */
221 public function render_launcher_wrapper() {
222 ob_start();
223 do_action( self::FOOTER_LAUNCHERS_HOOK );
224 $launchers_html = trim( ob_get_clean() );
225
226 if ( '' === $launchers_html ) {
227 return;
228 }
229
230 printf(
231 '<div class="lp-footer-launchers" aria-label="%1$s">%2$s</div>',
232 esc_attr__( 'Learning tools', 'learnpress' ),
233 $launchers_html // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
234 );
235 }
236
237 /**
238 * Render the AI Assistant launcher into the shared wrapper.
239 */
240 public function render_launcher() {
241 if ( ! $this->get_render_state() ) {
242 return;
243 }
244
245 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
246 echo $this->html_toggle();
247 }
248
249 /**
250 * Render the AI Assistant panel on wp_footer.
251 */
252 public function render_panel() {
253 try {
254 $render_state = $this->get_render_state();
255 if ( ! $render_state ) {
256 return;
257 }
258
259 $this->localize_script_data( $render_state );
260 $this->html_panel_widget(
261 $render_state['free_chat_enabled'],
262 $render_state['enabled_actions']
263 );
264 } catch ( Throwable $e ) {
265 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
266 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
267 echo '<!-- LP AI Assistant render error: ' . esc_html( $e->getMessage() ) . ' -->';
268 }
269 }
270 }
271
272 /**
273 * Get quiz result if user has completed the specific quiz.
274 *
275 * Returns the result array from UserQuizModel::get_result() when the quiz
276 * status is LP_ITEM_COMPLETED, or false if not completed yet.
277 *
278 * @param int $user_id
279 * @param int $quiz_id
280 * @param int $course_id
281 *
282 * @return array|false Result array on completion, false otherwise.
283 */
284 private function get_completed_quiz_result( int $user_id, int $quiz_id, int $course_id ) {
285 if ( $user_id <= 0 || $quiz_id <= 0 || $course_id <= 0 ) {
286 return false;
287 }
288
289 $user_quiz = UserQuizModel::find_user_item(
290 $user_id,
291 $quiz_id,
292 LP_QUIZ_CPT,
293 $course_id,
294 LP_COURSE_CPT,
295 true
296 );
297
298 if ( ! $user_quiz instanceof UserQuizModel ) {
299 return false;
300 }
301
302 if ( ! method_exists( $user_quiz, 'get_status' ) || $user_quiz->get_status() !== LP_ITEM_COMPLETED ) {
303 return false;
304 }
305
306 if ( ! method_exists( $user_quiz, 'get_result' ) ) {
307 return false;
308 }
309
310 $result = $user_quiz->get_result();
311
312 return is_array( $result ) ? $result : false;
313 }
314
315 /**
316 * Toggle button that opens/closes the chat panel.
317 *
318 * @return string
319 */
320 public function html_toggle(): string {
321 $icon = '<span class="lp-icon lp-icon-graduation-cap"></span>';
322 $label = sprintf(
323 '<span class="lp-ai-assistant__toggle-label">%s</span>',
324 esc_html__( 'AI Assistant', 'learnpress' )
325 );
326
327 $section = apply_filters(
328 'learn-press/ai-assistant/html-toggle',
329 array(
330 'wrapper' => sprintf(
331 '<button type="button" class="lp-ai-assistant__toggle" aria-label="%s" aria-expanded="false" aria-controls="lp-ai-assistant-panel">',
332 esc_attr__( 'Open AI Learning Assistant', 'learnpress' )
333 ),
334 'icon' => $icon,
335 'label' => $label,
336 'wrapper_end' => '</button>',
337 )
338 );
339
340 return Template::combine_components( $section );
341 }
342
343 /**
344 * Panel header: title + clear and close action buttons.
345 *
346 * @return string
347 */
348 public function html_header(): string {
349 $title = sprintf(
350 '<h2 id="lp-ai-assistant-title" class="lp-ai-assistant__title">%s</h2>',
351 esc_html__( 'AI Learning Assistant', 'learnpress' )
352 );
353 $clear_btn = sprintf(
354 '<span class="lp-ai-assistant__clear-btn lp-icon-trash-o" aria-label="%1$s" title="%1$s" aria-hidden="true"></span>',
355 esc_attr__( 'Clear chat history', 'learnpress' ),
356 );
357 $close_btn = sprintf(
358 '<span class="lp-ai-assistant__close-btn lp-icon-angle-right" aria-label="%1$s" title="%1$s" aria-hidden="true"></span>',
359 esc_attr__( 'Close AI Assistant', 'learnpress' ),
360 );
361 $actions = Template::instance()->nest_elements(
362 array( '<div class="lp-ai-assistant__header-actions">' => '</div>' ),
363 sprintf( '%s%s', $clear_btn, $close_btn )
364 );
365
366 $section = apply_filters(
367 'learn-press/ai-assistant/html-header',
368 array(
369 'wrapper' => '<div class="lp-ai-assistant__header">',
370 'title' => $title,
371 'actions' => $actions,
372 'wrapper_end' => '</div>',
373 )
374 );
375
376 return Template::combine_components( $section );
377 }
378
379 /**
380 * Scrollable message log container (populated by JS).
381 *
382 * @return string
383 */
384 public function html_messages(): string {
385 $section = apply_filters(
386 'learn-press/ai-assistant/html-messages',
387 array(
388 'wrapper' => '<div class="lp-ai-assistant__messages-wrap">',
389 'messages' => '<div class="lp-ai-assistant__messages" role="log" aria-live="polite" aria-relevant="additions"></div>',
390 'wrapper_end' => '</div>',
391 )
392 );
393
394 return Template::combine_components( $section );
395 }
396
397 /**
398 * Quick-action buttons row (Summarize, Smart Review).
399 *
400 * @return string
401 */
402 public function html_quick_actions( array $enabled_actions = array() ): string {
403 $buttons = array();
404
405 if ( $enabled_actions['explain'] ?? true ) {
406 $buttons[] = sprintf(
407 '<button type="button" class="lp-ai-assistant__quick-btn" data-lp-ai-action="explain">%s</button>',
408 esc_html__( 'Explain Concept', 'learnpress' )
409 );
410 }
411
412 if ( $enabled_actions['quick_quiz'] ?? true ) {
413 $buttons[] = sprintf(
414 '<button type="button" class="lp-ai-assistant__quick-btn" data-lp-ai-action="quick-quiz">%s</button>',
415 esc_html__( 'Quick Quiz', 'learnpress' )
416 );
417 }
418
419 if ( $enabled_actions['summarize'] ?? true ) {
420 $buttons[] = sprintf(
421 '<button type="button" class="lp-ai-assistant__quick-btn" data-lp-ai-action="summarize">%s</button>',
422 esc_html__( 'Summarize Lesson', 'learnpress' )
423 );
424 }
425
426 if ( $enabled_actions['smart_review'] ?? true ) {
427 $buttons[] = sprintf(
428 '<button type="button" class="lp-ai-assistant__quick-btn lp-ai-assistant__smart-review-btn" data-lp-ai-action="smart-review">%s</button>',
429 esc_html__( 'Smart Review', 'learnpress' )
430 );
431 }
432
433 if ( empty( $buttons ) ) {
434 return '';
435 }
436
437 $section = apply_filters(
438 'learn-press/ai-assistant/html-quick-actions',
439 array(
440 'wrapper' => '<div class="lp-ai-assistant__quick-actions" role="group" aria-label="' . esc_attr__( 'AI assistant quick actions', 'learnpress' ) . '">',
441 'buttons' => implode( '', $buttons ),
442 'wrapper_end' => '</div>',
443 )
444 );
445
446 return Template::combine_components( $section );
447 }
448
449 /**
450 * Textarea + Send button input row.
451 *
452 * @return string
453 */
454 public function html_input_area(): string {
455 $textarea = sprintf(
456 '<textarea class="lp-ai-assistant__input" rows="1" aria-label="%s" placeholder="%s"></textarea>',
457 esc_attr__( 'Your message to the AI assistant', 'learnpress' ),
458 esc_attr__( 'Type your message', 'learnpress' )
459 );
460
461 $send_btn = sprintf(
462 '<span class="lp-ai-assistant__send-btn lp-icon-comment-o" aria-label="%1$s" title="%1$s" aria-hidden="true"></span>',
463 esc_attr__( 'Send message', 'learnpress' )
464 );
465
466 $section = apply_filters(
467 'learn-press/ai-assistant/html-input-area',
468 array(
469 'wrapper' => '<div class="lp-ai-assistant__input-area">',
470 'composer' => '<div class="lp-ai-assistant__composer">',
471 'textarea' => $textarea,
472 'send_btn' => $send_btn,
473 'composer_end' => '</div>',
474 'wrapper_end' => '</div>',
475 )
476 );
477
478 return Template::combine_components( $section );
479 }
480
481 /**
482 * Footer controls pinned to the bottom of the panel.
483 *
484 * @param bool $free_chat_enabled Whether to render the textarea/send-button input area.
485 * @param array $enabled_actions Enabled quick actions.
486 *
487 * @return string
488 */
489 public function html_panel_footer( bool $free_chat_enabled = true, array $enabled_actions = array() ): string {
490 $content = sprintf(
491 '%s%s',
492 $this->html_quick_actions( $enabled_actions ),
493 $free_chat_enabled ? $this->html_input_area() : ''
494 );
495
496 if ( '' === $content ) {
497 return '';
498 }
499
500 $section = apply_filters(
501 'learn-press/ai-assistant/html-panel-footer',
502 array(
503 'wrapper' => '<div class="lp-ai-assistant__panel-footer">',
504 'content' => $content,
505 'wrapper_end' => '</div>',
506 )
507 );
508
509 return Template::combine_components( $section );
510 }
511
512 /**
513 * Full chat panel (header + messages + quick actions + optional input area).
514 *
515 * @param bool $free_chat_enabled Whether to render the textarea/send-button input area.
516 *
517 * @return string
518 */
519 public function html_panel( bool $free_chat_enabled = true, array $enabled_actions = array() ): string {
520 $content = sprintf(
521 '%s<div class="lp-ai-assistant__panel-body">%s%s</div>',
522 $this->html_header(),
523 $this->html_messages(),
524 $this->html_panel_footer( $free_chat_enabled, $enabled_actions )
525 );
526
527 $panel_class = 'lp-ai-assistant__panel' . ( $free_chat_enabled ? '' : ' lp-ai-assistant-panel--quick-only' );
528
529 $section = apply_filters(
530 'learn-press/ai-assistant/html-panel',
531 array(
532 'wrapper' => sprintf(
533 '<div id="lp-ai-assistant-panel" class="%s" role="dialog" aria-labelledby="lp-ai-assistant-title" aria-modal="true" hidden>',
534 esc_attr( $panel_class )
535 ),
536 'content' => $content,
537 'wrapper_end' => '</div>',
538 )
539 );
540
541 return Template::combine_components( $section );
542 }
543
544 /**
545 * Root widget that contains only the floating panel.
546 *
547 * @param bool $free_chat_enabled Whether to render the full chat input area.
548 * @param array $enabled_actions Enabled quick actions.
549 */
550 public function html_panel_widget( bool $free_chat_enabled = true, array $enabled_actions = array() ) {
551 $section = apply_filters(
552 'learn-press/ai-assistant/html-panel-widget',
553 array(
554 'wrapper' => '<div id="lp-ai-assistant" class="lp-ai-assistant" aria-hidden="true">',
555 'panel' => $this->html_panel( $free_chat_enabled, $enabled_actions ),
556 'wrapper_end' => '</div>',
557 )
558 );
559
560 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
561 echo Template::combine_components( $section );
562 }
563
564 /**
565 * Outer floating widget: toggle button + panel, assembled from sub-components.
566 *
567 * Follows LP TemplateHook standard:
568 * - Each visual block is a dedicated `html_*()` method returning string.
569 * - Sections assembled via `Template::combine_components()`.
570 * - Each section wrapped in `apply_filters()` for extensibility.
571 *
572 * @param bool $free_chat_enabled Whether to render the full chat input area.
573 */
574 public function html_widget( bool $free_chat_enabled = true, array $enabled_actions = array() ) {
575 $section = apply_filters(
576 'learn-press/ai-assistant/html-widget',
577 array(
578 'wrapper' => '<div id="lp-ai-assistant" class="lp-ai-assistant" aria-hidden="true">',
579 'toggle' => $this->html_toggle(),
580 'panel' => $this->html_panel( $free_chat_enabled, $enabled_actions ),
581 'wrapper_end' => '</div>',
582 )
583 );
584
585 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
586 echo Template::combine_components( $section );
587 }
588 }
589