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
← All changes | inc/TemplateHooks/Course/CourseAIAssistantTemplate.php +608 -588 4.3.94.4.8 View file →
@@ -1,588 +1,608 @@
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 -}
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 +}