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 / inc / TemplateHooks / Course / CourseAIAssistantTemplate.php

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

609 lines 17.9 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 $item_type = $item ? (string) $item->get_item_type() : '';
131 $course_id = $item ? absint( $item->get_course_id() ) : 0;
132 $user_id = get_current_user_id();
133
134 /**
135 * Defense in depth: run the same resolver the AJAX controller uses, so the widget
136 * is never offered for an item the user cannot view. This is not the security
137 * boundary — AIAssistantController::handle_chat() is, because the AJAX action is
138 * reachable without this markup ever rendering.
139 *
140 * Catches Throwable because this runs on wp_enqueue_scripts, outside the
141 * render_panel() try/catch. Any failure denies rather than fatals the page.
142 */
143 try {
144 AIAssistantController::resolve_item_access( $user_id, $course_id, $item_type, $item_id );
145 } catch ( Throwable $e ) {
146 return $this->render_state = false;
147 }
148
149 $enabled_actions = AIAssistantController::get_enabled_actions();
150 $free_chat_enabled = LP_Settings::get_option( 'ai_assistant_free_chat', 'no' ) === 'yes';
151
152 if ( $context === 'quiz' ) {
153 if ( ! ( $enabled_actions['smart_review'] ?? true ) ) {
154 return $this->render_state = false;
155 }
156
157 $quiz_result = $this->get_completed_quiz_result( $user_id, $item_id, $course_id );
158 if ( $quiz_result === false ) {
159 return $this->render_state = false;
160 }
161
162 $enabled_actions = array(
163 'summarize' => false,
164 'explain' => false,
165 'quick_quiz' => false,
166 'smart_review' => true,
167 );
168 $free_chat_enabled = false;
169 } else {
170 $enabled_actions['smart_review'] = false;
171
172 if ( ! $free_chat_enabled && ! in_array( true, $enabled_actions, true ) ) {
173 return $this->render_state = false;
174 }
175
176 $quiz_result = null;
177 }
178
179 return $this->render_state = array(
180 'context' => $context,
181 'item_id' => $item_id,
182 'item_type' => $item_type,
183 'course_id' => $course_id,
184 'enabled_actions' => $enabled_actions,
185 'free_chat_enabled' => $free_chat_enabled,
186 'quiz_result' => $quiz_result,
187 );
188 }
189
190 /**
191 * Enqueue assets and localize runtime data for the frontend widget.
192 *
193 * @param array $render_state Computed render state.
194 */
195 protected function localize_script_data( array $render_state ) {
196 $js_data = wp_json_encode(
197 array(
198 'ajaxUrl' => LP_Settings::url_handle_lp_ajax(),
199 'nonce' => wp_create_nonce( 'wp_rest' ),
200 'lessonId' => $render_state['item_id'],
201 'itemId' => $render_state['item_id'],
202 // Server-resolved curriculum type. The client echoes it back as item_type
203 // and the server re-validates it; it is transport, not proof.
204 'itemType' => $render_state['item_type'],
205 'courseId' => $render_state['course_id'],
206 'context' => $render_state['context'],
207 'quizCompleted' => $render_state['context'] === 'quiz',
208 'quizResult' => $render_state['quiz_result'],
209 'enabled' => true,
210 'freeChatEnabled' => $render_state['free_chat_enabled'],
211 'enabledActions' => $render_state['enabled_actions'],
212 'i18n' => array(
213 'you' => __( 'You', 'learnpress' ),
214 'assistant' => __( 'AI Assistant', 'learnpress' ),
215 'thinking' => __( 'Thinking...', 'learnpress' ),
216 'sendError' => __( 'An error occurred. Please try again.', 'learnpress' ),
217 'clearConfirm' => __( 'Clear chat history?', 'learnpress' ),
218 'quizPrompt' => __( 'Create a quick quiz from this lesson.', 'learnpress' ),
219 'explainPrompt' => __( 'Explain a concept from this lesson.', 'learnpress' ),
220 'summarizePrompt' => __( 'Summarize this lesson with key points.', 'learnpress' ),
221 'smartReviewPrompt' => __( 'Give me a smart review of my quiz results.', 'learnpress' ),
222 'quizCorrectTitle' => __( 'Correct', 'learnpress' ),
223 'quizWrongTitle' => __( 'Incorrect', 'learnpress' ),
224 ),
225 )
226 );
227
228 wp_add_inline_script( 'lp-ai-assistant', 'window.lpAIAssistant = ' . $js_data . ';', 'before' );
229 }
230
231 /**
232 * Backward-compatible entrypoint kept for external callers.
233 */
234 public function render_widget() {
235 $this->render_panel();
236 }
237
238 /**
239 * Render the shared footer wrapper for launcher buttons.
240 */
241 public function render_launcher_wrapper() {
242 ob_start();
243 do_action( self::FOOTER_LAUNCHERS_HOOK );
244 $launchers_html = trim( ob_get_clean() );
245
246 if ( '' === $launchers_html ) {
247 return;
248 }
249
250 printf(
251 '<div class="lp-footer-launchers" aria-label="%1$s">%2$s</div>',
252 esc_attr__( 'Learning tools', 'learnpress' ),
253 $launchers_html // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
254 );
255 }
256
257 /**
258 * Render the AI Assistant launcher into the shared wrapper.
259 */
260 public function render_launcher() {
261 if ( ! $this->get_render_state() ) {
262 return;
263 }
264
265 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
266 echo $this->html_toggle();
267 }
268
269 /**
270 * Render the AI Assistant panel on wp_footer.
271 */
272 public function render_panel() {
273 try {
274 $render_state = $this->get_render_state();
275 if ( ! $render_state ) {
276 return;
277 }
278
279 $this->localize_script_data( $render_state );
280 $this->html_panel_widget(
281 $render_state['free_chat_enabled'],
282 $render_state['enabled_actions']
283 );
284 } catch ( Throwable $e ) {
285 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
286 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
287 echo '<!-- LP AI Assistant render error: ' . esc_html( $e->getMessage() ) . ' -->';
288 }
289 }
290 }
291
292 /**
293 * Get quiz result if user has completed the specific quiz.
294 *
295 * Returns the result array from UserQuizModel::get_result() when the quiz
296 * status is LP_ITEM_COMPLETED, or false if not completed yet.
297 *
298 * @param int $user_id
299 * @param int $quiz_id
300 * @param int $course_id
301 *
302 * @return array|false Result array on completion, false otherwise.
303 */
304 private function get_completed_quiz_result( int $user_id, int $quiz_id, int $course_id ) {
305 if ( $user_id <= 0 || $quiz_id <= 0 || $course_id <= 0 ) {
306 return false;
307 }
308
309 $user_quiz = UserQuizModel::find_user_item(
310 $user_id,
311 $quiz_id,
312 LP_QUIZ_CPT,
313 $course_id,
314 LP_COURSE_CPT,
315 true
316 );
317
318 if ( ! $user_quiz instanceof UserQuizModel ) {
319 return false;
320 }
321
322 if ( ! method_exists( $user_quiz, 'get_status' ) || $user_quiz->get_status() !== LP_ITEM_COMPLETED ) {
323 return false;
324 }
325
326 if ( ! method_exists( $user_quiz, 'get_result' ) ) {
327 return false;
328 }
329
330 $result = $user_quiz->get_result();
331
332 return is_array( $result ) ? $result : false;
333 }
334
335 /**
336 * Toggle button that opens/closes the chat panel.
337 *
338 * @return string
339 */
340 public function html_toggle(): string {
341 $icon = '<span class="lp-icon lp-icon-graduation-cap"></span>';
342 $label = sprintf(
343 '<span class="lp-ai-assistant__toggle-label">%s</span>',
344 esc_html__( 'AI Assistant', 'learnpress' )
345 );
346
347 $section = apply_filters(
348 'learn-press/ai-assistant/html-toggle',
349 array(
350 'wrapper' => sprintf(
351 '<button type="button" class="lp-ai-assistant__toggle" aria-label="%s" aria-expanded="false" aria-controls="lp-ai-assistant-panel">',
352 esc_attr__( 'Open AI Learning Assistant', 'learnpress' )
353 ),
354 'icon' => $icon,
355 'label' => $label,
356 'wrapper_end' => '</button>',
357 )
358 );
359
360 return Template::combine_components( $section );
361 }
362
363 /**
364 * Panel header: title + clear and close action buttons.
365 *
366 * @return string
367 */
368 public function html_header(): string {
369 $title = sprintf(
370 '<h2 id="lp-ai-assistant-title" class="lp-ai-assistant__title">%s</h2>',
371 esc_html__( 'AI Learning Assistant', 'learnpress' )
372 );
373 $clear_btn = sprintf(
374 '<span class="lp-ai-assistant__clear-btn lp-icon-trash-o" aria-label="%1$s" title="%1$s" aria-hidden="true"></span>',
375 esc_attr__( 'Clear chat history', 'learnpress' ),
376 );
377 $close_btn = sprintf(
378 '<span class="lp-ai-assistant__close-btn lp-icon-angle-right" aria-label="%1$s" title="%1$s" aria-hidden="true"></span>',
379 esc_attr__( 'Close AI Assistant', 'learnpress' ),
380 );
381 $actions = Template::instance()->nest_elements(
382 array( '<div class="lp-ai-assistant__header-actions">' => '</div>' ),
383 sprintf( '%s%s', $clear_btn, $close_btn )
384 );
385
386 $section = apply_filters(
387 'learn-press/ai-assistant/html-header',
388 array(
389 'wrapper' => '<div class="lp-ai-assistant__header">',
390 'title' => $title,
391 'actions' => $actions,
392 'wrapper_end' => '</div>',
393 )
394 );
395
396 return Template::combine_components( $section );
397 }
398
399 /**
400 * Scrollable message log container (populated by JS).
401 *
402 * @return string
403 */
404 public function html_messages(): string {
405 $section = apply_filters(
406 'learn-press/ai-assistant/html-messages',
407 array(
408 'wrapper' => '<div class="lp-ai-assistant__messages-wrap">',
409 'messages' => '<div class="lp-ai-assistant__messages" role="log" aria-live="polite" aria-relevant="additions"></div>',
410 'wrapper_end' => '</div>',
411 )
412 );
413
414 return Template::combine_components( $section );
415 }
416
417 /**
418 * Quick-action buttons row (Summarize, Smart Review).
419 *
420 * @return string
421 */
422 public function html_quick_actions( array $enabled_actions = array() ): string {
423 $buttons = array();
424
425 if ( $enabled_actions['explain'] ?? true ) {
426 $buttons[] = sprintf(
427 '<button type="button" class="lp-ai-assistant__quick-btn" data-lp-ai-action="explain">%s</button>',
428 esc_html__( 'Explain Concept', 'learnpress' )
429 );
430 }
431
432 if ( $enabled_actions['quick_quiz'] ?? true ) {
433 $buttons[] = sprintf(
434 '<button type="button" class="lp-ai-assistant__quick-btn" data-lp-ai-action="quick-quiz">%s</button>',
435 esc_html__( 'Quick Quiz', 'learnpress' )
436 );
437 }
438
439 if ( $enabled_actions['summarize'] ?? true ) {
440 $buttons[] = sprintf(
441 '<button type="button" class="lp-ai-assistant__quick-btn" data-lp-ai-action="summarize">%s</button>',
442 esc_html__( 'Summarize Lesson', 'learnpress' )
443 );
444 }
445
446 if ( $enabled_actions['smart_review'] ?? true ) {
447 $buttons[] = sprintf(
448 '<button type="button" class="lp-ai-assistant__quick-btn lp-ai-assistant__smart-review-btn" data-lp-ai-action="smart-review">%s</button>',
449 esc_html__( 'Smart Review', 'learnpress' )
450 );
451 }
452
453 if ( empty( $buttons ) ) {
454 return '';
455 }
456
457 $section = apply_filters(
458 'learn-press/ai-assistant/html-quick-actions',
459 array(
460 'wrapper' => '<div class="lp-ai-assistant__quick-actions" role="group" aria-label="' . esc_attr__( 'AI assistant quick actions', 'learnpress' ) . '">',
461 'buttons' => implode( '', $buttons ),
462 'wrapper_end' => '</div>',
463 )
464 );
465
466 return Template::combine_components( $section );
467 }
468
469 /**
470 * Textarea + Send button input row.
471 *
472 * @return string
473 */
474 public function html_input_area(): string {
475 $textarea = sprintf(
476 '<textarea class="lp-ai-assistant__input" rows="1" aria-label="%s" placeholder="%s"></textarea>',
477 esc_attr__( 'Your message to the AI assistant', 'learnpress' ),
478 esc_attr__( 'Type your message', 'learnpress' )
479 );
480
481 $send_btn = sprintf(
482 '<span class="lp-ai-assistant__send-btn lp-icon-comment-o" aria-label="%1$s" title="%1$s" aria-hidden="true"></span>',
483 esc_attr__( 'Send message', 'learnpress' )
484 );
485
486 $section = apply_filters(
487 'learn-press/ai-assistant/html-input-area',
488 array(
489 'wrapper' => '<div class="lp-ai-assistant__input-area">',
490 'composer' => '<div class="lp-ai-assistant__composer">',
491 'textarea' => $textarea,
492 'send_btn' => $send_btn,
493 'composer_end' => '</div>',
494 'wrapper_end' => '</div>',
495 )
496 );
497
498 return Template::combine_components( $section );
499 }
500
501 /**
502 * Footer controls pinned to the bottom of the panel.
503 *
504 * @param bool $free_chat_enabled Whether to render the textarea/send-button input area.
505 * @param array $enabled_actions Enabled quick actions.
506 *
507 * @return string
508 */
509 public function html_panel_footer( bool $free_chat_enabled = true, array $enabled_actions = array() ): string {
510 $content = sprintf(
511 '%s%s',
512 $this->html_quick_actions( $enabled_actions ),
513 $free_chat_enabled ? $this->html_input_area() : ''
514 );
515
516 if ( '' === $content ) {
517 return '';
518 }
519
520 $section = apply_filters(
521 'learn-press/ai-assistant/html-panel-footer',
522 array(
523 'wrapper' => '<div class="lp-ai-assistant__panel-footer">',
524 'content' => $content,
525 'wrapper_end' => '</div>',
526 )
527 );
528
529 return Template::combine_components( $section );
530 }
531
532 /**
533 * Full chat panel (header + messages + quick actions + optional input area).
534 *
535 * @param bool $free_chat_enabled Whether to render the textarea/send-button input area.
536 *
537 * @return string
538 */
539 public function html_panel( bool $free_chat_enabled = true, array $enabled_actions = array() ): string {
540 $content = sprintf(
541 '%s<div class="lp-ai-assistant__panel-body">%s%s</div>',
542 $this->html_header(),
543 $this->html_messages(),
544 $this->html_panel_footer( $free_chat_enabled, $enabled_actions )
545 );
546
547 $panel_class = 'lp-ai-assistant__panel' . ( $free_chat_enabled ? '' : ' lp-ai-assistant-panel--quick-only' );
548
549 $section = apply_filters(
550 'learn-press/ai-assistant/html-panel',
551 array(
552 'wrapper' => sprintf(
553 '<div id="lp-ai-assistant-panel" class="%s" role="dialog" aria-labelledby="lp-ai-assistant-title" aria-modal="true" hidden>',
554 esc_attr( $panel_class )
555 ),
556 'content' => $content,
557 'wrapper_end' => '</div>',
558 )
559 );
560
561 return Template::combine_components( $section );
562 }
563
564 /**
565 * Root widget that contains only the floating panel.
566 *
567 * @param bool $free_chat_enabled Whether to render the full chat input area.
568 * @param array $enabled_actions Enabled quick actions.
569 */
570 public function html_panel_widget( bool $free_chat_enabled = true, array $enabled_actions = array() ) {
571 $section = apply_filters(
572 'learn-press/ai-assistant/html-panel-widget',
573 array(
574 'wrapper' => '<div id="lp-ai-assistant" class="lp-ai-assistant" aria-hidden="true">',
575 'panel' => $this->html_panel( $free_chat_enabled, $enabled_actions ),
576 'wrapper_end' => '</div>',
577 )
578 );
579
580 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
581 echo Template::combine_components( $section );
582 }
583
584 /**
585 * Outer floating widget: toggle button + panel, assembled from sub-components.
586 *
587 * Follows LP TemplateHook standard:
588 * - Each visual block is a dedicated `html_*()` method returning string.
589 * - Sections assembled via `Template::combine_components()`.
590 * - Each section wrapped in `apply_filters()` for extensibility.
591 *
592 * @param bool $free_chat_enabled Whether to render the full chat input area.
593 */
594 public function html_widget( bool $free_chat_enabled = true, array $enabled_actions = array() ) {
595 $section = apply_filters(
596 'learn-press/ai-assistant/html-widget',
597 array(
598 'wrapper' => '<div id="lp-ai-assistant" class="lp-ai-assistant" aria-hidden="true">',
599 'toggle' => $this->html_toggle(),
600 'panel' => $this->html_panel( $free_chat_enabled, $enabled_actions ),
601 'wrapper_end' => '</div>',
602 )
603 );
604
605 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
606 echo Template::combine_components( $section );
607 }
608 }
609