diff --git a/assets/front-end-onboarding/css/front-end-onboarding.css b/assets/front-end-onboarding/css/front-end-onboarding.css new file mode 100644 index 0000000000..ea924029d7 --- /dev/null +++ b/assets/front-end-onboarding/css/front-end-onboarding.css @@ -0,0 +1,327 @@ +@font-face { + font-family: Gilroy; + src: url( '../fonts/Gilroy-Regular.woff2' ) format('woff2'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: Gilroy-Bold; + src: url('../fonts/Gilroy-Bold.woff2') format('woff2'); + font-weight: 700; + font-style: normal; +} + +.prpl-popover-onboarding { + + & * { + box-sizing: border-box; + } + + font-family: Gilroy, sans-serif; + + padding: 24px 24px 14px 24px; + box-sizing: border-box; + + background: #fff; + border: 1px solid #9ca3af; + border-radius: 8px; + font-weight: 400; + max-height: 82vh; + width: 1200px; + max-width: 80vw; + + position: relative; + + &::backdrop { + background: rgba(0, 0, 0, 0.5); + } + + h1, h2, h3, h4, h5, h6 { + font-family: Gilroy-Bold, sans-serif; + } + + .prpl-popover-close { + position: absolute; + top: 0; + right: 0; + padding: 0.5em; + cursor: pointer; + background: none; + border: none; + color: #4b5563; + } + + .tour-header { + + .tour-title { + margin-top: 0; + font-size: 20px; + color: #38296d; + font-weight: 600; + } + } + + .tour-footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 10px; + } + + .tour-content { + font-size: 16px; + line-height: 1.5; + + p { + margin-bottom: 10px; + } + + img { + max-width: 100%; + height: auto; + } + + } + + .prpl-columns-wrapper-flex { + display: flex; + gap: 40px; + overflow: hidden; + padding-bottom: 10px; + + .prpl-column { + flex-grow: 1; + flex-basis: 50%; + + &.prpl-column-content { + padding: 20px; + border-radius: 6px; + background-color: #f6f5fb; + } + } + } + + .prpl-btn { + display: inline-block; + margin: 1rem 0; + padding: 0.75rem 1.25rem; + color: var(--prpl-color-button-primary-text); + text-decoration: none; + cursor: pointer; + font-size: 16px; + background: var(--prpl-color-button-primary); + line-height: 1.25; + box-shadow: none; + border: none; + border-radius: 6px; + transition: all 0.25s ease-in-out; + font-weight: 600; + text-align: center; + box-sizing: border-box; + position: relative; + z-index: 1; + + &:disabled { + opacity: 0.5; + pointer-events: none; + } + + &:not([disabled]):hover, + &:not([disabled]):focus { + background: var(--prpl-color-button-primary-hover); + } + + &.prpl-btn-secondary { + background: var(--prpl-background-banner); + color: var(--prpl-color-text);; + + &:not([disabled]):hover, + &:not([disabled]):focus { + background: var(--prpl-background-banner); + color: var(--prpl-color-text);; + } + } + } + + .prpl-complete-task-btn:not(.prpl-btn) { + border: none; + background: none; + cursor: pointer; + padding: 0; + margin: 0; + font-size: 16px; + color: #1e40af; + } + + .prpl-complete-task-btn-completed:not(.prpl-btn) { + color: #059669; + pointer-events: none; + opacity: 0.5; + } + + .prpl-complete-task-btn-error { + color: #9f0712; + } + + /* WIP for tasks which need user input. */ + .prpl-onboarding-task-form { + display: flex; + align-items: center; + gap: 0.5rem; + border: none; + padding: 0; + margin: 0; + background: none; + + input[type="text"] { + width: 100%; + padding: 0.5rem; + border: 1px solid #ccc; + border-radius: 0.25rem; + } + + /* WIP */ + select { + font-size: 14px; + line-height: 2; + color: #2c3338; + border-color: #8c8f94; + box-shadow: none; + border-radius: 3px; + padding: 0 24px 0 8px; + min-height: 30px; + + /* max-width: 25rem; */ + width: 100%; + -webkit-appearance: none; + background: #fff url(data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M5%206l5%205%205-5%202%201-7%207-7-7%202-1z%22%20fill%3D%22%23555%22%2F%3E%3C%2Fsvg%3E) no-repeat right 5px top 55%; + background-size: 16px 16px; + cursor: pointer; + vertical-align: middle; + } + + .prpl-complete-task-btn { + flex-shrink: 0; + } + } + + .prpl-complete-task-item { + display: flex; + gap: 30px; + justify-content: space-between; + } + + /* Welcome */ + &[data-prpl-step="0"] { + + .prpl-column:not(.prpl-column-content) { + display: flex; + justify-content: center; + align-items: center; + } + + #prpl-welcome-logo { + display: flex; + justify-content: center; + align-items: center; + + img, svg { + height: 150px; /* Set height since it is what we do in the PP dashboard. */ + } + } + } + + /* First task */ + .prpl-onboarding-task { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + + .prpl-onboarding-task-title { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #4b5563; + } + + .prpl-onboarding-task-form { + width: 100%; + flex-direction: column; + + .prpl-complete-task-btn { + align-self: flex-end; + + &.prpl-complete-task-btn-completed { + opacity: 0.5; + pointer-events: none; + } + } + } + } + + /* More tasks */ + .prpl-column:has(.prpl-task-list) { + display: flex; + align-items: center; + } + + .prpl-task-list { + list-style: none; + padding: 0; + margin: 0; + width: 100%; + + li { + padding: 7px 5px; + margin: 0; + + &:nth-child(odd) { + background-color: #f6f7f9; + } + + .task-title { + color: #4b5563; + font-weight: 500; + } + } + } + + /* File drop zone. */ + .prpl-file-drop-zone { + border: 2px dashed #aaa; + border-radius: 10px; + padding: 40px; + text-align: center; + color: #555; + transition: background 0.2s, border-color 0.2s; + cursor: pointer; + } + + .prpl-file-drop-zone.dragover { + background: #f0f8ff; + border-color: #007bff; + } + + .prpl-file-browse-link { + color: #007bff; + text-decoration: underline; + cursor: pointer; + } + + /* WIP */ + #prpl-upload-status { + margin-top: 10px; + font-family: monospace; + } +} + +/* Task popover is opened. */ +body:has(.prpl-task-popover) { + + /* Hide Tour (parent) popover, this will hide it's backdrop as well. */ + #prpl-popover-front-end-onboarding { + display: none; + } +} diff --git a/assets/front-end-onboarding/fonts/Gilroy-Bold.woff2 b/assets/front-end-onboarding/fonts/Gilroy-Bold.woff2 new file mode 100644 index 0000000000..e0ccbc4779 Binary files /dev/null and b/assets/front-end-onboarding/fonts/Gilroy-Bold.woff2 differ diff --git a/assets/front-end-onboarding/fonts/Gilroy-Regular.woff2 b/assets/front-end-onboarding/fonts/Gilroy-Regular.woff2 new file mode 100644 index 0000000000..78e41dce63 Binary files /dev/null and b/assets/front-end-onboarding/fonts/Gilroy-Regular.woff2 differ diff --git a/assets/front-end-onboarding/images/badge-gauge.png b/assets/front-end-onboarding/images/badge-gauge.png new file mode 100644 index 0000000000..9cd819bccd Binary files /dev/null and b/assets/front-end-onboarding/images/badge-gauge.png differ diff --git a/assets/front-end-onboarding/js/front-end-onboarding.js b/assets/front-end-onboarding/js/front-end-onboarding.js new file mode 100644 index 0000000000..d59b257330 --- /dev/null +++ b/assets/front-end-onboarding/js/front-end-onboarding.js @@ -0,0 +1,714 @@ +/** + * Progress Planner Tour + * Handles the front-end onboarding tour functionality + */ +/* global ProgressPlannerData */ + +// eslint-disable-next-line no-unused-vars +class ProgressPlannerTour { + constructor( config ) { + this.popoverId = 'prpl-popover-front-end-onboarding'; + this.config = config; + this.state = { + currentStep: 0, + data: { + moreTasksCompleted: {}, + firstTaskCompleted: false, + finished: false, + }, + cleanup: null, + }; + + this.tourSteps = this.initializeTourSteps(); + this.setupStateProxy(); + + // Set DOM related properties. + this.popover = document.getElementById( this.popoverId ); + this.contentWrapper = this.popover.querySelector( + '.tour-content-wrapper' + ); + this.nextBtn = this.popover.querySelector( '.prpl-tour-next' ); + this.dashboardBtn = this.popover.querySelector( '#prpl-dashboard-btn' ); + this.closeBtn = this.popover.querySelector( '#prpl-tour-close-btn' ); + + // Setup event listeners after DOM is ready + this.setupEventListeners(); + } + + /** + * Initialize tour steps configuration + */ + initializeTourSteps() { + return [ + { + id: 'welcome', + render: () => + document.getElementById( 'tour-step-welcome' ).innerHTML, + }, + { + id: 'first-task', + render: () => + document.getElementById( 'tour-step-first-task' ).innerHTML, + onMount: ( state ) => this.mountFirstTaskStep( state ), + canProceed: ( state ) => !! state.data.firstTaskCompleted, + }, + { + id: 'badges', + render: () => + document.getElementById( 'tour-step-badges' ).innerHTML, + }, + { + id: 'more-tasks', + render: () => + document.getElementById( 'tour-step-more-tasks' ).innerHTML, + onMount: ( state ) => this.mountMoreTasksStep( state ), + canProceed: ( state ) => { + return ( + Object.keys( state.data.moreTasksCompleted ).length > + 0 && + Object.values( state.data.moreTasksCompleted ).every( + Boolean + ) + ); + }, + }, + ]; + } + + /** + * Mount first task step + * @param {Object} state + */ + mountFirstTaskStep( state ) { + const btn = this.popover.querySelector( '#first-task-btn' ); + if ( ! btn ) return () => {}; + + const handler = ( e ) => { + const thisBtn = e.target.closest( 'button' ); + + const form = thisBtn.closest( 'form' ); // find parent form + let formValues = {}; + + if ( form ) { + const formData = new FormData( form ); + + // Convert to plain object + formValues = Object.fromEntries( formData.entries() ); + } + + ProgressPlannerTourUtils.completeTask( + thisBtn.dataset.taskId, + formValues + ) + .then( () => { + thisBtn.classList.add( 'prpl-complete-task-btn-completed' ); + state.data.firstTaskCompleted = { + [ thisBtn.dataset.taskId ]: true, + }; + + // If everything is completed advance to the next step. + this.nextStep(); + } ) + .catch( ( error ) => { + console.error( error ); + thisBtn.classList.add( 'prpl-complete-task-btn-error' ); + } ); + }; + + btn.addEventListener( 'click', handler ); + return () => btn.removeEventListener( 'click', handler ); + } + + /** + * Mount more tasks step + * @param {Object} state + */ + mountMoreTasksStep( state ) { + const moreTasks = this.popover.querySelectorAll( + '.prpl-task-item[data-task-id]' + ); + moreTasks.forEach( ( btn ) => { + state.data.moreTasksCompleted[ btn.dataset.taskId ] = false; + } ); + + this.tasks = Array.from( + this.popover.querySelectorAll( '[data-popover="task"]' ) + ).map( ( t ) => new PopoverTask( t ) ); + + const handler = ( e ) => { + // Update state. + state.data.moreTasksCompleted[ e.target.dataset.taskId ] = true; + }; + + this.popover.addEventListener( 'taskCompleted', ( e ) => handler( e ) ); + + return () => { + this.popover.removeEventListener( 'taskCompleted', handler ); + }; + } + + /** + * Render current step + */ + renderStep() { + const step = this.tourSteps[ this.state.currentStep ]; + + this.popover.querySelector( '.tour-content-wrapper' ).innerHTML = + step.render(); + + // Cleanup previous step + if ( this.state.cleanup ) { + this.state.cleanup(); + } + + // Mount current step + if ( typeof step.onMount === 'function' ) { + this.state.cleanup = step.onMount( this.state ); + } else { + this.state.cleanup = () => {}; + } + + // Update step indicator + this.popover.dataset.prplStep = this.state.currentStep; + this.updateButtonStates(); + this.updateNextButton(); + } + + /** + * Update button visibility states + */ + updateButtonStates() { + const isLastStep = this.state.currentStep === this.tourSteps.length - 1; + + // Toggle button visibility + this.nextBtn.style.display = + isLastStep || this.state.currentStep === 1 // We hide the "First task" step. + ? 'none' + : 'inline-block'; + this.dashboardBtn.style.display = isLastStep ? 'inline-block' : 'none'; + } + + /** + * Move to next step + */ + nextStep() { + console.log( + 'nextStep() called, current step:', + this.state.currentStep + ); + const step = this.tourSteps[ this.state.currentStep ]; + + if ( step.canProceed && ! step.canProceed( this.state ) ) { + console.log( 'Cannot proceed - step requirements not met' ); + return; + } + + if ( this.state.currentStep < this.tourSteps.length - 1 ) { + this.state.currentStep++; + console.log( 'Moving to step:', this.state.currentStep ); + this.saveProgressToServer(); + this.renderStep(); + } else { + console.log( 'Closing tour - reached last step' ); + this.closeTour(); + } + } + + /** + * Move to previous step + */ + prevStep() { + if ( this.state.currentStep > 0 ) { + this.state.currentStep--; + this.renderStep(); + } + } + + /** + * Close the tour + */ + closeTour() { + if ( this.popover ) { + this.popover.hidePopover(); + } + this.saveProgressToServer(); + + // Cleanup active step + if ( this.state.cleanup ) { + this.state.cleanup(); + } + + // Reset cleanup + this.state.cleanup = null; + } + + /** + * Start the tour + */ + startTour() { + if ( this.popover ) { + this.popover.showPopover(); + this.renderStep(); + } + } + + /** + * Save progress to server + */ + async saveProgressToServer() { + try { + const response = await fetch( this.config.adminAjaxUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams( { + state: JSON.stringify( this.state ), + nonce: this.config.nonceProgressPlanner, + action: 'progress_planner_tour_save_progress', + } ), + credentials: 'same-origin', + } ); + + if ( ! response.ok ) { + throw new Error( 'Request failed: ' + response.status ); + } + + return response.json(); + } catch ( error ) { + console.error( 'Failed to save tour progress:', error ); + } + } + + /** + * Update next button state + */ + updateNextButton() { + const step = this.tourSteps[ this.state.currentStep ]; + + if ( step.canProceed ) { + this.nextBtn.disabled = ! step.canProceed( this.state ); + } else { + this.nextBtn.disabled = false; + } + } + + /** + * Update DOM, used for reactive updates. + * All changes which should happen when the state changes should be done here. + */ + updateDOM() { + this.updateNextButton(); + } + + /** + * Get popover element + */ + getPopover() { + return document.getElementById( this.popoverId ); + } + + /** + * Setup event listeners + */ + setupEventListeners() { + console.log( 'Setting up event listeners...' ); + if ( this.popover ) { + console.log( 'Popover found:', this.popover ); + + this.popover.addEventListener( 'beforetoggle', ( event ) => { + if ( event.newState === 'open' ) { + console.log( 'Tour opened' ); + } + if ( event.newState === 'closed' ) { + console.log( 'Tour closed' ); + } + } ); + + if ( this.nextBtn ) { + this.nextBtn.addEventListener( 'click', () => { + console.log( 'Next button clicked!' ); + this.nextStep(); + } ); + } + + if ( this.dashboardBtn ) { + this.dashboardBtn.addEventListener( 'click', ( e ) => { + e.preventDefault(); + console.log( 'Dashboard button clicked!' ); + this.state.data.finished = true; + this.closeTour(); + + // Redirect to the dashboard. + window.location.href = + this.dashboardBtn.getAttribute( 'data-redirect-to' ); + } ); + } + + if ( this.closeBtn ) { + this.closeBtn.addEventListener( 'click', () => { + console.log( 'Close button clicked!' ); + this.state.data.finished = + this.state.currentStep === this.tourSteps.length - 1; + this.closeTour(); + } ); + } + } else { + console.error( 'Popover not found!' ); + } + } + + /** + * Setup state proxy for reactive updates + */ + setupStateProxy() { + this.state.data = this.createDeepProxy( this.state.data, () => + this.updateDOM() + ); + } + + /** + * Create deep proxy for nested object changes + * @param {Object} target + * @param {Function} callback + */ + createDeepProxy( target, callback ) { + // Recursively wrap existing nested objects first + for ( const key of Object.keys( target ) ) { + if ( + target[ key ] && + typeof target[ key ] === 'object' && + ! Array.isArray( target[ key ] ) + ) { + target[ key ] = this.createDeepProxy( target[ key ], callback ); + } + } + + return new Proxy( target, { + set: ( obj, prop, value ) => { + if ( + value && + typeof value === 'object' && + ! Array.isArray( value ) + ) { + value = this.createDeepProxy( value, callback ); + } + obj[ prop ] = value; + callback(); + return true; + }, + } ); + } +} + +// eslint-disable-next-line no-unused-vars +class PopoverTask { + constructor( el ) { + this.el = el; + this.id = el.dataset.taskId; + this.popover = null; + this.formValues = {}; + this.openPopoverBtn = el.querySelector( '[prpl-open-task-popover]' ); + + // Register popover open event, this is needed to be able to open the popover from the button. + this.openPopoverBtn?.addEventListener( 'click', () => this.open() ); + } + + registerEvents() { + this.popover.addEventListener( 'click', ( e ) => { + if ( e.target.classList.contains( 'prpl-complete-task-btn' ) ) { + const formData = new FormData( + this.popover.querySelector( 'form' ) + ); + this.formValues = Object.fromEntries( formData.entries() ); + this.complete(); + } + } ); + + this.popover + .querySelector( '.prpl-popover-close' ) + ?.addEventListener( 'click', () => this.close() ); + + this.setupFormValidation(); + + // Initialize upload handling (only if upload field exists) + this.setupFileUpload(); + + this.el.addEventListener( 'prplFileUploaded', ( e ) => { + // Handle file upload for the 'set site icon' task. + if ( 'core-siteicon' === e.detail.fileInput.dataset.taskId ) { + // Element which will be used to store the file post ID. + const nextElementSibling = + e.detail.fileInput.nextElementSibling; + + nextElementSibling.value = e.detail.filePost.id; + + // Trigger change so validation is triggered and "Complete" button is enabled. + nextElementSibling.dispatchEvent( + new CustomEvent( 'change', { + bubbles: true, + } ) + ); + } + } ); + } + + open() { + if ( this.popover ) return; + + const content = this.el + .querySelector( 'template' ) + .content.cloneNode( true ); + this.popover = document.createElement( 'div' ); + this.popover.className = + 'prpl-popover prpl-popover-onboarding prpl-task-popover'; + this.popover.setAttribute( 'popover', 'manual' ); + this.popover.appendChild( content ); + + // Add close button. + const closeBtn = document.createElement( 'button' ); + closeBtn.className = 'prpl-popover-close'; + closeBtn.setAttribute( 'popovertarget', this.popover.id ); + closeBtn.setAttribute( 'popovertargetaction', 'hide' ); + closeBtn.innerHTML = ''; + this.popover.appendChild( closeBtn ); + + document.body.appendChild( this.popover ); + + // Register events + this.registerEvents(); + + this.popover.showPopover(); + } + + close() { + this.popover?.remove(); + this.popover = null; + } + + complete() { + ProgressPlannerTourUtils.completeTask( this.id, this.formValues ) + .then( () => { + this.el.classList.add( 'completed' ); + this.el + .querySelector( '.prpl-complete-task-btn' ) + .classList.add( 'prpl-complete-task-btn-completed' ); + + this.close(); + this.notifyParent(); + } ) + .catch( ( error ) => { + console.error( error ); + // TODO: Handle error. + } ); + } + + notifyParent() { + const event = new CustomEvent( 'taskCompleted', { + bubbles: true, + detail: { id: this.id, formValues: this.formValues }, + } ); + this.el.dispatchEvent( event ); + } + + setupFormValidation() { + const form = this.popover.querySelector( 'form' ); + const submitButton = this.popover.querySelector( + '.prpl-complete-task-btn' + ); + + if ( ! form || ! submitButton ) return; + + const validateElements = form.querySelectorAll( '[data-validate]' ); + if ( validateElements.length === 0 ) return; + + const checkValidation = () => { + let isValid = true; + + validateElements.forEach( ( element ) => { + const validationType = element.getAttribute( 'data-validate' ); + let elementValid = false; + + switch ( validationType ) { + case 'required': + elementValid = + element.value !== null && + element.value !== undefined && + element.value !== ''; + break; + case 'not-empty': + elementValid = element.value.trim() !== ''; + break; + default: + elementValid = true; + } + + if ( ! elementValid ) { + isValid = false; + } + } ); + + submitButton.disabled = ! isValid; + }; + + checkValidation(); + validateElements.forEach( ( element ) => { + element.addEventListener( 'change', checkValidation ); + element.addEventListener( 'input', checkValidation ); + } ); + } + + /** + * Handles drag-and-drop or manual file upload for specific tasks. + * Only runs if the form contains an upload field. + */ + setupFileUpload() { + const uploadContainer = this.popover.querySelector( + '[data-upload-field]' + ); + if ( ! uploadContainer ) return; // no upload for this task + + const fileInput = uploadContainer.querySelector( 'input[type="file"]' ); + const statusDiv = uploadContainer.querySelector( + '.prpl-upload-status' + ); + + // Visual drag behavior + [ 'dragenter', 'dragover' ].forEach( ( event ) => { + uploadContainer.addEventListener( event, ( e ) => { + e.preventDefault(); + uploadContainer.classList.add( 'dragover' ); + } ); + } ); + + [ 'dragleave', 'drop' ].forEach( ( event ) => { + uploadContainer.addEventListener( event, ( e ) => { + e.preventDefault(); + uploadContainer.classList.remove( 'dragover' ); + } ); + } ); + + uploadContainer.addEventListener( 'drop', ( e ) => { + const file = e.dataTransfer.files[ 0 ]; + if ( file ) { + this.uploadFile( file, statusDiv ).then( ( response ) => { + this.el.dispatchEvent( + new CustomEvent( 'prplFileUploaded', { + detail: { file, filePost: response, fileInput }, + bubbles: true, + } ) + ); + } ); + } + } ); + + fileInput?.addEventListener( 'change', ( e ) => { + const file = e.target.files[ 0 ]; + if ( file ) { + this.uploadFile( file, statusDiv, fileInput ).then( + ( response ) => { + this.el.dispatchEvent( + new CustomEvent( 'prplFileUploaded', { + detail: { file, filePost: response, fileInput }, + bubbles: true, + } ) + ); + } + ); + } + } ); + } + + async uploadFile( file, statusDiv ) { + // Validate file extension + if ( ! this.isValidFaviconFile( file ) ) { + const fileInput = + this.popover.querySelector( 'input[type="file"]' ); + const acceptedTypes = fileInput?.accept || 'supported file types'; + statusDiv.textContent = `Invalid file type. Please upload a file with one of these formats: ${ acceptedTypes }`; + return; + } + + statusDiv.textContent = `Uploading ${ file.name }...`; + + const formData = new FormData(); + formData.append( 'file', file ); + formData.append( 'prplFileUpload', '1' ); + + return fetch( '/wp-json/wp/v2/media', { + method: 'POST', + headers: { + 'X-WP-Nonce': ProgressPlannerData.nonceWPAPI, // usually wp_localize_script adds this + }, + body: formData, + credentials: 'same-origin', + } ) + .then( ( res ) => { + if ( 201 !== res.status ) { + throw new Error( 'Failed to upload file' ); + } + return res.json(); + } ) + .then( ( response ) => { + statusDiv.textContent = `${ file.name } uploaded.`; + return response; + } ) + .catch( ( error ) => { + console.error( error ); + statusDiv.textContent = `Error: ${ error.message }`; + } ); + } + + /** + * Validate if file matches the accepted file types from the input + * @param {File} file The file to validate + * @return {boolean} True if file extension is supported + */ + isValidFaviconFile( file ) { + const fileInput = this.popover.querySelector( 'input[type="file"]' ); + if ( ! fileInput || ! fileInput.accept ) { + return true; // No restrictions if no accept attribute + } + + const acceptedTypes = fileInput.accept + .split( ',' ) + .map( ( type ) => type.trim() ); + const fileName = file.name.toLowerCase(); + + return acceptedTypes.some( ( type ) => { + if ( type.startsWith( '.' ) ) { + // Extension-based validation + return fileName.endsWith( type ); + } else if ( type.includes( '/' ) ) { + // MIME type-based validation + return file.type === type; + } + return false; + } ); + } +} + +class ProgressPlannerTourUtils { + /** + * Complete a task via AJAX + * @param {string} taskId + * @param {Object} formValues + */ + static async completeTask( taskId, formValues = {} ) { + const response = await fetch( ProgressPlannerData.adminAjaxUrl, { + method: 'POST', + body: new URLSearchParams( { + form_values: JSON.stringify( formValues ), + task_id: taskId, + nonce: ProgressPlannerData.nonceProgressPlanner, + action: 'progress_planner_tour_complete_task', + } ), + } ); + + if ( ! response.ok ) { + throw new Error( 'Request failed: ' + response.status ); + } + + return response.json(); + } +} diff --git a/classes/class-base.php b/classes/class-base.php index e31bd8e6bf..c94e3f1abb 100644 --- a/classes/class-base.php +++ b/classes/class-base.php @@ -55,6 +55,7 @@ * @method \Progress_Planner\Admin\Widgets\Challenge get_admin__widgets__challenge() * @method \Progress_Planner\Admin\Widgets\Activity_Scores get_admin__widgets__activity_scores() * @method \Progress_Planner\Utils\Date get_utils__date() + * @method \Progress_Planner\Front_End\Front_End_Onboarding get_front_end_onboarding() */ class Base { @@ -86,11 +87,9 @@ class Base { */ public function init() { if ( ! \function_exists( 'current_user_can' ) ) { - // @phpstan-ignore-next-line requireOnce.fileNotFound require_once ABSPATH . 'wp-includes/capabilities.php'; } if ( ! \function_exists( 'wp_get_current_user' ) ) { - // @phpstan-ignore-next-line requireOnce.fileNotFound require_once ABSPATH . 'wp-includes/pluggable.php'; } @@ -172,6 +171,9 @@ public function init() { // Init the enqueue class. $this->get_admin__enqueue()->init(); + + // TODO: Decide when this needs to be initialized. + $this->get_front_end__front_end_onboarding(); } /** @@ -423,7 +425,6 @@ public function get_file_version( $file ) { // Otherwise, use the plugin header. if ( ! \function_exists( 'get_file_data' ) ) { - // @phpstan-ignore-next-line requireOnce.fileNotFound require_once ABSPATH . 'wp-includes/functions.php'; } diff --git a/classes/class-suggested-tasks-db.php b/classes/class-suggested-tasks-db.php index e1444bf08e..40508b24ce 100644 --- a/classes/class-suggested-tasks-db.php +++ b/classes/class-suggested-tasks-db.php @@ -312,6 +312,9 @@ public function format_recommendation( $post ) { $terms = \wp_get_post_terms( $post_data['ID'], 'prpl_recommendations_provider' ); $post_data['provider'] = \is_array( $terms ) && isset( $terms[0] ) ? $terms[0] : null; + // Set task_id from post_name. + $post_data['task_id'] = \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $post_data['post_name'] ); + $cached[ $post_data['ID'] ] = new Task( $post_data ); return $cached[ $post_data['ID'] ]; } diff --git a/classes/class-suggested-tasks.php b/classes/class-suggested-tasks.php index e25fc60367..db43ac6817 100644 --- a/classes/class-suggested-tasks.php +++ b/classes/class-suggested-tasks.php @@ -213,6 +213,19 @@ public function maybe_complete_task() { return; } + // Mark task as completed and delete the token. + $this->mark_task_as_completed( $task_id, $user_id ); + } + + /** + * Complete a task. + * + * @param string $task_id The task ID. + * @param int|null $user_id Optional. The user ID for token deletion. If provided, the token will be deleted. + * + * @return bool + */ + public function mark_task_as_completed( $task_id, $user_id = null ) { if ( ! $this->was_task_completed( $task_id ) ) { $task = \progress_planner()->get_suggested_tasks_db()->get_post( $task_id ); @@ -222,10 +235,16 @@ public function maybe_complete_task() { // Insert an activity. $this->insert_activity( $task_id ); - // Delete the token after successful use (one-time use). - $this->delete_task_completion_token( $task_id, $user_id ); + // Delete the token after successful use (one-time use) if user_id is provided. + if ( $user_id ) { + $this->delete_task_completion_token( $task_id, $user_id ); + } + + return true; } } + + return false; } /** diff --git a/classes/front-end/class-front-end-onboarding.php b/classes/front-end/class-front-end-onboarding.php new file mode 100644 index 0000000000..a7e009c6b1 --- /dev/null +++ b/classes/front-end/class-front-end-onboarding.php @@ -0,0 +1,386 @@ +get_file_params(); + + if ( empty( $files['file'] ) ) { + return new \WP_Error( + 'rest_no_file', + __( 'No file uploaded.', 'progress-planner' ), + [ 'status' => 400 ] + ); + } + + $file = $files['file']; + + // Check MIME type. + if ( strpos( $file['type'], 'image/' ) !== 0 ) { + return new \WP_Error( + 'rest_invalid_file_type', + __( 'Only images are allowed for this upload.', 'progress-planner' ), + [ 'status' => 400 ] + ); + } + } + + return $attachment; + } + + /** + * Maybe clean up the user meta. + * + * @return void + */ + public function maybe_clean_up_user_meta() { + if ( ! \get_current_user_id() ) { + return; + } + + $screen = \get_current_screen(); + + // If the user is on the Progress Planner dashboard delete the user meta. + if ( ! $screen || 'toplevel_page_progress-planner' !== $screen->id ) { + return; + } + + $tour_data = \get_user_meta( \get_current_user_id(), '_prpl_tour_progress', true ); + if ( ! $tour_data ) { + return; + } + + \delete_user_meta( \get_current_user_id(), '_prpl_tour_progress' ); + } + + + /** + * Maybe show user notification that tour is not finished. + * + * @return void + */ + public function maybe_show_user_notification() { + if ( ! \get_current_user_id() ) { + return; + } + + $tour_data = \get_user_meta( \get_current_user_id(), '_prpl_tour_progress', true ); + if ( ! $tour_data ) { + return; + } + + $screen = \get_current_screen(); + + if ( ! $screen ) { + return; + } + + // If the user is on the Progress Planner dashboard do not display the notification and delete the user meta. + // This is a 'safety net' since we currently prevent all admin notices on the Progress Planner dashboard screen. + if ( 'toplevel_page_progress-planner' === $screen->id ) { + \delete_user_meta( \get_current_user_id(), '_prpl_tour_progress' ); + + // Do not show the notification. + return; + } + + $tour_data = \json_decode( $tour_data, true ); + + if ( $tour_data && isset( $tour_data['data'] ) && ! $tour_data['data']['finished'] ) { + ?> +
+ ', + '' + ); + ?> +
++ +
++ Lorem ipsum dolor sit amet consectetur adipiscing elit, eget interdum nostra tortor vestibulum ultrices, quisque congue nibh ullamcorper sapien natoque. +
+ ++ Venenatis parturient suspendisse massa cursus litora dapibus auctor, et vestibulum blandit condimentum quis ultrices sagittis aliquam. +
++ Lorem ipsum dolor sit amet consectetur adipiscing elit, eget interdum nostra tortor vestibulum ultrices, quisque congue nibh ullamcorper sapien natoque. +
+ ++ Venenatis parturient suspendisse massa cursus litora dapibus auctor, et vestibulum blandit condimentum quis ultrices sagittis aliquam. +
++ Lorem ipsum dolor sit amet consectetur adipiscing elit, eget interdum nostra tortor vestibulum ultrices, quisque congue nibh ullamcorper sapien natoque. +
+ ++ Venenatis parturient suspendisse massa cursus litora dapibus auctor, et vestibulum blandit condimentum quis ultrices sagittis aliquam. +
+