diff --git a/assets/core/ts/config/config.ts b/assets/core/ts/config/config.ts index 16008c8d53..355b6e4f67 100644 --- a/assets/core/ts/config/config.ts +++ b/assets/core/ts/config/config.ts @@ -86,7 +86,7 @@ const defaultTutorConfig = { instructor_can_change_course_author: 'off', instructor_can_manage_co_instructors: 'off', chatgpt_enable: 'off', - course_builder_logo_url: '', + brand_logo_light: '', chatgpt_key_exist: false, hide_admin_bar_for_users: 'off', enable_redirect_on_course_publish_from_frontend: 'off', diff --git a/assets/icons/dark.svg b/assets/icons/dark.svg new file mode 100644 index 0000000000..4b5a89f7c3 --- /dev/null +++ b/assets/icons/dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/footer.svg b/assets/icons/footer.svg new file mode 100644 index 0000000000..cf8df45ae0 --- /dev/null +++ b/assets/icons/footer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/src/js/admin-dashboard/segments/options.js b/assets/src/js/admin-dashboard/segments/options.js index b54ed904d4..e361832531 100644 --- a/assets/src/js/admin-dashboard/segments/options.js +++ b/assets/src/js/admin-dashboard/segments/options.js @@ -82,6 +82,73 @@ document.addEventListener('DOMContentLoaded', function () { }; } + /** + * Initialize a reusable image upload list field. + * + * Wires up the select/remove buttons rendered by the `image_upload_list` + * field type, so any similar field can reuse it with its own settings. + * + * @since 4.0.7 + * + * @param {Object} config + * @param {string} config.mediaTitle Media frame title. + * @param {string} config.mediaButtonText Media frame select button text. + * @param {string[]} [config.allowedMimeTypes] Restrict selectable attachment mime types. Empty means any image. + * @return {void} + */ + function initImageUploadList({ mediaTitle, mediaButtonText, allowedMimeTypes = [] }) { + $(document).on('click', '.tutor-image-upload-select', function (event) { + event.preventDefault(); + + const $upload = $(this).closest('.tutor-image-upload-item'); + const $input = $upload.find('.tutor-image-upload-input'); + const $preview = $upload.find('.tutor-image-upload-preview'); + const $image = $preview.find('img'); + const $remove = $upload.find('.tutor-image-upload-remove'); + const frame = wp.media({ + title: mediaTitle, + button: { text: mediaButtonText }, + library: { type: 'image' }, + multiple: false, + }); + + frame.on('select', function () { + const attachment = frame.state().get('selection').first().toJSON(); + if (allowedMimeTypes.length && !allowedMimeTypes.includes(attachment.mime)) { + tutor_toast(__('Error!', 'tutor'), __('Please select a supported image.', 'tutor'), 'error'); + return; + } + + $input.val(attachment.id).trigger('change'); + $image.attr('src', attachment.url).prop('hidden', false); + $preview.addClass('has-image'); + $upload.addClass('has-image'); + $remove.prop('hidden', false); + $('#save_tutor_option').prop('disabled', false); + }); + + frame.open(); + }); + + $(document).on('click', '.tutor-image-upload-remove', function (event) { + event.preventDefault(); + + const $upload = $(this).closest('.tutor-image-upload-item'); + $upload.find('.tutor-image-upload-input').val('').trigger('change'); + $upload.find('.tutor-image-upload-preview img').attr('src', '').prop('hidden', true); + $upload.find('.tutor-image-upload-preview').removeClass('has-image'); + $upload.removeClass('has-image'); + $(this).prop('hidden', true); + $('#save_tutor_option').prop('disabled', false); + }); + } + + initImageUploadList({ + mediaTitle: __('Select a brand logo', 'tutor'), + mediaButtonText: __('Use this logo', 'tutor'), + allowedMimeTypes: ['image/jpeg', 'image/png'], + }); + const validateEmail = (email) => { const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; return re.test(String(email).toLowerCase()); diff --git a/assets/src/js/admin-dashboard/segments/reset.js b/assets/src/js/admin-dashboard/segments/reset.js index 0ee942f629..49cec03a6b 100644 --- a/assets/src/js/admin-dashboard/segments/reset.js +++ b/assets/src/js/admin-dashboard/segments/reset.js @@ -47,7 +47,7 @@ const resetConfirmation = () => { if (xhttp.readyState === 4) { let pageData = JSON.parse(xhttp.response).data; pageData.forEach((item) => { - const field_types_associate = ['color_field', 'upload_full', 'checkbox_notification', 'checkgroup', 'group_radio_full_3', 'group_radio', 'radio_vertical', 'checkbox_horizontal', 'radio_horizontal', 'radio_horizontal_full', 'radio_horizontal_image', 'checkbox_vertical', 'toggle_switch', 'toggle_switch_button', 'text', 'textarea', 'email', 'hidden', 'select', 'number']; + const field_types_associate = ['color_field', 'upload_full', 'image_upload_list', 'toggle_matrix', 'checkbox_notification', 'checkgroup', 'group_radio_full_3', 'group_radio', 'radio_vertical', 'checkbox_horizontal', 'radio_horizontal', 'radio_horizontal_full', 'radio_horizontal_image', 'checkbox_vertical', 'toggle_switch', 'toggle_switch_button', 'text', 'textarea', 'email', 'hidden', 'select', 'number']; if (field_types_associate.includes(item.type)) { let itemName = 'tutor_option[' + item.key + ']'; @@ -84,6 +84,36 @@ const resetConfirmation = () => { }); } + } else if (item.type == 'image_upload_list') { + Object.values(item.items || {}).forEach((imageItem) => { + const imageInput = elementByName(`tutor_option[${imageItem.key}]`)[0]; + if (!imageInput) return; + + const uploadItem = imageInput.closest('.tutor-image-upload-item'); + const defaultValue = imageItem.default || ''; + const defaultImageUrl = uploadItem?.dataset.defaultImageUrl || ''; + imageInput.value = defaultValue; + uploadItem?.classList.toggle('has-image', !!defaultImageUrl); + uploadItem?.querySelector('.tutor-image-upload-preview')?.classList.toggle('has-image', !!defaultImageUrl); + const image = uploadItem?.querySelector('.tutor-image-upload-preview img'); + if (image) { + image.src = defaultImageUrl; + image.hidden = !defaultImageUrl; + } + }); + } else if (item.type == 'toggle_matrix') { + Object.values(item.items || {}).forEach((matrixItem) => { + Object.values(matrixItem).forEach((setting) => { + if (!setting || !setting.key) return; + const matrixInput = elementByName(`tutor_option[${setting.key}]`)[0]; + if (!matrixInput) return; + + const isEnabled = setting.default === 'on'; + matrixInput.value = isEnabled ? 'on' : 'off'; + const checkbox = matrixInput.nextElementSibling; + if (checkbox) checkbox.checked = isEnabled; + }); + }); } else if (item.type == 'upload_full') { elementItem.value = ''; elementItem.nextElementSibling.src = ''; @@ -193,4 +223,4 @@ if (null !== optionForm) { document.getElementById('save_tutor_option').disabled = false; } }); -} \ No newline at end of file +} diff --git a/assets/src/js/frontend/dashboard/index.ts b/assets/src/js/frontend/dashboard/index.ts index c0a3191914..1aefede2f4 100644 --- a/assets/src/js/frontend/dashboard/index.ts +++ b/assets/src/js/frontend/dashboard/index.ts @@ -4,6 +4,7 @@ import { initializeReviews } from '@FrontendComponents/reviews'; import { initializeTour } from '@FrontendComponents/tour'; import { initializeCommon } from '@FrontendServices/common'; +import { initializeSiteShell } from '@FrontendServices/site-shell'; import { initializeConfetti } from './confetti'; import { initializeHeader } from './header'; @@ -74,6 +75,7 @@ const getCurrentPage = (): string => { }; const initializeDashboard = () => { + initializeSiteShell(); initializeHeader(); initializeCommon(); initializeTour(); diff --git a/assets/src/js/frontend/learning-area/index.ts b/assets/src/js/frontend/learning-area/index.ts index f24c83fbb1..553c219dec 100644 --- a/assets/src/js/frontend/learning-area/index.ts +++ b/assets/src/js/frontend/learning-area/index.ts @@ -4,6 +4,7 @@ import { initializeReviews } from '@FrontendComponents/reviews'; import { initializeTour } from '@FrontendComponents/tour'; import { initializeCommon } from '@FrontendServices/common'; +import { initializeSiteShell } from '@FrontendServices/site-shell'; import { initializeCommon as initializeLearningAreaCommon } from './common'; import { initializeLesson } from './lesson'; @@ -21,6 +22,7 @@ const decodePathSegment = (segment: string): string => { }; const initializeLearningArea = () => { + initializeSiteShell(); initializeLearningAreaCommon(); initializeCommon(); initializeSidebar(); diff --git a/assets/src/js/frontend/services/site-shell.ts b/assets/src/js/frontend/services/site-shell.ts new file mode 100644 index 0000000000..05b33c4613 --- /dev/null +++ b/assets/src/js/frontend/services/site-shell.ts @@ -0,0 +1,136 @@ +const CSS_VARIABLE_HEADER_OFFSET = '--tutor-site-header-offset'; +const CSS_VARIABLE_HEADER_OVERLAY_OFFSET = '--tutor-site-header-overlay-offset'; + +const WP_ADMIN_BAR_ID = 'wpadminbar'; + +const SITE_SHELL_ROOT_SELECTOR = '[data-tutor-learning-site-shell], [data-tutor-dashboard-site-shell]'; + +/** + * Returns the lowest visible bottom edge among the site header and admin bar. + * + * A normal-flow header contributes while it is visible, then naturally drops + * out of the boundary as the document scrolls. Fixed and sticky headers keep + * contributing because their bottom edge remains in the viewport. + */ +const getVisibleHeaderBoundary = (elements: HTMLElement[]): number => { + return elements.reduce((offset, element) => { + return Math.max(offset, element.getBoundingClientRect().bottom, 0); + }, 0); +}; + +/** + * A static (normal-flow) header only needs its computed position checked, + * not its inline style, since positioning is almost always set via CSS. + */ +const isStaticPosition = (element: HTMLElement): boolean => { + return window.getComputedStyle(element).position === 'static'; +}; + +class SiteShellController { + private animationFrame: number | null = null; + private resizeObserver: ResizeObserver | null = null; + private themeHeader: HTMLElement | null = null; + + constructor( + private readonly root: HTMLElement, + private readonly themeHeaderSelector: string, + ) {} + + start(): void { + this.themeHeader = this.findThemeHeader(); + this.updateOffset(); + + // A normal-flow (static) header naturally drops out of the visible + // boundary once scrolled past — its contribution to updateOffset() + // only matters on first paint, so skip the ongoing listeners/observer. + if (this.themeHeader && isStaticPosition(this.themeHeader)) { + return; + } + + if (this.themeHeader && typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver(() => this.scheduleOffsetUpdate()); + this.resizeObserver.observe(this.themeHeader); + } + + window.addEventListener('resize', this.scheduleOffsetUpdate); + window.addEventListener('scroll', this.scheduleOffsetUpdate, { passive: true }); + } + + destroy(): void { + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + + window.removeEventListener('resize', this.scheduleOffsetUpdate); + window.removeEventListener('scroll', this.scheduleOffsetUpdate); + + if (this.animationFrame !== null) { + window.cancelAnimationFrame(this.animationFrame); + this.animationFrame = null; + } + + this.themeHeader = null; + } + + private findThemeHeader(): HTMLElement | null { + if (!this.themeHeaderSelector) { + return null; + } + + try { + const header = document.querySelector(this.themeHeaderSelector); + return header instanceof HTMLElement ? header : null; + } catch { + return null; + } + } + + private scheduleOffsetUpdate = (): void => { + if (this.animationFrame !== null) { + return; + } + + this.animationFrame = window.requestAnimationFrame(() => { + this.animationFrame = null; + this.updateOffset(); + }); + }; + + private updateOffset(): void { + const adminBar = document.getElementById(WP_ADMIN_BAR_ID); + const elements = [this.themeHeader, adminBar].filter( + (element): element is HTMLElement => element instanceof HTMLElement, + ); + const offset = getVisibleHeaderBoundary(elements); + + this.root.style.setProperty(CSS_VARIABLE_HEADER_OFFSET, `${offset}px`); + + // The active-quiz form is positioned below a normal-flow theme header, + // but starts behind a fixed or stuck header. This value lets its + // absolutely positioned question panel reserve only the part of the + // visible boundary that overlays the Learning Area itself. + const rootTop = this.root.getBoundingClientRect().top; + const overlayOffset = Math.max(0, offset - Math.max(0, rootTop)); + this.root.style.setProperty(CSS_VARIABLE_HEADER_OVERLAY_OFFSET, `${overlayOffset}px`); + } +} + +/** + * Initializes all Tutor site-shell roots found in the current DOM. + * + * Returns a disposer that tears down every controller it created (removes + * listeners, disconnects observers, cancels pending rAFs). Callers are + * responsible for invoking it when the roots are removed or re-initialized, + * to avoid duplicate listeners/observers stacking up across repeated calls. + */ +export const initializeSiteShell = (): (() => void) => { + const controllers = Array.from(document.querySelectorAll(SITE_SHELL_ROOT_SELECTOR)).map((root) => { + const selector = root.dataset.tutorThemeHeaderSelector || ''; + const controller = new SiteShellController(root, selector); + controller.start(); + return controller; + }); + + return () => { + controllers.forEach((controller) => controller.destroy()); + }; +}; diff --git a/assets/src/js/v3/@types/index.d.ts b/assets/src/js/v3/@types/index.d.ts index eda7184e80..1e25dcf047 100644 --- a/assets/src/js/v3/@types/index.d.ts +++ b/assets/src/js/v3/@types/index.d.ts @@ -179,7 +179,7 @@ declare global { instructor_can_change_course_author: 'on' | 'off'; instructor_can_manage_co_instructors: 'on' | 'off'; chatgpt_enable: 'on' | 'off'; - course_builder_logo_url: string | false; + brand_logo_light: string | false; chatgpt_key_exist: boolean; hide_admin_bar_for_users: 'on' | 'off'; enable_redirect_on_course_publish_from_frontend: 'on' | 'off'; diff --git a/assets/src/js/v3/shared/components/Logo.tsx b/assets/src/js/v3/shared/components/Logo.tsx index 6c213b0d1a..887f63c769 100644 --- a/assets/src/js/v3/shared/components/Logo.tsx +++ b/assets/src/js/v3/shared/components/Logo.tsx @@ -12,15 +12,10 @@ interface LogoProps { } const Logo = ({ wrapperCss }: LogoProps) => { - const isTutorPro = !!tutorConfig.tutor_pro_url; - return ( ); diff --git a/assets/src/js/v3/shared/config/config.ts b/assets/src/js/v3/shared/config/config.ts index 6a9921788c..22d54dd255 100644 --- a/assets/src/js/v3/shared/config/config.ts +++ b/assets/src/js/v3/shared/config/config.ts @@ -85,7 +85,7 @@ const defaultTutorConfig = { instructor_can_change_course_author: 'off', instructor_can_manage_co_instructors: 'off', chatgpt_enable: 'off', - course_builder_logo_url: '', + brand_logo_light: '', chatgpt_key_exist: false, hide_admin_bar_for_users: 'off', enable_redirect_on_course_publish_from_frontend: 'off', diff --git a/assets/src/js/v3/shared/icons/types.ts b/assets/src/js/v3/shared/icons/types.ts index fd54be6096..2d92c90742 100644 --- a/assets/src/js/v3/shared/icons/types.ts +++ b/assets/src/js/v3/shared/icons/types.ts @@ -124,6 +124,7 @@ export const icons = [ 'css', 'csv', 'currency', + 'dark', 'dbf', 'delete', 'delete2', @@ -181,6 +182,7 @@ export const icons = [ 'fireDisabled', 'fla', 'font', + 'footer', 'freeShippingType', 'gift', 'giftCard', diff --git a/assets/src/scss/admin-dashboard/_tutor-admin.scss b/assets/src/scss/admin-dashboard/_tutor-admin.scss index 060031dc52..7c15e23981 100644 --- a/assets/src/scss/admin-dashboard/_tutor-admin.scss +++ b/assets/src/scss/admin-dashboard/_tutor-admin.scss @@ -291,6 +291,228 @@ body.tutor-backend-tutor_settings #wpbody-content { border-bottom: none; } +.tutor-image-upload-list-field { + .tutor-image-upload-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 15px; + padding: 12px; + border: 1px solid var(--tutor-border-color); + border-radius: 8px; + width: 100%; + min-width: 0; + } + + .tutor-image-upload-item { + display: grid; + grid-template-columns: minmax(160px, 1fr) minmax(240px, 320px); + gap: 16px; + min-height: 40px; + align-items: center; + width: 100%; + height: 100%; + min-width: 0; + + > *:last-child { + justify-self: end; + } + + @include breakpoint-max(782) { + grid-template-columns: 1fr; + gap: 10px; + } + } + + .tutor-image-upload-control { + position: relative; + max-width: 141px; + width: 100%; + } + + .tutor-image-upload-preview { + display: flex; + align-items: center; + justify-content: center; + max-height: 40px; + height: 100%; + padding: 12px; + background: #f7f8fa; + border-radius: 6px; + overflow: hidden; + + img { + max-width: 100%; + max-height: 40px; + height: 100%; + object-fit: contain; + } + + &:not(.has-image) { + display: none; + } + } + + .tutor-image-upload-actions { + display: none; + position: absolute; + inset: 0; + align-items: center; + justify-content: center; + gap: 10px; + background: rgba(34, 37, 43, 0.72); + border-radius: 6px; + + .tutor-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 6px; + border: 0; + border-radius: 4px; + background: #f3f5fa; + color: var(--tutor-color-primary); + font-size: 20px; + } + + .tutor-image-upload-remove { + background: #e53935; + color: #fff; + } + } + + .tutor-image-upload-preview.has-image:hover .tutor-image-upload-actions, + .tutor-image-upload-preview.has-image:focus-within .tutor-image-upload-actions { + display: flex; + } + + .tutor-image-upload-item.has-image .tutor-image-upload-empty { + display: none; + } + + .tutor-image-upload-empty { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + height: 100%; + max-height: 40px; + padding: 12px; + border: 1px dashed #d3d4d9; + border-radius: 6px; + background: #f7f8fa; + color: #69738a; + font-size: 13px; + + svg { + color: var(--tutor-color-primary); + } + } + + .tutor-image-upload-item[data-image-upload-theme='dark'] { + .tutor-image-upload-preview, + .tutor-image-upload-empty { + background: #25272d; + } + + .tutor-image-upload-empty { + border-color: #69738a; + color: #b8bdc8; + } + } + + @media (hover: none) { + .tutor-image-upload-preview.has-image .tutor-image-upload-actions { + display: flex; + } + } + + @include breakpoint-max(782) { + .tutor-image-upload-list { + padding: 0 16px; + } + + .tutor-image-upload-item { + grid-template-columns: 1fr; + gap: 10px; + padding: 16px 0; + } + } +} + +.tutor-toggle-matrix-field { + .tutor-toggle-matrix { + margin-top: 24px; + border: 1px solid var(--tutor-border-color); + border-radius: 6px; + overflow: hidden; + } + + .tutor-toggle-matrix-head, + .tutor-toggle-matrix-row { + display: grid; + grid-template-columns: minmax(180px, 1fr) 180px 180px; + align-items: center; + gap: 16px; + padding: 8px 12px; + } + + .tutor-toggle-matrix-row { + padding: 12px; + } + + .tutor-toggle-matrix-head { + background: #fff; + border-bottom: 1px solid var(--tutor-border-color); + font-size: 16px; + font-weight: 500; + + .tutor-toggle-matrix-head-icon { + padding: 4px; + background-color: rgba(244, 246, 249, 1); + border-radius: 50%; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + svg { + color: var(--tutor-color-primary); + } + + [data-header-icon='true'] { + transform: rotate(180deg); + } + } + } + + .tutor-toggle-matrix-column { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + + .tutor-toggle-matrix-toggle { + justify-self: center; + } + + @include breakpoint-max(782) { + .tutor-toggle-matrix-head, + .tutor-toggle-matrix-row { + grid-template-columns: minmax(140px, 1fr) 1fr 1fr; + gap: 8px; + padding: 16px; + } + + .tutor-toggle-matrix-row { + font-size: 16px; + } + } +} + .tutor-option-field-row input[type='text'], .tutor-option-field-row input[type='email'], .tutor-option-field-row input[type='number'], diff --git a/assets/src/scss/frontend/components/_brand-logo.scss b/assets/src/scss/frontend/components/_brand-logo.scss new file mode 100644 index 0000000000..2f968bbb29 --- /dev/null +++ b/assets/src/scss/frontend/components/_brand-logo.scss @@ -0,0 +1,15 @@ +// Theme-aware Tutor branding shared by the dashboard and learning area. + +.tutor-brand-logo-dark { + display: none; +} + +[data-tutor-theme='dark'] { + .tutor-brand-logo-light { + display: none; + } + + .tutor-brand-logo-dark { + display: inline; + } +} diff --git a/assets/src/scss/frontend/components/_index.scss b/assets/src/scss/frontend/components/_index.scss index eefc76093a..20504733d0 100644 --- a/assets/src/scss/frontend/components/_index.scss +++ b/assets/src/scss/frontend/components/_index.scss @@ -11,3 +11,4 @@ @forward 'quiz-summary'; @forward 'player'; @forward 'tour'; +@forward 'brand-logo'; diff --git a/assets/src/scss/frontend/components/_quiz-attempt-details.scss b/assets/src/scss/frontend/components/_quiz-attempt-details.scss index 57b474eb63..33dc002018 100644 --- a/assets/src/scss/frontend/components/_quiz-attempt-details.scss +++ b/assets/src/scss/frontend/components/_quiz-attempt-details.scss @@ -431,6 +431,36 @@ } } +// The summary page is normally an isolated screen. In a Tutor page shell, +// keep its sticky controls below the theme header and let the footer remain +// bounded by the page so the theme footer can follow it in normal flow. +.tutor-learning-area.tutor-has-site-shell .tutor-quiz-summary-page, +.tutor-dashboard-layout.tutor-has-site-shell .tutor-quiz-summary-page, +.tutor-dashboard-isolated-page-wrapper.tutor-has-site-shell .tutor-quiz-summary-page { + --tutor-quiz-summary-header-top: var(--tutor-site-header-offset); + --tutor-quiz-summary-footer-height: 0px; + + .tutor-quiz-summary-body { + padding-bottom: $tutor-spacing-4; + } + + .tutor-quiz-summary-footer { + position: sticky; + bottom: 0; + left: auto; + right: auto; + } +} + +body:has(#wpadminbar) + :is( + .tutor-learning-area.tutor-has-site-shell .tutor-quiz-summary-page, + .tutor-dashboard-layout.tutor-has-site-shell .tutor-quiz-summary-page, + .tutor-dashboard-isolated-page-wrapper.tutor-has-site-shell .tutor-quiz-summary-page + ) { + --tutor-quiz-summary-header-top: var(--tutor-site-header-offset); +} + .tutor-quiz-summary-feedback { @include tutor-flex(column); gap: $tutor-spacing-4; diff --git a/assets/src/scss/frontend/dashboard/layout/_account.scss b/assets/src/scss/frontend/dashboard/layout/_account.scss index 17c937667f..e125499e75 100644 --- a/assets/src/scss/frontend/dashboard/layout/_account.scss +++ b/assets/src/scss/frontend/dashboard/layout/_account.scss @@ -2,6 +2,9 @@ @use '@Core/scss/tokens' as *; .tutor-account-page-wrapper { + --tutor-site-header-offset: 0px; + --tutor-site-header-overlay-offset: 0px; + .tutor-account-header { position: sticky; top: 0; @@ -30,6 +33,18 @@ } } } + + &.tutor-has-site-shell { + padding-top: var(--tutor-site-header-overlay-offset); + + .tutor-account-header { + top: var(--tutor-site-header-offset); + } + } +} + +body:has(#wpadminbar) .tutor-account-page-wrapper.tutor-has-site-shell .tutor-account-header { + top: var(--tutor-site-header-offset); } .tutor-account-container { diff --git a/assets/src/scss/frontend/dashboard/layout/_header.scss b/assets/src/scss/frontend/dashboard/layout/_header.scss index 6617484ea3..15691ea745 100644 --- a/assets/src/scss/frontend/dashboard/layout/_header.scss +++ b/assets/src/scss/frontend/dashboard/layout/_header.scss @@ -280,3 +280,11 @@ } } } + +.tutor-dashboard-layout.tutor-has-site-shell .tutor-dashboard-header { + top: var(--tutor-site-header-offset); +} + +body:has(#wpadminbar) .tutor-dashboard-layout.tutor-has-site-shell .tutor-dashboard-header { + top: var(--tutor-site-header-offset); +} diff --git a/assets/src/scss/frontend/dashboard/layout/_layout.scss b/assets/src/scss/frontend/dashboard/layout/_layout.scss index 3eeba22574..b594b2084f 100644 --- a/assets/src/scss/frontend/dashboard/layout/_layout.scss +++ b/assets/src/scss/frontend/dashboard/layout/_layout.scss @@ -5,6 +5,9 @@ @use '@Core/scss/mixins' as *; .tutor-dashboard-layout { + --tutor-site-header-offset: 0px; + --tutor-site-header-overlay-offset: 0px; + display: grid; grid-template-columns: 274px 1fr; @@ -23,6 +26,25 @@ padding-top: $tutor-spacing-1; } } + + &.tutor-has-site-footer .tutor-dashboard-page { + @include tutor-breakpoint-down(sm) { + padding-bottom: $tutor-spacing-7; + } + } + + &.tutor-has-site-shell { + padding-top: var(--tutor-site-header-overlay-offset); + } +} + +.tutor-dashboard-isolated-page-wrapper { + --tutor-site-header-offset: 0px; + --tutor-site-header-overlay-offset: 0px; + + &.tutor-has-site-shell { + padding-top: var(--tutor-site-header-overlay-offset); + } } .tutor-dashboard-page-card { diff --git a/assets/src/scss/frontend/dashboard/layout/_nav-mobile.scss b/assets/src/scss/frontend/dashboard/layout/_nav-mobile.scss index 4725a79212..2dba82c74a 100644 --- a/assets/src/scss/frontend/dashboard/layout/_nav-mobile.scss +++ b/assets/src/scss/frontend/dashboard/layout/_nav-mobile.scss @@ -93,3 +93,13 @@ } } } + +// A theme footer is part of the document-flow dashboard shell. Keep the +// mobile navigation at the viewport bottom while the dashboard is visible, +// then let it stop before the theme footer. +.tutor-dashboard-layout.tutor-has-site-footer .tutor-dashboard-nav-mobile { + position: sticky; + bottom: 0; + left: auto; + right: auto; +} diff --git a/assets/src/scss/frontend/dashboard/layout/_profile-header.scss b/assets/src/scss/frontend/dashboard/layout/_profile-header.scss index bdad94f55a..10aca1623a 100644 --- a/assets/src/scss/frontend/dashboard/layout/_profile-header.scss +++ b/assets/src/scss/frontend/dashboard/layout/_profile-header.scss @@ -54,3 +54,11 @@ } } } + +.tutor-has-site-shell .tutor-profile-header { + top: var(--tutor-site-header-offset); +} + +body:has(#wpadminbar) .tutor-has-site-shell .tutor-profile-header { + top: var(--tutor-site-header-offset); +} diff --git a/assets/src/scss/frontend/dashboard/layout/_sidebar.scss b/assets/src/scss/frontend/dashboard/layout/_sidebar.scss index d791179aa6..51f61a71f4 100644 --- a/assets/src/scss/frontend/dashboard/layout/_sidebar.scss +++ b/assets/src/scss/frontend/dashboard/layout/_sidebar.scss @@ -29,6 +29,11 @@ border-bottom: 1px solid $tutor-border-idle; overflow: hidden; + .tutor-brand-logo { + display: block; + max-width: 120px; + } + img { display: block; max-width: 120px; @@ -43,7 +48,6 @@ } } } - .tutor-dashboard-sidebar-nav { padding: $tutor-spacing-8 $tutor-spacing-6; @@ -115,3 +119,13 @@ } } } + +.tutor-dashboard-layout.tutor-has-site-shell .tutor-dashboard-sidebar { + top: var(--tutor-site-header-offset); + min-height: calc(100dvh - var(--tutor-site-header-offset)); +} + +body:has(#wpadminbar) .tutor-dashboard-layout.tutor-has-site-shell .tutor-dashboard-sidebar { + top: var(--tutor-site-header-offset); + min-height: calc(100dvh - var(--tutor-site-header-offset)); +} diff --git a/assets/src/scss/frontend/learning-area/_quiz.scss b/assets/src/scss/frontend/learning-area/_quiz.scss index 4688d6fa32..23ec26234e 100644 --- a/assets/src/scss/frontend/learning-area/_quiz.scss +++ b/assets/src/scss/frontend/learning-area/_quiz.scss @@ -1241,3 +1241,68 @@ $tutor-quiz-content-bottom-offset: calc( } } } + +// An active quiz is normally an isolated, viewport-fixed screen. When the +// Learning Area renders the theme shell, keep its controls inside the quiz +// instead so the theme footer remains in document flow after the attempt. +.tutor-learning-area.tutor-has-site-shell { + .tutor-quiz-header { + position: sticky; + top: var(--tutor-site-header-offset); + } + + .tutor-quiz-submission { + &[data-question-layout-view='single_question'], + &[data-question-layout-view='question_pagination'] { + height: calc(100dvh - var(--tutor-site-header-overlay-offset)); + position: relative; + } + + &[data-question-layout-view='single_question'] .tutor-quiz-footer, + &[data-question-layout-view='question_pagination'] .tutor-quiz-footer { + position: sticky; + margin-top: auto; + } + } + + .tutor-quiz-questions { + &[data-question-layout-view='single_question'], + &[data-question-layout-view='question_pagination'] { + top: calc(#{$tutor-quiz-header-offset} + var(--tutor-site-header-overlay-offset)); + height: calc( + 100% - #{$tutor-quiz-header-offset} - #{$tutor-quiz-footer-offset} - var(--tutor-site-header-overlay-offset) + ); + } + } + + .tutor-quiz-questions-pagination { + position: absolute; + } +} + +// Keep the site-shell geometry authoritative when the WordPress admin bar is +// present; the isolated-mode admin-bar selectors above intentionally win in +// every other context. +body:has(#wpadminbar) .tutor-learning-area.tutor-has-site-shell { + .tutor-quiz-header { + position: sticky; + top: var(--tutor-site-header-offset); + } + + .tutor-quiz-submission { + &[data-question-layout-view='single_question'], + &[data-question-layout-view='question_pagination'] { + height: calc(100dvh - var(--tutor-site-header-overlay-offset)); + } + } + + .tutor-quiz-questions { + &[data-question-layout-view='single_question'], + &[data-question-layout-view='question_pagination'] { + top: calc(#{$tutor-quiz-header-offset} + var(--tutor-site-header-overlay-offset)); + height: calc( + 100% - #{$tutor-quiz-header-offset} - #{$tutor-quiz-footer-offset} - var(--tutor-site-header-overlay-offset) + ); + } + } +} diff --git a/assets/src/scss/frontend/learning-area/layout/_footer.scss b/assets/src/scss/frontend/learning-area/layout/_footer.scss index 89a416c279..ad0b63996f 100644 --- a/assets/src/scss/frontend/learning-area/layout/_footer.scss +++ b/assets/src/scss/frontend/learning-area/layout/_footer.scss @@ -19,6 +19,13 @@ z-index: $tutor-z-footer; justify-content: space-between; + .tutor-has-site-shell & { + @include tutor-card-base(); + margin-top: $tutor-spacing-6; + border-radius: $tutor-radius-2xl; + position: sticky; + } + form { width: 50%; @@ -53,6 +60,11 @@ padding-block: $tutor-spacing-5; width: 100%; + .tutor-has-site-shell & { + border: none; + position: sticky; + } + form { width: auto; } diff --git a/assets/src/scss/frontend/learning-area/layout/_header.scss b/assets/src/scss/frontend/learning-area/layout/_header.scss index b3be4da2ad..a9afddb208 100644 --- a/assets/src/scss/frontend/learning-area/layout/_header.scss +++ b/assets/src/scss/frontend/learning-area/layout/_header.scss @@ -27,6 +27,11 @@ position: sticky; } + .tutor-has-site-shell & { + position: sticky; + top: var(--tutor-site-header-offset); + } + &-inner { @include tutor-flex(row, center); diff --git a/assets/src/scss/frontend/learning-area/layout/_layout.scss b/assets/src/scss/frontend/learning-area/layout/_layout.scss index 156e8a3ddd..edc8dd9220 100644 --- a/assets/src/scss/frontend/learning-area/layout/_layout.scss +++ b/assets/src/scss/frontend/learning-area/layout/_layout.scss @@ -7,6 +7,10 @@ $tutor-fixed-footer-height: 80px; $tutor-fixed-footer-offset: $tutor-spacing-6; .tutor-learning-area { + --tutor-site-header-offset: 0px; + --tutor-site-header-overlay-offset: 0px; + --tutor-learning-header-height: 64px; + background-color: $tutor-surface-base; .tutor-learning-area-body { @@ -23,20 +27,68 @@ $tutor-fixed-footer-offset: $tutor-spacing-6; padding-inline-start: 0; } - @include tutor-breakpoint-up(md) { + &.tutor-has-site-shell { + // Sticky positioning offsets the header visually without reserving that + // offset in normal flow. Reserve only the theme-header portion that + // overlaps the Learning Area so the body starts below the Tutor header. + padding-top: var(--tutor-site-header-overlay-offset); + .tutor-learning-area-content { - padding-top: 64px; + padding-top: 0; } - } - .tutor-expand-btn { - position: fixed; - inset-inline-start: $tutor-spacing-6; - bottom: $tutor-spacing-6; - color: $tutor-icon-secondary; + @include tutor-breakpoint-up(lg) { + .tutor-learning-area-body { + display: grid; + grid-template-columns: 320px minmax(0, 1fr); + align-items: start; + padding-inline-start: 0; + } + + .tutor-learning-sidebar { + position: sticky; + top: calc(var(--tutor-site-header-offset) + var(--tutor-learning-header-height)); + grid-column: 1; + width: 320px; + height: calc(100dvh - var(--tutor-site-header-offset) - var(--tutor-learning-header-height)); + } + + .tutor-learning-area-content { + grid-column: 2; + min-width: 0; + } + } @include tutor-breakpoint-down(lg) { - display: none; + --tutor-learning-header-height: 56px; + + .tutor-learning-sidebar { + top: var(--tutor-site-header-offset); + height: calc(100dvh - var(--tutor-site-header-offset)); + } + } + } + + &.tutor-has-site-shell.is-fullscreen { + @include tutor-breakpoint-up(lg) { + .tutor-learning-area-body { + grid-template-columns: 0 minmax(0, 1fr); + } + + .tutor-learning-sidebar { + padding: 0; + width: 0; + } + + .tutor-learning-area-content { + grid-column: 2; + } + } + } + + @include tutor-breakpoint-up(md) { + .tutor-learning-area-content { + padding-top: 64px; } } diff --git a/assets/src/scss/frontend/learning-area/layout/_sidebar.scss b/assets/src/scss/frontend/learning-area/layout/_sidebar.scss index d758d79502..45773fbbfd 100644 --- a/assets/src/scss/frontend/learning-area/layout/_sidebar.scss +++ b/assets/src/scss/frontend/learning-area/layout/_sidebar.scss @@ -13,6 +13,7 @@ width: 320px; inset-inline-start: 0; overflow: hidden; + z-index: $tutor-z-sidebar; @include tutor-transition((opacity, transform)); .tutor-has-admin-bar & { @@ -21,13 +22,32 @@ } .is-fullscreen & { - opacity: 0; pointer-events: none; transform: translateX(-100%); + overflow: visible; [dir='rtl'] & { transform: translateX(100%); } + + .tutor-learning-sidebar-curriculum, + .tutor-learning-sidebar-pages { + opacity: 0; + pointer-events: none; + } + + .tutor-expand-btn { + position: fixed; + inset-inline-start: $tutor-spacing-6; + bottom: $tutor-spacing-6; + pointer-events: auto; + transform: translateX(calc(100% + $tutor-spacing-6)); + z-index: calc($tutor-z-sidebar + 1); + + [dir='rtl'] & { + transform: translateX(calc(-100% - $tutor-spacing-6)); + } + } } @include tutor-breakpoint-down(lg) { @@ -40,6 +60,10 @@ overflow-y: auto; background-color: $tutor-surface-base; z-index: $tutor-z-sidebar; + + .tutor-has-site-shell & { + z-index: $tutor-z-highest; + } transform: translateX(100%); @include tutor-transition(transform, 0.3s); @@ -258,10 +282,6 @@ } } - @include tutor-breakpoint-up(lg) { - margin-bottom: $tutor-spacing-11; - } - .tutor-sidebar-resizer { position: absolute; top: -14px; @@ -419,4 +439,14 @@ } } } + + .tutor-expand-btn { + align-self: flex-start; + flex-shrink: 0; + color: $tutor-icon-secondary; + + @include tutor-breakpoint-down(lg) { + display: none; + } + } } diff --git a/classes/Course.php b/classes/Course.php index 279aa4ef33..e4c0b0976d 100644 --- a/classes/Course.php +++ b/classes/Course.php @@ -1553,11 +1553,12 @@ public function localize_course_builder_data( $data ) { 'instructor_can_manage_co_instructors', ); - $full_settings = get_option( 'tutor_option', array() ); - $settings = Options_V2::get_only( $required_options ); - $settings['course_builder_logo_url'] = wp_get_attachment_image_url( $full_settings['tutor_frontend_course_page_logo_id'] ?? 0, 'full' ); - $settings['chatgpt_key_exist'] = tutor()->has_pro && ! empty( $full_settings['chatgpt_api_key'] ?? '' ); - $settings['youtube_api_key_exist'] = ! empty( $full_settings['lesson_video_duration_youtube_api_key'] ?? '' ); + $full_settings = get_option( 'tutor_option', array() ); + $settings = Options_V2::get_only( $required_options ); + $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' ) : ''; + $settings['chatgpt_key_exist'] = tutor()->has_pro && ! empty( $full_settings['chatgpt_api_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(); diff --git a/classes/Icon.php b/classes/Icon.php index a69212ae4d..0154b3fba8 100644 --- a/classes/Icon.php +++ b/classes/Icon.php @@ -140,6 +140,7 @@ final class Icon { const CSS = 'css'; const CSV = 'csv'; const CURRENCY = 'currency'; + const DARK = 'dark'; const DBF = 'dbf'; const DELETE = 'delete'; const DELETE_2 = 'delete-2'; @@ -197,6 +198,7 @@ final class Icon { const FIRE_DISABLED = 'fire-disabled'; const FLA = 'fla'; const FONT = 'font'; + const FOOTER = 'footer'; const FREE_SHIPPING_TYPE = 'free-shipping-type'; const GIFT = 'gift'; const GIFT_CARD = 'gift-card'; diff --git a/classes/Options_V2.php b/classes/Options_V2.php index 9af44eae49..5a6f710ab0 100644 --- a/classes/Options_V2.php +++ b/classes/Options_V2.php @@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit; +use TUTOR\Icon; use TUTOR\Input; use Tutor\Traits\JsonResponse; use Tutor\Ecommerce\OptionKeys; @@ -529,10 +530,17 @@ public function tutor_option_save() { $option = (array) tutor_utils()->array_get( 'tutor_option', $_POST, array() ); //phpcs:ignore + unset( $option['enable_spotlight_mode'], $option['tutor_frontend_course_page_logo_id'] ); + + foreach ( array( 'brand_logo_light', 'brand_logo_dark' ) as $brand_logo_key ) { + $attachment_id = absint( $option[ $brand_logo_key ] ?? 0 ); + $option[ $brand_logo_key ] = $attachment_id && wp_attachment_is_image( $attachment_id ) ? $attachment_id : 0; + } + do_action( 'tutor_option_save_before', $option ); if ( ! isset( $option['tutor_login_page'] ) ) { - $option['tutor_login_page'] = $login_page_id ?? 0; + $option['tutor_login_page'] = $login_page_id ? $login_page_id : 0; } $option = Input::sanitize_array( @@ -633,6 +641,7 @@ public function tutor_option_default_save() { $attr = $this->get_setting_fields(); $tutor_default_option = get_option( 'tutor_default_option' ); $tutor_saved_option = get_option( 'tutor_option' ); + $attr_default = array(); foreach ( $attr as $sections ) { if ( is_array( $sections ) && count( $sections ) ) { @@ -667,6 +676,7 @@ public function tutor_option_default_save() { * @return void */ public function load_settings_page() { + $template_path = ''; extract( $this->get_setting_fields() ); //phpcs:ignore if ( ! $template_path ) { @@ -716,6 +726,7 @@ public function get_setting_fields() { $page_posts = get_posts( $page_args ); $course_archive_page_id = ( is_array( $page_posts ) && count( $page_posts ) ) ? $page_posts[0] : null; + $default_visibility = (bool) tutor_utils()->get_option( 'enable_spotlight_mode', true ) ? 'off' : 'on'; $attr = array( 'general' => array( @@ -857,14 +868,6 @@ public function get_setting_fields() { 'default' => 'on', 'desc' => __( 'Enabling this feature will show a course content summary on the Course Details page.', 'tutor' ), ), - array( - 'key' => 'enable_spotlight_mode', - 'type' => 'toggle_switch', - 'label' => __( 'Spotlight Mode', 'tutor' ), - 'default' => 'off', - 'label_title' => '', - 'desc' => __( 'This will hide the header and the footer and enable spotlight (full screen) mode when students view lessons.', 'tutor' ), - ), array( 'key' => 'auto_course_complete_on_all_lesson_completion', 'type' => 'toggle_switch', @@ -1227,7 +1230,7 @@ public function get_setting_fields() { 'icon' => 'tutor-icon-color-palette', 'blocks' => array( // Experimental. - 'block_appearance' => array( + 'block_appearance' => array( 'label' => __( 'Appearance', 'tutor' ), 'slug' => 'appearance', 'block_type' => 'uniform', @@ -1283,9 +1286,29 @@ public function get_setting_fields() { 'class' => 'color-picker-wrapper', 'default' => tutor_utils()->get_default_brand_color(), ), + array( + 'key' => 'brand_logo', + 'type' => 'image_upload_list', + 'label' => __( 'Brand Logo', 'tutor' ), + 'desc' => __( 'Upload your brand logo to display it in the Course Builder and Frontend Dashboard.', 'tutor' ), + 'items' => array( + 'light' => array( + 'label' => __( 'Light Mode', 'tutor' ), + 'key' => 'brand_logo_light', + 'icon' => Icon::LIGHT, + 'default' => get_tutor_option( 'tutor_frontend_course_page_logo_id', 0 ), + ), + 'dark' => array( + 'label' => __( 'Dark Mode', 'tutor' ), + 'key' => 'brand_logo_dark', + 'icon' => Icon::DARK, + 'default' => 0, + ), + ), + ), ), ), - 'block_course' => array( + 'block_course' => array( 'label' => __( 'Course', 'tutor' ), 'slug' => 'course', 'block_type' => 'uniform', @@ -1344,7 +1367,7 @@ public function get_setting_fields() { ), ), ), - 'layout' => array( + 'layout' => array( 'label' => __( 'Layout', 'tutor' ), 'slug' => 'layout', 'block_type' => 'uniform', @@ -1434,7 +1457,55 @@ public function get_setting_fields() { ), ), ), - 'course-details' => array( + 'block_page_elements' => array( + 'label' => __( 'Page Elements', 'tutor' ), + 'slug' => 'page-elements', + 'block_type' => 'uniform', + 'fields' => array( + array( + 'key' => 'page_elements', + 'type' => 'toggle_matrix', + 'label' => __( 'Header & Footer', 'tutor' ), + 'desc' => __( 'Control the visibility of Header and Footer for Dashboard and Learning Experience', 'tutor' ), + 'columns' => array( + 'header' => array( + 'label' => __( 'Header', 'tutor' ), + 'icon' => Icon::FOOTER, + 'icon_attrs' => array( 'data-header-icon' => 'true' ), + ), + 'footer' => array( + 'label' => __( 'Footer', 'tutor' ), + 'icon' => Icon::FOOTER, + ), + ), + 'items' => array( + 'dashboard' => array( + 'label' => __( 'Dashboard', 'tutor' ), + 'header' => array( + 'key' => 'show_dashboard_site_header', + 'default' => 'off', + ), + 'footer' => array( + 'key' => 'show_dashboard_site_footer', + 'default' => 'off', + ), + ), + 'learning' => array( + 'label' => __( 'Learning Experience', 'tutor' ), + 'header' => array( + 'key' => 'show_learning_site_header', + 'default' => $default_visibility, + ), + 'footer' => array( + 'key' => 'show_learning_site_footer', + 'default' => $default_visibility, + ), + ), + ), + ), + ), + ), + 'course-details' => array( 'label' => __( 'Course Details', 'tutor' ), 'slug' => 'course-details', 'block_type' => 'isolate', @@ -1964,7 +2035,7 @@ public function update_settings_log( $new_settings_data, $action_type ) { } if ( 'imported' === $action ) { - if ( ! empty( $save_import_data ) ) { + if ( ! empty( $save_import_data['dataset'] ) ) { update_option( 'tutor_option', $save_import_data['dataset'] ); } } @@ -1972,12 +2043,10 @@ public function update_settings_log( $new_settings_data, $action_type ) { $get_final_data = get_option( 'tutor_settings_log' ); } else { - if ( ! empty( $import_data ) ) { - update_option( 'tutor_settings_log', $import_data ); - } + update_option( 'tutor_settings_log', $import_data ); if ( 'imported' === $action ) { - if ( ! empty( $save_import_data ) ) { + if ( ! empty( $save_import_data['dataset'] ) ) { update_option( 'tutor_option', $save_import_data['dataset'] ); } } diff --git a/classes/Template.php b/classes/Template.php index f7be8b7b0c..549df85c3e 100644 --- a/classes/Template.php +++ b/classes/Template.php @@ -23,6 +23,27 @@ */ class Template extends Tutor_Base { + /** + * Page context for the Learning Area theme shell. + * + * @var string + */ + public const SITE_SHELL_CONTEXT_LEARNING = 'learning'; + + /** + * Page context for the Dashboard theme shell. + * + * @var string + */ + public const SITE_SHELL_CONTEXT_DASHBOARD = 'dashboard'; + + /** + * Default selector used to find common theme header markup. + * + * @var string + */ + private const THEME_HEADER_SELECTOR = 'header[role="banner"], header[role="heading"], header.site-header, header#masthead, #masthead, .site-header, header.wp-block-template-part, header.header-sticky'; + /** * Store Shortcode Object * @@ -30,6 +51,66 @@ class Template extends Tutor_Base { */ public $shortcode_obj; + /** + * Get the site-shell state for a Tutor page context. + * + * This is the single source of truth for theme header/footer visibility and + * the browser selector used to measure a rendered theme header. + * + * @since 4.0.7 + * + * @param string $context One of the SITE_SHELL_CONTEXT_* constants. + * + * @return array{ + * show_site_header: bool, + * show_site_footer: bool, + * has_site_shell: bool, + * theme_header_selector: string + * } + */ + public static function get_site_shell_data( string $context ): array { + if ( self::SITE_SHELL_CONTEXT_LEARNING === $context ) { + $legacy_spotlight_mode = (bool) tutor_utils()->get_option( 'enable_spotlight_mode' ); + $show_site_header = (bool) tutor_utils()->get_option( 'show_learning_site_header', ! $legacy_spotlight_mode ); + $show_site_footer = (bool) tutor_utils()->get_option( 'show_learning_site_footer', ! $legacy_spotlight_mode ); + } elseif ( self::SITE_SHELL_CONTEXT_DASHBOARD === $context ) { + $show_site_header = (bool) tutor_utils()->get_option( 'show_dashboard_site_header' ); + $show_site_footer = (bool) tutor_utils()->get_option( 'show_dashboard_site_footer' ); + } else { + return array( + 'show_site_header' => false, + 'show_site_footer' => false, + 'has_site_shell' => false, + 'theme_header_selector' => '', + ); + } + + $has_site_shell = $show_site_header || $show_site_footer; + $selector = ''; + + if ( $show_site_header ) { + /** + * Filters the selector used to find a theme header that overlays a Tutor page. + * + * Themes with non-standard header markup can return their exact selector. + * The selector is evaluated in the browser and invalid selectors are ignored. + * + * @since 4.0.6 + * + * @param string $selector CSS selector for the active theme header. + */ + $selector = apply_filters( 'tutor_theme_header_selector', self::THEME_HEADER_SELECTOR ); + $selector = is_string( $selector ) ? $selector : ''; + } + + return array( + 'show_site_header' => $show_site_header, + 'show_site_footer' => $show_site_footer, + 'has_site_shell' => $has_site_shell, + 'theme_header_selector' => $selector, + ); + } + /** * Register Hooks * diff --git a/classes/Utils.php b/classes/Utils.php index f383822fd3..4e3967fab9 100644 --- a/classes/Utils.php +++ b/classes/Utils.php @@ -301,6 +301,32 @@ public function get_option( $key, $default = false, $type = true, $from_options return apply_filters( $key, $value ); } + // Normalize legacy option keys that have been superseded by new settings. + // When we reach here, $key does not exist in $option (checked above). + if ( 'enable_spotlight_mode' === $key ) { + // Derive from new page-elements setting: spotlight mode = both header and footer hidden. + // Default to $default when new keys are absent (fresh install). + $show_header = $option['show_learning_site_header'] ?? null; + $show_footer = $option['show_learning_site_footer'] ?? null; + + if ( null !== $show_header || null !== $show_footer ) { + $header_off = ( null === $show_header || 'off' === $show_header || false === $show_header ); + $footer_off = ( null === $show_footer || 'off' === $show_footer || false === $show_footer ); + $is_spotlight = $header_off && $footer_off; + + return apply_filters( $key, $is_spotlight ); + } + return $this->get_option_default( $key, $default, $from_options ); + } + + if ( 'tutor_frontend_course_page_logo_id' === $key ) { + // Fall back to the new brand_logo_light key. + if ( array_key_exists( 'brand_logo_light', $option ) ) { + return apply_filters( $key, absint( $option['brand_logo_light'] ) ); + } + return $this->get_option_default( $key, $default, $from_options ); + } + return $this->get_option_default( $key, $default, $from_options ); } @@ -9018,49 +9044,104 @@ public function convert_date_into_wp_timezone( string $date, ?string $format = n /** * Tutor custom header. * + * Renders the page-opening HTML. When $show is true (default) the active + * theme header is included — either via get_header() for classic themes or + * a block template-part for block themes. When $show is false, only the + * bare standalone boilerplate (doctype / head / body) is output, with no + * theme header rendered. + * + * All existing callers that pass no argument continue to work unchanged. + * * @since 2.0.0 + * @since 4.0.6 Added optional $show param to suppress the theme header/footer. + * + * @param bool $show Whether to render the active theme header. Default true. * * @return void */ - public function tutor_custom_header() { + public function tutor_custom_header( bool $show = true ) { global $wp_version; - if ( version_compare( $wp_version, '5.9', '>=' ) && function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) { - ?> - - > - + $is_block_theme = version_compare( $wp_version, '5.9', '>=' ) + && function_exists( 'wp_is_block_theme' ) + && wp_is_block_theme(); + + if ( $show ) { + if ( $is_block_theme ) { + ?> + + > + - - - > - -
+ + + > + +
get( 'TextDomain' ); - echo do_blocks( '' ); + $theme = wp_get_theme(); + $theme_slug = $theme->get( 'TextDomain' ); + echo do_blocks( '' ); + } else { + get_header(); + } } else { - get_header(); + // Standalone boilerplate — no theme header rendered. + ?> + + > + + + + + + > + + , ) with no theme footer + * and no wp-site-blocks closing wrapper for block themes. + * + * All existing callers that pass no argument continue to work unchanged. * * @since 2.0.0 + * @since 4.0.6 Added optional $show param to suppress the theme header/footer. + * + * @param bool $show Whether to render the active theme footer. Default true. + * + * @return void */ - public function tutor_custom_footer() { + public function tutor_custom_footer( bool $show = true ) { global $wp_version; - if ( version_compare( $wp_version, '5.9', '>=' ) && function_exists( 'wp_is_block_theme' ) && true === wp_is_block_theme() ) { - $theme = wp_get_theme(); - $theme_slug = $theme->get( 'TextDomain' ); - echo do_blocks( '' ); - echo '
'; - wp_footer(); - echo ''; - echo ''; + $is_block_theme = version_compare( $wp_version, '5.9', '>=' ) + && function_exists( 'wp_is_block_theme' ) + && true === wp_is_block_theme(); + + if ( $show ) { + if ( $is_block_theme ) { + $theme = wp_get_theme(); + $theme_slug = $theme->get( 'TextDomain' ); + echo do_blocks( '' ); + echo '
'; + wp_footer(); + echo ''; + echo ''; + } else { + get_footer(); + } } else { - get_footer(); + // Standalone close — no theme footer rendered. + wp_footer(); + ?> + + + get_option( 'enable_spotlight_mode' ); - - if ( $enable_spotlight_mode || $full_screen ) { + $show_learning_area_header = (bool) tutor_utils()->get_option( 'show_learning_site_header' ); + if ( ! $show_learning_area_header || $full_screen ) { ?> > @@ -498,8 +507,8 @@ function get_tutor_header( $full_screen = false ) { * @return void */ function get_tutor_footer( $full_screen = false ) { - $enable_spotlight_mode = tutor_utils()->get_option( 'enable_spotlight_mode' ); - if ( $enable_spotlight_mode || $full_screen ) { + $show_learning_area_footer = (bool) tutor_utils()->get_option( 'show_learning_site_footer' ); + if ( ! $show_learning_area_footer || $full_screen ) { ?> @@ -635,8 +644,11 @@ function tutor_alert( $msg = null, $type = 'warning', $echo = true, $use_compone break; } - $html = Alert::make()->variant( $type )->text( $msg )->icon( $icon ); - return $echo ? $html->render() : $html; + $html = Alert::make()->variant( $type )->text( $msg )->icon( $icon )->get(); + if ( $echo ) { + echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + return $html; } $html = '
@@ -809,10 +821,8 @@ function tutor_maintenance_mode( $enable = false ) { if ( ! file_exists( $file ) ) { file_put_contents( $file, $maintenance_string ); } - } else { - if ( file_exists( $file ) ) { + } elseif ( file_exists( $file ) ) { unlink( $file ); - } } } } @@ -969,7 +979,6 @@ function _tutor_search_by_title_only( $search, $wp_query ) { */ function get_request( $var ) { return isset( $_REQUEST[ $var ] ) ? sanitize_text_field( $_REQUEST[ $var ] ) : false;//phpcs:ignore - } } @@ -1099,7 +1108,7 @@ function tutor_log() { */ function tutor_wc_price_currency_format( $amount ) { - $symbol = get_woocommerce_currency_symbol(); + $symbol = function_exists( 'get_woocommerce_currency_symbol' ) ? get_woocommerce_currency_symbol() : ''; $position = get_option( 'woocommerce_currency_pos', 'left' ); switch ( $position ) { @@ -1748,11 +1757,11 @@ function tutor_get_formatted_price( $price ) { $price = number_format( $price, $no_of_decimal, $decimal_separator, $thousand_separator ); $price = 'left' === $currency_position ? $currency_symbol . $price : $price . $currency_symbol; - } elseif ( 'wc' === $monetize_by ) { + } elseif ( 'wc' === $monetize_by && function_exists( 'wc_price' ) ) { $price = wc_price( $price ); - } elseif ( 'edd' === $monetize_by ) { - $price = edd_currency_filter( edd_format_amount( $price ) ); - } elseif ( 'pmpro' === $monetize_by ) { + } elseif ( 'edd' === $monetize_by && function_exists( 'edd_currency_filter' ) ) { + $price = edd_currency_filter( function_exists( 'edd_format_amount' ) ? edd_format_amount( $price ) : $price ); + } elseif ( 'pmpro' === $monetize_by && function_exists( 'pmpro_formatPrice' ) ) { $price = pmpro_formatPrice( $price ); } diff --git a/templates/account.php b/templates/account.php index fbfb1e3865..5fc371da07 100644 --- a/templates/account.php +++ b/templates/account.php @@ -11,6 +11,7 @@ defined( 'ABSPATH' ) || exit; use TUTOR\Dashboard; +use TUTOR\Template; global $wp_query; @@ -26,19 +27,23 @@ $close_url = $dashboard_url; Dashboard::set_document_title( $meta_title ); + +$site_shell = Template::get_site_shell_data( Template::SITE_SHELL_CONTEXT_DASHBOARD ); +$show_dashboard_site_header = $site_shell['show_site_header']; +$show_dashboard_site_footer = $site_shell['show_site_footer']; +$has_dashboard_site_shell = $site_shell['has_site_shell']; +$theme_header_selector = $site_shell['theme_header_selector']; + +tutor_utils()->tutor_custom_header( $show_dashboard_site_header ); ?> - -> - - - - - -> - - +
tutor_custom_header( $show_learning_site_header ); ?> - - > - - - - - - x-data="tutorCourseCompleteHandler()"> - +
'; + tutor_utils()->tutor_custom_footer( $show_learning_site_footer ); return; } @@ -118,8 +120,19 @@ $tutor_is_started_quiz = tutor_utils()->is_started_quiz( $tutor_current_content_id ); if ( $tutor_is_started_quiz ) { + ?> +
+ data-tutor-learning-site-shell + data-tutor-theme-header-selector="" + + > + '; + echo '
'; + tutor_utils()->tutor_custom_footer( $show_learning_site_footer ); exit; } } @@ -128,14 +141,29 @@ $user_action = Input::get( 'action' ); if ( Quiz::ACTION_VIEW_DETAILS === $user_action && $attempt_id ) { + ?> +
+ data-tutor-learning-site-shell + data-tutor-theme-header-selector="" + + > + '; + echo '
'; + tutor_utils()->tutor_custom_footer( $show_learning_site_footer ); exit; } ?>
+ data-tutor-learning-site-shell + data-tutor-theme-header-selector="" + x-data="{ sidebarOpen: false, isFullScreen: false }" :class="{ 'is-fullscreen': isFullScreen }" > @@ -158,19 +186,6 @@ class="tutor-learning-area
-
- - render(); } ?> - - \ No newline at end of file + +tutor_custom_footer( $show_learning_site_footer ); ?> diff --git a/templates/shared/brand-logo.php b/templates/shared/brand-logo.php new file mode 100644 index 0000000000..bebf3b57df --- /dev/null +++ b/templates/shared/brand-logo.php @@ -0,0 +1,49 @@ + + * @link https://themeum.com + * + * @since 4.0.6 + */ + +defined( 'ABSPATH' ) || exit; + +$site_name = get_bloginfo( 'name' ); +$custom_logo_id = absint( get_theme_mod( 'custom_logo' ) ); +$logo_ids = array( + 'light' => absint( get_tutor_option( 'brand_logo_light', 0 ) ), + 'dark' => absint( get_tutor_option( 'brand_logo_dark', 0 ) ), +); + +/** + * Resolve the first valid image URL from a list of candidate attachment IDs, + * checked in priority order. + * + * @param int[] $attachment_ids Candidate attachment IDs, highest priority first. + * @return string|false Image URL, or false if none of the candidates are valid images. + */ +$resolve_logo_url = static function ( array $attachment_ids ) { + foreach ( $attachment_ids as $attachment_id ) { + if ( $attachment_id && wp_attachment_is_image( $attachment_id ) ) { + return wp_get_attachment_image_url( $attachment_id, 'full' ); + } + } + return false; +}; +?> + diff --git a/templates/shared/components/quiz/attempt-details.php b/templates/shared/components/quiz/attempt-details.php index c4f1224276..b241541fda 100644 --- a/templates/shared/components/quiz/attempt-details.php +++ b/templates/shared/components/quiz/attempt-details.php @@ -34,6 +34,7 @@ $attempt_data = $attempt_data ?? null; $back_url = $back_url ?? get_permalink( $quiz_id ); $context = (string) ( $context ?? '' ); +$is_learning_area = ! empty( $is_learning_area ); $is_instructor_review = ! empty( $is_instructor_review ); if ( $attempt_id > 0 && ! $attempt_data ) { diff --git a/templates/single-content-loader.php b/templates/single-content-loader.php index b35777751d..97f36df402 100644 --- a/templates/single-content-loader.php +++ b/templates/single-content-loader.php @@ -19,8 +19,7 @@ $next_id = $contents->next_id; $user_id = get_current_user_id(); -$is_course_completed = tutor_utils()->is_completed_course( $course_id, $user_id ); -$enable_spotlight_mode = tutor_utils()->get_option( 'enable_spotlight_mode' ); +$is_course_completed = tutor_utils()->is_completed_course( $course_id, $user_id ); $data = isset( $data ) && is_array( $data ) ? $data : array(); $context = isset( $data['context'] ) ? sanitize_key( $data['context'] ) : ''; @@ -60,7 +59,7 @@ function tutor_course_single_sidebar( $echo = true, $context = 'desktop' ) { ?> -
+
diff --git a/templates/single-quiz.php b/templates/single-quiz.php index 01a7eba93a..d0cf83d805 100644 --- a/templates/single-quiz.php +++ b/templates/single-quiz.php @@ -19,7 +19,6 @@ $contents = tutor_utils()->get_course_prev_next_contents_by_id( $content_id ); $previous_id = $contents->previous_id; $course = CourseModel::get_course_by_quiz( get_the_ID() ); -$enable_spotlight_mode = tutor_utils()->get_option( 'enable_spotlight_mode' ); ob_start(); ?> diff --git a/views/options/field-types/image_upload_list.php b/views/options/field-types/image_upload_list.php new file mode 100644 index 0000000000..06af4ee5f3 --- /dev/null +++ b/views/options/field-types/image_upload_list.php @@ -0,0 +1,107 @@ + +
+
+
+ +
+ +
+
+ $item ) : + ++$index; + $default_attachment_id = absint( $item['default'] ?? 0 ); + $attachment_id = absint( get_tutor_option( $item['key'], $default_attachment_id ) ); + $image_url = $attachment_id && wp_attachment_is_image( $attachment_id ) ? wp_get_attachment_image_url( $attachment_id, 'medium' ) : ''; + $default_image_url = $default_attachment_id && wp_attachment_is_image( $default_attachment_id ) ? wp_get_attachment_image_url( $default_attachment_id, 'medium' ) : ''; + ?> +
+
+ name( $item['icon'] ) + ->size( 20 ) + ->ignore_kids() + ->render(); + ?> + +
+ +
+
+ /> +
+ + +
+
+ +
+
+ + + + +
+

+ name( Icon::INFO_OCTAGON ) + ->ignore_kids() + ->render(); + ?> + + +

+
diff --git a/views/options/field-types/toggle_matrix.php b/views/options/field-types/toggle_matrix.php new file mode 100644 index 0000000000..5cc948c6e6 --- /dev/null +++ b/views/options/field-types/toggle_matrix.php @@ -0,0 +1,101 @@ + +
+
+
+
+
+ +
+
+ + + + + + + + + +
+ + +
+
+ + + + +
+ + + + +
+