From c83106ea243ce356811acf6258fde5c3710d7ef3 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Thu, 23 Jul 2026 18:09:42 +0600 Subject: [PATCH 001/111] Add cart button integration for block and classic themes - Add Gutenberg cart button block for block theme integration - Enable cart button usage in any block theme - Add global cart button function for classic theme integration - Add [tutor_cart_button] shortcode for theme developers - Add customization options: show/hide icon, show/hide count, custom CSS class - Add cart count badge with WooCommerce-style positioning and styling --- CART_BUTTON_INTEGRATION.md | 280 ++++++++++++++++++ assets/src/js/front/tutor-cart-button.js | 44 +++ assets/src/js/front/tutor-front.js | 1 + .../src/js/gutenberg/cart-button/block.json | 40 +++ assets/src/js/gutenberg/cart-button/edit.js | 112 +++++++ assets/src/js/gutenberg/cart-button/index.js | 10 + .../src/js/gutenberg/cart-button/render.php | 48 +++ assets/src/js/gutenberg/cart-button/save.js | 3 + assets/src/scss/blocks/cart-button.scss | 30 ++ classes/Assets.php | 1 + classes/Gutenberg.php | 18 +- classes/Shortcode.php | 40 +++ includes/ecommerce-functions.php | 101 +++++++ rspack.config.mjs | 5 + 14 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 CART_BUTTON_INTEGRATION.md create mode 100644 assets/src/js/front/tutor-cart-button.js create mode 100644 assets/src/js/gutenberg/cart-button/block.json create mode 100644 assets/src/js/gutenberg/cart-button/edit.js create mode 100644 assets/src/js/gutenberg/cart-button/index.js create mode 100644 assets/src/js/gutenberg/cart-button/render.php create mode 100644 assets/src/js/gutenberg/cart-button/save.js create mode 100644 assets/src/scss/blocks/cart-button.scss diff --git a/CART_BUTTON_INTEGRATION.md b/CART_BUTTON_INTEGRATION.md new file mode 100644 index 0000000000..95fa3ffd76 --- /dev/null +++ b/CART_BUTTON_INTEGRATION.md @@ -0,0 +1,280 @@ +# Tutor LMS Cart Button Integration Guide + +This guide explains how to integrate the Tutor LMS native ecommerce cart button into any WordPress theme, similar to WooCommerce's cart button integration. + +## Overview + +The Tutor LMS plugin now provides a global cart button function that can be used by any theme to display the shopping cart button in the header or any other location. This works with Tutor's native ecommerce system (when monetization is set to 'tutor'). + +## Available Functions + +### 1. `tutor_ecommerce_cart_button( $args = array() )` + +Displays the cart button with icon and item count. + +**Parameters:** +- `$args` (array) - Optional arguments to customize the cart button: + - `class` (string) - CSS class for the cart button. Default: 'cart-contents' + - `title` (string) - Title attribute for the cart link. Default: 'View your shopping cart' + - `show_icon` (bool) - Whether to show the cart icon. Default: true + - `show_count` (bool) - Whether to show the cart item count. Default: true + - `icon_svg` (string) - Custom SVG icon. If not provided, default cart icon will be used + - `before_count` (string) - Text before cart count. Default: '(' + - `after_count` (string) - Text after cart count. Default: ')' + +**Example Usage:** + +```php +// Basic usage - default cart button + + +// Custom CSS class + 'my-theme-cart' ) ); ?> + +// Hide count, show only icon + false ) ); ?> + +// Custom icon and styling + 'header-cart-btn', + 'title' => 'View Cart', + 'before_count' => '', + 'after_count' => ' items', +) ); +?> +``` + +### 2. `tutor_ecommerce_get_cart_count()` + +Returns the current cart item count as an integer. + +**Example Usage:** + +```php + +``` + +## Shortcode Integration + +For themes that prefer shortcode integration, use: + +``` +[tutor_cart_button] +``` + +**Shortcode Attributes:** +- `class` - CSS class for the cart button +- `title` - Title attribute +- `show_icon` - "true" or "false" +- `show_count` - "true" or "false" +- `before_count` - Text before count +- `after_count` - Text after count + +**Example:** +``` +[tutor_cart_button class="my-cart" show_count="false"] +``` + +## Block Theme Integration (Gutenberg) + +For block-based themes (FSE - Full Site Editing), Tutor LMS provides a dedicated Gutenberg block: + +### Tutor Cart Button Block + +**Block Name:** `tutor-gutenberg/cart-button` +**Category:** Tutor +**Icon:** Cart + +**How to Use:** +1. In the WordPress block editor, search for "Tutor Cart Button" +2. Add the block to your header template or any other template +3. The block will automatically render the cart button with icon and count + +**Features:** +- Automatically detects if Tutor native ecommerce is enabled +- Shows cart icon and item count +- Links to the cart page +- Works in any block template (header, footer, etc.) + +**Note:** The block uses dynamic rendering and will display the cart button only when Tutor native ecommerce is active. + +**Important:** This block cannot be added directly to navigation menus. For navigation menu integration, see the section below. + +## Navigation Menu Integration + +### Classic Themes (PHP-based) + +For classic themes using `wp_nav_menu()`, you can manually add the cart button to your navigation menus by editing your theme's header template or using the `wp_nav_menu_items` filter in your theme. + +**Example - Adding to specific menu location via theme:** + +```php +// In your theme's functions.php +add_filter( 'wp_nav_menu_items', function( $items, $args ) { + if ( 'primary' === $args->theme_location && function_exists( 'tutor_ecommerce_cart_button' ) ) { + ob_start(); + tutor_ecommerce_cart_button(); + $cart_button = ob_get_clean(); + $items .= ''; + } + return $items; +}, 10, 2 ); +``` + +This approach gives themes full control over where the cart appears, similar to WooCommerce's philosophy. + +### Block Themes (FSE - Full Site Editing) + +For block themes, the `wp_nav_menu_items` filter does NOT work because block themes use the Navigation block instead of `wp_nav_menu()`. + +**Important Limitation:** WordPress Navigation blocks have a strict whitelist of allowed blocks. Neither custom blocks (like Tutor Cart Button) nor the Shortcode block can be added directly inside Navigation blocks. This is a WordPress core restriction. + +#### Recommended Approach for Block Themes + +Place the **Tutor Cart Button Gutenberg block** in your header template (outside the Navigation block): + +1. Go to Appearance → Editor (Site Editor) +2. Edit your Header template +3. Add the "Tutor Cart Button" block in the header area, alongside or near your Navigation block +4. The block will display the cart icon and count + +This is the standard approach used by WooCommerce and other plugins - the cart button is placed in the header template, not inside the Navigation block itself. + +#### Alternative: Custom Template Part with PHP + +For more control, create a custom template part: + +1. Go to Appearance → Editor (Site Editor) +2. Create a new template part (e.g., "Header Cart") +3. Add a "Custom HTML" block with the shortcode: `[tutor_cart_button]` +4. Insert this template part in your header + +#### Alternative: Theme Customization + +If you need the cart inside the navigation area and have access to theme files: + +1. Create a child theme +2. Override the header template +3. Use the PHP function: `` + +## Theme Integration Examples + +### Example 1: Simple Header Integration + +```php +// In your theme's header.php file + +
+ +
+ +``` + +### Example 2: Conditional Display (Like WooCommerce) + +```php +// Only show if Tutor native ecommerce is active +is_monetize_by_tutor() ) { + tutor_ecommerce_cart_button(); +} +?> +``` + +### Example 3: Custom Styling Integration + +```php +// In your theme's header with custom wrapper + +
+ 'theme-cart-link', + 'show_count' => true, + 'before_count' => '', + 'after_count' => '', + ) ); + ?> +
+ +``` + +### Example 4: Replacing the Current Starter Theme Implementation + +The current implementation in tutorstarter theme can be simplified: + +**Before (Current):** +```php +if ( class_exists( 'Tutor\Ecommerce\CartController' ) && 'tutor' === tutor_utils()->get_option( 'monetize_by' ) && 'header_fullwidth_center' !== get_theme_mod( 'header_type_select' ) ) { + $tutor_native_cart_controller = new CartController(); + if ( true === get_theme_mod( 'cart_btn_toggle', true ) ) { + $items = $tutor_native_cart_controller->get_cart_items()['courses']; + ?> + + + ... + + get_cart_items()['courses']['total_count'] ); + } ?> + + + + 0) { + counter.textContent = event.detail.cart_count; + counter.style.display = 'inline-block'; + } + }); + }); + + // Listen for remove from cart event + document.addEventListener('tutorRemoveCartEvent', function (event) { + const cartCounters = document.querySelectorAll('.tutor-cart-count'); + cartCounters.forEach(function (counter) { + const mode = counter.getAttribute('data-show-count') || 'if_has_items'; + + if (event.detail.cart_count > 0) { + counter.textContent = event.detail.cart_count; + counter.style.display = 'inline-block'; + } else { + if (mode === 'always') { + counter.textContent = '0'; + counter.style.display = 'inline-block'; + } else { + counter.textContent = ''; + counter.style.display = 'none'; + } + } + }); + }); +}); diff --git a/assets/src/js/front/tutor-front.js b/assets/src/js/front/tutor-front.js index ac1229e3d5..b47995c7ae 100644 --- a/assets/src/js/front/tutor-front.js +++ b/assets/src/js/front/tutor-front.js @@ -8,6 +8,7 @@ import './pages/cart'; import './pages/checkout'; import './pages/course-landing'; import './pages/instructor-list-filter'; +import './tutor-cart-button'; /** * Codes from this file should be decentralized according to relavent file/folder structure. diff --git a/assets/src/js/gutenberg/cart-button/block.json b/assets/src/js/gutenberg/cart-button/block.json new file mode 100644 index 0000000000..16778b3a18 --- /dev/null +++ b/assets/src/js/gutenberg/cart-button/block.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "tutor-gutenberg/cart-button", + "title": "Tutor Cart Button", + "category": "tutor", + "icon": "cart", + "description": "Display a cart button with item count for Tutor LMS ecommerce", + "attributes": { + "showCount": { + "type": "string", + "default": "if_has_items" + }, + "customClass": { + "type": "string", + "default": "cart-contents" + }, + "iconColor": { + "type": "string", + "default": "" + }, + "badgeBgColor": { + "type": "string", + "default": "#e74c3c" + }, + "badgeTextColor": { + "type": "string", + "default": "#ffffff" + } + }, + "supports": { + "spacing": { + "margin": true, + "padding": true + } + }, + "editorScript": "tutor-gutenberg-cart-button", + "style": "tutor-cart-button", + "render": "file:./render.php" +} diff --git a/assets/src/js/gutenberg/cart-button/edit.js b/assets/src/js/gutenberg/cart-button/edit.js new file mode 100644 index 0000000000..4cb09f77e7 --- /dev/null +++ b/assets/src/js/gutenberg/cart-button/edit.js @@ -0,0 +1,112 @@ +import { InspectorControls, useBlockProps } from '@wordpress/block-editor'; +import { PanelBody, RadioControl, TextControl, BaseControl } from '@wordpress/components'; +import { ColorPalette } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +export default function Edit( { attributes, setAttributes } ) { + const { showCount, customClass, iconColor, badgeBgColor, badgeTextColor } = attributes; + + // Use a sample count for editor preview + const cartCount = 3; + + return ( + <> + + + setAttributes( { showCount: value } ) } + help={ __( 'The editor does not display the real count value, but a placeholder to indicate how it will look on the front-end.', 'tutor' ) } + /> + setAttributes( { customClass: value } ) } + placeholder="cart-contents" + /> + + + + + + + setAttributes( { iconColor: value } ) } + disableCustomColors={ false } + clearable={ true } + /> + + + setAttributes( { badgeBgColor: value } ) } + disableCustomColors={ false } + clearable={ true } + /> + + + + +
+
+ + + + + + + { ( showCount === 'always' || showCount === 'if_has_items' ) && ( + + { cartCount } + + ) } + +
+
+ + ); +} diff --git a/assets/src/js/gutenberg/cart-button/index.js b/assets/src/js/gutenberg/cart-button/index.js new file mode 100644 index 0000000000..b156efcd9f --- /dev/null +++ b/assets/src/js/gutenberg/cart-button/index.js @@ -0,0 +1,10 @@ +import { registerBlockType } from '@wordpress/blocks'; +import metadata from './block.json'; +import Edit from './edit'; +import save from './save'; + +registerBlockType( metadata.name, { + ...metadata, + edit: Edit, + save, +} ); diff --git a/assets/src/js/gutenberg/cart-button/render.php b/assets/src/js/gutenberg/cart-button/render.php new file mode 100644 index 0000000000..f1df34815d --- /dev/null +++ b/assets/src/js/gutenberg/cart-button/render.php @@ -0,0 +1,48 @@ + 'true', + 'show_count' => isset( $attributes['showCount'] ) ? $attributes['showCount'] : 'if_has_items', + 'class' => isset( $attributes['customClass'] ) && ! empty( $attributes['customClass'] ) ? $attributes['customClass'] : 'cart-contents', +); + +$shortcode = '[tutor_cart_button'; +foreach ( $atts as $key => $value ) { + $shortcode .= ' ' . $key . '="' . esc_attr( $value ) . '"'; +} +$shortcode .= ']'; + +// Build inline CSS variables for custom colors set in the block editor. +// The stylesheet consumes these via var(--tutor-cart-*) with hardcoded fallbacks, +// so no +
+ +
+ From 4e7214938c0409472dcc7bcb519c906f476bc84e Mon Sep 17 00:00:00 2001 From: b-l-i-n-d Date: Sun, 20 Sep 2026 23:41:29 +0600 Subject: [PATCH 083/111] fix(instructor-dashboard): give tiny completion-chart segments a minimum width --- .../dashboard/pages/instructor/home-charts.ts | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/assets/src/js/frontend/dashboard/pages/instructor/home-charts.ts b/assets/src/js/frontend/dashboard/pages/instructor/home-charts.ts index 392c3e3796..3f07fb30a2 100644 --- a/assets/src/js/frontend/dashboard/pages/instructor/home-charts.ts +++ b/assets/src/js/frontend/dashboard/pages/instructor/home-charts.ts @@ -70,34 +70,31 @@ const CHART_CONFIG = { overviewMaxHeight: 179, completionMaxHeight: 118, }, - aspectRatio: { overview: 3, }, - point: { radius: 4, hoverRadius: 6, borderWidth: 2, hoverBorderWidth: 3, }, - line: { width: 1.3, tension: 0.4, }, - bar: { thickness: 118, borderRadius: 5, borderWidth: 3, }, - + completionSegment: { + minWidth: 16, + }, gradient: { topOffset: -0.95, opacity: 0.9, }, - tooltip: { fontSize: '10px', lineHeight: '1.6', @@ -111,7 +108,6 @@ const CHART_CONFIG = { barOffsetX: 6, lineOffsetX: 12, }, - common: { devicePixelRatio: () => window.devicePixelRatio || 2, responsive: true, @@ -181,6 +177,22 @@ const getCompletionChartAnimation = (): { duration: 0 } | Record { + const total = values.reduce((sum, value) => sum + value, 0); + const canvasWidth = canvas?.clientWidth || 0; + const minValue = (minWidth / canvasWidth) * total; + const activeCount = values.filter((value) => value > 0).length; + + if (total <= 0 || minValue <= 0 || activeCount * minValue >= total) { + return [...values]; + } + + const padded = values.map((value) => (value > 0 ? Math.max(value, minValue) : 0)); + const paddedTotal = padded.reduce((sum, value) => sum + value, 0); + + return padded.map((value) => (value / paddedTotal) * total); +}; + const createChart = (canvas: HTMLCanvasElement, config: ChartConfiguration): void => { requestAnimationFrame(async () => { const Chart = await loadChartConstructor(); @@ -808,7 +820,7 @@ export const courseCompletionChart = (data: CourseCompletionChartData) => ({ createChartConfig(data: CourseCompletionChartData): ChartConfiguration<'bar'> { const colors = extractCompletionChartColors(this.$refs.canvas); - const chartData = [ + const rawValues = [ data.enrolled.value, data.completed.value, data.in_progress.value, @@ -816,6 +828,8 @@ export const courseCompletionChart = (data: CourseCompletionChartData) => ({ data.cancelled.value, ]; + const chartData = applyMinSegmentWidth(rawValues, CHART_CONFIG.completionSegment.minWidth, this.$refs.canvas); + const chartColors = [colors.enrolled, colors.completed, colors.in_progress, colors.inactive, colors.cancelled]; const dataKeys: CourseCompletionChartDataKey[] = ['enrolled', 'completed', 'in_progress', 'inactive', 'cancelled']; @@ -870,7 +884,7 @@ export const courseCompletionChart = (data: CourseCompletionChartData) => ({ return data[dataKeys[item.datasetIndex]]?.label || ''; }, label: (item) => { - const value = item.raw as number; + const value = rawValues[item.datasetIndex] ?? 0; return `${value}`; }, }, From 731c394d883684cf8477d971a8aaf0b9fb2a114f Mon Sep 17 00:00:00 2001 From: b-l-i-n-d Date: Mon, 21 Sep 2026 08:22:36 +0600 Subject: [PATCH 084/111] fix(quiz): gate mark breakdown to instructors and reorder summary parameters - Hide Score: X/Y breakdown from students in legacy attempt details; it is now instructor-only. - Remove trailing .0 from whole-number achieved scores (1.0 renders as 1). - Move tutor_quiz_summary_parameters filter to the end of render_quiz_summary and add a passing_grade key anchor so add-on rows can be positioned before the Passing Grade row. - Add MINUS_CIRCLE icon and minus-circle SVG for the negative marking summary row. --- assets/icons/minus-circle.svg | 1 + assets/src/js/v3/shared/icons/types.ts | 1 + classes/Icon.php | 1 + classes/Quiz.php | 25 +++++++++++++------------ views/quiz/attempt-details.php | 9 +++------ 5 files changed, 19 insertions(+), 18 deletions(-) create mode 100644 assets/icons/minus-circle.svg diff --git a/assets/icons/minus-circle.svg b/assets/icons/minus-circle.svg new file mode 100644 index 0000000000..f711c9819a --- /dev/null +++ b/assets/icons/minus-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/src/js/v3/shared/icons/types.ts b/assets/src/js/v3/shared/icons/types.ts index 9bf9a9f33f..d286c09d83 100644 --- a/assets/src/js/v3/shared/icons/types.ts +++ b/assets/src/js/v3/shared/icons/types.ts @@ -269,6 +269,7 @@ export const icons = [ 'member', 'menu', 'minus', + 'minusCircle', 'minusSquare', 'monitorPlay', 'more', diff --git a/classes/Icon.php b/classes/Icon.php index 89635fb619..e0a651f86a 100644 --- a/classes/Icon.php +++ b/classes/Icon.php @@ -285,6 +285,7 @@ final class Icon { const MEMBER = 'member'; const MENU = 'menu'; const MINUS = 'minus'; + const MINUS_CIRCLE = 'minus-circle'; const MINUS_SQUARE = 'minus-square'; const MONITOR_PLAY = 'monitor-play'; const MORE = 'more'; diff --git a/classes/Quiz.php b/classes/Quiz.php index adb92b9f2a..06e766f881 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -2217,19 +2217,8 @@ public static function render_quiz_summary( $total_questions, $quiz_item_readabl ); } - /** - * Filter the quiz summary parameter rows. - * - * Allows Pro and add-ons to inject additional parameter rows (e.g. partial/negative marking). - * - * @since 4.1.0 - * - * @param array $quiz_summary Array of table rows for the quiz summary. - * @param int $quiz_id Quiz post ID. - */ - $quiz_summary = apply_filters( 'tutor_quiz_summary_parameters', $quiz_summary, $quiz_id ); - $quiz_summary[] = array( + 'key' => 'passing_grade', 'columns' => array( array( 'content' => '
@@ -2266,6 +2255,18 @@ public static function render_quiz_summary( $total_questions, $quiz_item_readabl ); } + /** + * Filter the quiz summary parameter rows. + * + * Allows Pro and add-ons to inject additional parameter rows (e.g. partial/negative marking). + * + * @since 4.1.0 + * + * @param array $quiz_summary Array of table rows for the quiz summary. + * @param int $quiz_id Quiz post ID. + */ + $quiz_summary = apply_filters( 'tutor_quiz_summary_parameters', $quiz_summary, $quiz_id ); + Table::make()->contents( $quiz_summary )->render(); } diff --git a/views/quiz/attempt-details.php b/views/quiz/attempt-details.php index 8813135e60..631f91026f 100644 --- a/views/quiz/attempt-details.php +++ b/views/quiz/attempt-details.php @@ -774,11 +774,8 @@ function ( $ans ) { $achieved_val = (float) ( $answer->achieved_mark ?? 0 ); $question_mark = (float) ( $answer->question_mark ?? 0 ); $qmark_str = ( floor( $question_mark ) === $question_mark ) ? (string) (int) $question_mark : (string) round( $question_mark, 2 ); - $achieved_str = (string) round( $achieved_val, 2 ); - if ( floor( $achieved_val ) === $achieved_val ) { - $achieved_str = number_format( $achieved_val, 1, '.', '' ); - } - $score_label = sprintf( + $achieved_str = ( floor( $achieved_val ) === $achieved_val ) ? (string) (int) $achieved_val : (string) round( $achieved_val, 2 ); + $score_label = sprintf( /* translators: 1: achieved marks, 2: total marks. */ esc_html__( 'Score: %1$s/%2$s', 'tutor' ), $achieved_str, @@ -790,7 +787,7 @@ function ( $ans ) { echo '' . esc_html( $badge_info['label'] ?? '' ) . ''; - do_action( 'tutor_quiz_attempt_details_result_badge_after', $answer, $answer_status ); + do_action( 'tutor_quiz_attempt_details_result_badge_after', $answer, $answer_status, $is_instructor_review ); if ( 'pending' !== $answer_status && 'skipped' !== $answer_status ) { echo '
' . esc_html( $score_label ) . '
'; From 23879959ef512e842687f9c102ae95741504f80e Mon Sep 17 00:00:00 2001 From: b-l-i-n-d Date: Mon, 21 Sep 2026 09:26:30 +0600 Subject: [PATCH 085/111] feat(quiz): add settings filter and standardize negative marking config keys --- assets/src/js/v3/@types/index.d.ts | 4 +- .../components/curriculum/QuizSettings.tsx | 171 +++++++++--------- .../components/modals/QuizModal.tsx | 6 +- .../entries/course-builder/services/quiz.ts | 4 +- assets/src/js/v3/shared/config/config.ts | 4 +- classes/QuizBuilder.php | 2 +- 6 files changed, 94 insertions(+), 97 deletions(-) diff --git a/assets/src/js/v3/@types/index.d.ts b/assets/src/js/v3/@types/index.d.ts index c5f1b6c375..5a1337c94b 100644 --- a/assets/src/js/v3/@types/index.d.ts +++ b/assets/src/js/v3/@types/index.d.ts @@ -193,8 +193,8 @@ declare global { has_active_membership_plans: boolean; enable_quiz_partial_marking: 'on' | 'off'; enable_quiz_negative_marking: 'on' | 'off'; - quiz_negative_mark_mode: 'percent' | 'fixed'; - quiz_negative_mark_amount: string | number; + quiz_negative_mark_type: 'percent' | 'fixed'; + quiz_negative_mark_value: string | number; }; tutor_currency: { symbol: string; diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx index 7079cab261..d4098dc3b1 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx @@ -38,7 +38,6 @@ import QuizSingleLayoutSvg from '@SharedImages/quiz-single-question.svg'; import FormQuizLayoutSelect from './FormQuizLayoutSelect'; const courseId = getCourseId(); -const isTutorPro = !!tutorConfig.tutor_pro_url; interface QuizSettingsProps { contentDripType: ContentDripType; @@ -99,7 +98,7 @@ const QuizSettings = ({ contentDripType }: QuizSettingsProps) => { const isLegacyLearningMode = tutorConfig.settings?.learning_mode === 'legacy'; const adminPartialEnabled = tutorConfig.settings?.enable_quiz_partial_marking === 'on'; const adminNegativeEnabled = tutorConfig.settings?.enable_quiz_negative_marking === 'on'; - const quizOptionPartialAlreadyOn = form.watch('quiz_option.enable_partial_marking'); + const partialMarkingEnabled = form.watch('quiz_option.enable_partial_marking'); const negativeMarkType = form.watch('quiz_option.negative_mark_type'); const negativeMarkingEnabled = form.watch('quiz_option.enable_negative_marking'); @@ -302,113 +301,111 @@ const QuizSettings = ({ contentDripType }: QuizSettingsProps) => {
- -
{__('Grading', 'tutor')}
-
+
{__('Grading', 'tutor')}
+
+ { + if (value > 100) { + return __('Passing grade cannot be greater than 100', 'tutor'); + } + + if (value < 0) { + return __('Passing grade cannot be less than 0', 'tutor'); + } + + return true; + }, + }} + render={(controllerProps) => ( + + )} + /> + + +
{ - if (value > 100) { - return __('Passing grade cannot be greater than 100', 'tutor'); - } - - if (value < 0) { - return __('Passing grade cannot be less than 0', 'tutor'); - } - - return true; - }, - }} render={(controllerProps) => ( - )} /> +

+ {__('Award credit for correct sub-answers on multi-part questions.', 'tutor')} +

+
- -
+ +
+
( - )} /> -

- {__('Award credit for correct sub-answers on multi-part questions.', 'tutor')} -

- - - -
-
- - -
+ { + const numericValue = Number(value); + if (numericValue < 0) return __('Negative mark value cannot be less than 0', 'tutor'); + if (negativeMarkType === 'percent' && numericValue > 100) + return __('Percentage penalty cannot be greater than 100', 'tutor'); + return true; + }, + }} render={(controllerProps) => ( - )} /> - - { - const numericValue = Number(value); - if (numericValue < 0) return __('Negative mark value cannot be less than 0', 'tutor'); - if (negativeMarkType === 'percent' && numericValue > 100) - return __('Percentage penalty cannot be greater than 100', 'tutor'); - return true; - }, - }} - render={(controllerProps) => ( - - )} - /> - -
-
-
-
+
+
+ +
{__('Timing', 'tutor')}
diff --git a/assets/src/js/v3/entries/course-builder/components/modals/QuizModal.tsx b/assets/src/js/v3/entries/course-builder/components/modals/QuizModal.tsx index e0a9d615fe..b4509e8b8b 100644 --- a/assets/src/js/v3/entries/course-builder/components/modals/QuizModal.tsx +++ b/assets/src/js/v3/entries/course-builder/components/modals/QuizModal.tsx @@ -13,8 +13,8 @@ import FormQuestionTitle from '@TutorShared/components/fields/quiz/FormQuestionT import type { ModalProps } from '@TutorShared/components/modals/Modal'; import ModalWrapper from '@TutorShared/components/modals/ModalWrapper'; -import { CURRENT_VIEWPORT, DEFAULT_QUIZ_ATTEMPTS_ALLOWED, modal } from '@TutorShared/config/constants'; import { tutorConfig } from '@TutorShared/config/config'; +import { CURRENT_VIEWPORT, DEFAULT_QUIZ_ATTEMPTS_ALLOWED, modal } from '@TutorShared/config/constants'; import { borderRadius, Breakpoint, colorTokens, spacing } from '@TutorShared/config/styles'; import { typography } from '@TutorShared/config/typography'; import Show from '@TutorShared/controls/Show'; @@ -104,8 +104,8 @@ const QuizModal = ({ open_ended_answer_characters_limit: 500, enable_partial_marking: false, enable_negative_marking: false, - negative_mark_type: tutorConfig.settings?.quiz_negative_mark_mode === 'fixed' ? 'fixed' : 'percent', - negative_mark_value: Number(tutorConfig.settings?.quiz_negative_mark_amount ?? 0.15), + negative_mark_type: tutorConfig.settings?.quiz_negative_mark_type === 'fixed' ? 'fixed' : 'percent', + negative_mark_value: Number(tutorConfig.settings?.quiz_negative_mark_value ?? 0.15), content_drip_settings: { unlock_date: '', after_xdays_of_enroll: 0, diff --git a/assets/src/js/v3/entries/course-builder/services/quiz.ts b/assets/src/js/v3/entries/course-builder/services/quiz.ts index 63004a456a..95ba6ba38a 100644 --- a/assets/src/js/v3/entries/course-builder/services/quiz.ts +++ b/assets/src/js/v3/entries/course-builder/services/quiz.ts @@ -222,9 +222,9 @@ export const convertQuizResponseToFormData = (quiz: QuizDetailsResponse, slotFie enable_negative_marking: quiz.quiz_option.enable_negative_marking === '1', negative_mark_type: quiz.quiz_option.negative_mark_type ?? - (tutorConfig.settings?.quiz_negative_mark_mode === 'fixed' ? 'fixed' : 'percent'), + (tutorConfig.settings?.quiz_negative_mark_type === 'fixed' ? 'fixed' : 'percent'), negative_mark_value: - quiz.quiz_option.negative_mark_value ?? Number(tutorConfig.settings?.quiz_negative_mark_amount ?? 0.15), + quiz.quiz_option.negative_mark_value ?? Number(tutorConfig.settings?.quiz_negative_mark_value ?? 0.15), content_drip_settings: quiz.quiz_option.content_drip_settings || { unlock_date: '', after_xdays_of_enroll: 0, diff --git a/assets/src/js/v3/shared/config/config.ts b/assets/src/js/v3/shared/config/config.ts index 560f9305aa..8c20192715 100644 --- a/assets/src/js/v3/shared/config/config.ts +++ b/assets/src/js/v3/shared/config/config.ts @@ -99,8 +99,8 @@ const defaultTutorConfig = { has_active_membership_plans: false, enable_quiz_partial_marking: 'off', enable_quiz_negative_marking: 'off', - quiz_negative_mark_mode: 'percent', - quiz_negative_mark_amount: 0.15, + quiz_negative_mark_type: 'percent', + quiz_negative_mark_value: 0.15, }, tutor_currency: { symbol: '', diff --git a/classes/QuizBuilder.php b/classes/QuizBuilder.php index f1f234f905..b4dc5f382a 100644 --- a/classes/QuizBuilder.php +++ b/classes/QuizBuilder.php @@ -826,7 +826,7 @@ public function save_quiz( $topic_id, $payload ) { do_action( ( $is_update ? 'tutor_quiz_updated' : 'tutor_initial_quiz_created' ), $quiz_id ); // Save quiz settings. - $quiz_option = Input::sanitize_array( $payload['quiz_option'] ?? array() ); //phpcs:ignore + $quiz_option = apply_filters( 'tutor_quiz_settings', Input::sanitize_array( $payload['quiz_option'] ?? array() ), $quiz_id ); //phpcs:ignore update_post_meta( $quiz_id, Quiz::META_QUIZ_OPTION, $quiz_option ); do_action( 'tutor_quiz_settings_updated', $quiz_id ); From 341a1db886abfba9a82f143ef6f1b6547bfb1a22 Mon Sep 17 00:00:00 2001 From: b-l-i-n-d Date: Mon, 21 Sep 2026 09:30:36 +0600 Subject: [PATCH 086/111] refactor(quiz): separate quiz settings sanitization from filter --- classes/QuizBuilder.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/classes/QuizBuilder.php b/classes/QuizBuilder.php index b4dc5f382a..5a0c9987fa 100644 --- a/classes/QuizBuilder.php +++ b/classes/QuizBuilder.php @@ -826,7 +826,8 @@ public function save_quiz( $topic_id, $payload ) { do_action( ( $is_update ? 'tutor_quiz_updated' : 'tutor_initial_quiz_created' ), $quiz_id ); // Save quiz settings. - $quiz_option = apply_filters( 'tutor_quiz_settings', Input::sanitize_array( $payload['quiz_option'] ?? array() ), $quiz_id ); //phpcs:ignore + $quiz_option = Input::sanitize_array( $payload['quiz_option'] ?? array() ); //phpcs:ignore + $quiz_option = apply_filters( 'tutor_quiz_settings', $quiz_option, $quiz_id ); update_post_meta( $quiz_id, Quiz::META_QUIZ_OPTION, $quiz_option ); do_action( 'tutor_quiz_settings_updated', $quiz_id ); From 2363d1c98bbac630d35e6ed50b663f4afbe1dbad Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Mon, 21 Sep 2026 10:28:45 +0600 Subject: [PATCH 087/111] Cart button block style fixed --- assets/src/blocks/cart-button/block.json | 2 +- assets/src/blocks/cart-button/style.scss | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/assets/src/blocks/cart-button/block.json b/assets/src/blocks/cart-button/block.json index 5c79f4d574..ee71cc794a 100644 --- a/assets/src/blocks/cart-button/block.json +++ b/assets/src/blocks/cart-button/block.json @@ -21,7 +21,7 @@ }, "badgeBgColor": { "type": "string", - "default": "#e74c3c" + "default": "#0c111d" }, "badgeTextColor": { "type": "string", diff --git a/assets/src/blocks/cart-button/style.scss b/assets/src/blocks/cart-button/style.scss index 3a0d79ba60..2d98d4b7cb 100644 --- a/assets/src/blocks/cart-button/style.scss +++ b/assets/src/blocks/cart-button/style.scss @@ -14,8 +14,8 @@ .tutor-cart-count { position: absolute; top: -8px; - inset-inline-end: -8px; - border-radius: 50%; + inset-inline-start: 8px; + border-radius: 24px; padding: 0 6px; text-align: center; font-weight: bold; @@ -25,6 +25,6 @@ height: 18px; line-height: 18px; color: var(--tutor-cart-badge-color, #ffffff); - background-color: var(--tutor-cart-badge-bg, #e74c3c); + background-color: var(--tutor-cart-badge-bg, #0c111d); } } From a7448f8ecb018f7d90fd1a2affcb178a260b75df Mon Sep 17 00:00:00 2001 From: shewa <30765517+shewa12@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:39:59 +0600 Subject: [PATCH 088/111] Removed the AI connectors localize data --- classes/Course.php | 73 ++-------------------------------------------- 1 file changed, 3 insertions(+), 70 deletions(-) diff --git a/classes/Course.php b/classes/Course.php index 60d0bd2899..828f226139 100644 --- a/classes/Course.php +++ b/classes/Course.php @@ -1571,81 +1571,14 @@ public function localize_course_builder_data( $data ) { $logo_id = absint( $full_settings['brand_logo_light'] ?? $full_settings['tutor_frontend_course_page_logo_id'] ?? 0 ); $settings['brand_logo_light'] = $logo_id > 0 ? wp_get_attachment_image_url( $logo_id, 'full' ) : ''; - $is_wp_ai_supported = function_exists( 'wp_ai_client_prompt' ) || class_exists( '\WordPress\AiClient\AiClient' ) || function_exists( 'wp_get_connectors' ); - $has_ai_connector = false; - if ( $is_wp_ai_supported ) { - if ( class_exists( '\TutorPro\TutorAI\Helper' ) && method_exists( '\TutorPro\TutorAI\Helper', 'has_ai_connector' ) ) { - $has_ai_connector = \TutorPro\TutorAI\Helper::has_ai_connector(); - } elseif ( function_exists( 'wp_get_connectors' ) ) { - $connectors = wp_get_connectors(); - foreach ( $connectors as $connector_id => $connector_data ) { - if ( 'ai_provider' !== ( $connector_data['type'] ?? '' ) ) { - continue; - } - $auth = $connector_data['authentication'] ?? array(); - if ( 'api_key' === ( $auth['method'] ?? '' ) ) { - $setting_name = $auth['setting_name'] ?? ''; - $env_var_name = $auth['env_var_name'] ?? ''; - $constant_name = $auth['constant_name'] ?? ''; - - $source = function_exists( '_wp_connectors_get_api_key_source' ) - ? _wp_connectors_get_api_key_source( $setting_name, $env_var_name, $constant_name ) - : ( function_exists( '\WordPress\AI\get_connector_api_key_source' ) - ? \WordPress\AI\get_connector_api_key_source( $setting_name, $env_var_name, $constant_name ) - : 'none' ); - - if ( 'none' !== $source ) { - $has_ai_connector = true; - break; - } - } elseif ( 'application_password' === ( $auth['method'] ?? '' ) ) { - if ( function_exists( 'wp_connectors_get_application_password_credentials' ) ) { - $creds = wp_connectors_get_application_password_credentials( $auth ); - if ( 'none' !== ( $creds['source'] ?? 'none' ) ) { - $has_ai_connector = true; - break; - } - } - } - } - } elseif ( function_exists( '\WordPress\AI\has_connector_authentication' ) && function_exists( '\WordPress\AI\get_ai_connectors' ) ) { - $connectors = \WordPress\AI\get_ai_connectors(); - foreach ( array_keys( $connectors ) as $connector_id ) { - if ( \WordPress\AI\has_connector_authentication( $connector_id ) ) { - $has_ai_connector = true; - break; - } - } - } - } - - $settings['is_wp_ai_supported'] = $is_wp_ai_supported; - $settings['has_ai_connector'] = $has_ai_connector; - $settings['connectors_admin_url'] = admin_url( 'options-connectors.php' ); - - if ( $is_wp_ai_supported ) { - $has_image_connector = false; - if ( $has_ai_connector ) { - if ( class_exists( '\TutorPro\TutorAI\Helper' ) && method_exists( '\TutorPro\TutorAI\Helper', 'has_image_generation_support' ) ) { - $has_image_connector = \TutorPro\TutorAI\Helper::has_image_generation_support(); - } elseif ( function_exists( '\WordPress\AI\has_image_generation_support' ) ) { - $has_image_connector = \WordPress\AI\has_image_generation_support(); - } - } - $settings['chatgpt_key_exist'] = tutor()->has_pro && $has_ai_connector; - $settings['has_image_connector'] = tutor()->has_pro && $has_image_connector; - } else { - $has_legacy_key = tutor()->has_pro && ! empty( $full_settings['chatgpt_api_key'] ?? '' ); - $settings['chatgpt_key_exist'] = $has_legacy_key; - $settings['has_image_connector'] = $has_legacy_key; - } - $settings['youtube_api_key_exist'] = ! empty( $full_settings['lesson_video_duration_youtube_api_key'] ?? '' ); $settings['enable_tax'] = Tax::get_setting( 'enable_tax', true ); $settings['is_tax_included_in_price'] = Tax::is_tax_included_in_price(); $settings['enable_individual_tax_control'] = Tax::get_setting( 'enable_individual_tax_control' ); + $settings = apply_filters( 'tutor_course_builder_settings', $settings, $course_id ); + $new_data = array( 'settings' => $settings ); $data = array_merge( $default_data, $new_data ); @@ -1700,7 +1633,7 @@ public function localize_course_builder_data( $data ) { $data['course_builder_additional_locales'] = tutils()->get_script_locale_data( 'tutor-course-builder-additional', $data['local'] ); } - $data = apply_filters( 'tutor_course_builder_localized_data', $data ); + $data = apply_filters( 'tutor_course_builder_localized_data', $data, $course_id ); return $data; } From dd341a82c92436adcdc1cea20cd81f1f41359919 Mon Sep 17 00:00:00 2001 From: b-l-i-n-d Date: Mon, 21 Sep 2026 11:39:26 +0600 Subject: [PATCH 089/111] style(quiz): Update styles and icons --- assets/icons/comment-outline.svg | 1 + .../components/curriculum/QuestionList.tsx | 2 ++ assets/src/js/v3/shared/icons/types.ts | 1 + .../components/_quiz-attempt-details.scss | 15 ++++----------- classes/Icon.php | 1 + .../quiz/attempt-details/questions/open-ended.php | 4 ++-- views/quiz/attempt-details.php | 4 ++-- 7 files changed, 13 insertions(+), 15 deletions(-) create mode 100644 assets/icons/comment-outline.svg diff --git a/assets/icons/comment-outline.svg b/assets/icons/comment-outline.svg new file mode 100644 index 0000000000..821b6acc17 --- /dev/null +++ b/assets/icons/comment-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx index ce9f5a210a..d127456fc1 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx @@ -764,6 +764,8 @@ const styles = { } `, questionTypeOptionLabel: css` + ${typography.small('regular')}; + color: ${colorTokens.text.hints}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/assets/src/js/v3/shared/icons/types.ts b/assets/src/js/v3/shared/icons/types.ts index d286c09d83..9f8c9de6c3 100644 --- a/assets/src/js/v3/shared/icons/types.ts +++ b/assets/src/js/v3/shared/icons/types.ts @@ -93,6 +93,7 @@ export const icons = [ 'collapsed', 'colorOption', 'command', + 'commentOutline', 'comments', 'commission', 'completed', diff --git a/assets/src/scss/frontend/components/_quiz-attempt-details.scss b/assets/src/scss/frontend/components/_quiz-attempt-details.scss index e5d2f34570..acef014756 100644 --- a/assets/src/scss/frontend/components/_quiz-attempt-details.scss +++ b/assets/src/scss/frontend/components/_quiz-attempt-details.scss @@ -538,12 +538,11 @@ body:has(#wpadminbar) } .tutor-quiz-open-ended-answer { + @include tutor-typography(p2, regular, primary); border: 1px solid $tutor-border-idle; border-radius: $tutor-radius-2xl; background-color: $tutor-surface-base; padding: $tutor-spacing-6; - @include tutor-typography(p2, regular, primary); - line-height: 1.5; word-break: break-word; &.is-empty { @@ -568,12 +567,8 @@ body:has(#wpadminbar) gap: $tutor-spacing-3; .tutor-input-field { - width: 80px; + width: 114px; margin-bottom: 0; - - .tutor-input-wrapper { - width: 80px; - } } } @@ -589,6 +584,7 @@ body:has(#wpadminbar) .tutor-quiz-add-feedback-btn { @include tutor-flex(row, center); + @include tutor-typography(p2, medium); gap: $tutor-spacing-2; background: none; border: none; @@ -596,7 +592,6 @@ body:has(#wpadminbar) margin-top: $tutor-spacing-4; cursor: pointer; color: $tutor-text-brand; - @include tutor-typography(p2, medium); @include tutor-transition(color); &:hover { @@ -634,7 +629,6 @@ body:has(#wpadminbar) &-content { @include tutor-typography(p2, regular, primary); - line-height: 1.5; margin-bottom: $tutor-spacing-4; } @@ -662,11 +656,10 @@ body:has(#wpadminbar) } .tutor-quiz-question-feedback-card { + @include tutor-typography(p2, regular, primary); background-color: $tutor-surface-brand-tertiary; border-radius: $tutor-radius-2xl; padding: $tutor-spacing-6; - @include tutor-typography(p2, regular, primary); - line-height: 1.5; word-break: break-word; } } diff --git a/classes/Icon.php b/classes/Icon.php index e0a651f86a..39cfa56341 100644 --- a/classes/Icon.php +++ b/classes/Icon.php @@ -110,6 +110,7 @@ final class Icon { const COLOR_OPTION = 'color-option'; const COMMAND = 'command'; const COMMENTS = 'comments'; + const COMMENT_OUTLINE = 'comment-outline'; const COMMISSION = 'commission'; const COMPLETED = 'completed'; const COMPLETED_CIRCLE = 'completed-circle'; diff --git a/templates/shared/components/quiz/attempt-details/questions/open-ended.php b/templates/shared/components/quiz/attempt-details/questions/open-ended.php index d85e8941c4..5a604a02d9 100644 --- a/templates/shared/components/quiz/attempt-details/questions/open-ended.php +++ b/templates/shared/components/quiz/attempt-details/questions/open-ended.php @@ -98,7 +98,6 @@ ->attr( 'min', '0' ) ->attr( 'max', $qmark_formatted ) ->attr( 'step', 'any' ) - ->attr( 'style', 'width: 80px;' ) ->attr( 'x-bind', 'register(' . wp_json_encode( $manual_mark_field ) . ', ' . wp_json_encode( $mark_validation_rules ) . ')' @@ -121,7 +120,8 @@ class="tutor-question-feedback" label( __( 'Add Feedback', 'tutor' ) ) - ->icon( Icon::COMMENTS ) + ->icon( Icon::COMMENT_OUTLINE, 'left', 20, 20 ) + ->flip_rtl() ->variant( Variant::LINK ) ->size( Size::SM ) ->attr( 'type', 'button' ) diff --git a/views/quiz/attempt-details.php b/views/quiz/attempt-details.php index 631f91026f..6fe6b0ddc0 100644 --- a/views/quiz/attempt-details.php +++ b/views/quiz/attempt-details.php @@ -847,8 +847,8 @@ function ( $ans ) { is_correct ?? null ) && (int) $answer->is_correct === QuizModel::ATTEMPT_ANSWER_CORRECT; - $answer_is_incorrect = null !== ( $answer->is_correct ?? null ) && (int) $answer->is_correct === QuizModel::ATTEMPT_ANSWER_INCORRECT; + $answer_is_correct = null !== ( $answer->is_correct ?? null ) && (int) QuizModel::ATTEMPT_ANSWER_CORRECT === $answer->is_correct; + $answer_is_incorrect = null !== ( $answer->is_correct ?? null ) && (int) QuizModel::ATTEMPT_ANSWER_INCORRECT === $answer->is_correct; ?>
diff --git a/assets/src/blocks/cart-button/render.php b/assets/src/blocks/cart-button/render.php index efd4e5c329..81a290725c 100644 --- a/assets/src/blocks/cart-button/render.php +++ b/assets/src/blocks/cart-button/render.php @@ -15,6 +15,7 @@ $atts = array( 'show_count' => isset( $attributes['showCount'] ) ? $attributes['showCount'] : 'if_has_items', 'class' => isset( $attributes['customClass'] ) && ! empty( $attributes['customClass'] ) ? $attributes['customClass'] : 'tutor-cart-button', + 'cart_icon' => isset( $attributes['cartIcon'] ) ? $attributes['cartIcon'] : 'cart', ); $shortcode = '[tutor_cart_button'; @@ -23,13 +24,16 @@ } $shortcode .= ']'; -// Build inline CSS variables for custom colors set in the block editor. +// Build inline CSS variables for custom colors and size set in the block editor. // The stylesheet consumes these via var(--tutor-cart-*) with hardcoded fallbacks, // so no