diff --git a/CHANGELOG.md b/CHANGELOG.md index dbf7f0275..372f6e891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ = 1.9.0 = +In this release we've added an integration with the **All In One Seo** plugin so you’ll now see personalized suggestions based on your current SEO configuration. + +Added these recommendations from Ravi: + +* All In One SEO: [noindex the author archive](https://prpl.fyi/aioseo-author-archive) +* All In One SEO: [noindex the date archive](https://prpl.fyi/aioseo-date-archive) +* All In One SEO: [Remove post authors feeds](https://prpl.fyi/aioseo-crawl-optimization-feed-authors) +* All In One SEO: [Remove comment feeds](https://prpl.fyi/aioseo-crawl-optimization-feed-comments) +* All In One SEO: [disable the media pages](https://prpl.fyi/aioseo-media-pages) +* All In One SEO: set your [organization](https://prpl.fyi/aioseo-organization-logo) or [person](https://prpl.fyi/aioseo-person-logo) logo + Enhancements: * Add "Show all Recommendations" button to the "Ravi's Recommendations" widget 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 000000000..58cfcf916 --- /dev/null +++ b/assets/front-end-onboarding/css/front-end-onboarding.css @@ -0,0 +1,319 @@ +@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: #fff; + text-decoration: none; + cursor: pointer; + font-size: 16px; + background: #dd3244; + 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: #cf2441; + } + + &.prpl-btn-secondary { + background: #f9b23c; + color: #4b5563; + + &:not([disabled]):hover, + &:not([disabled]):focus { + background: #f9b23c; + color: #4b5563; + } + } + } + + .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; + } + } + + /* 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 000000000..e0ccbc477 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 000000000..78e41dce6 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 000000000..9cd819bcc 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 000000000..d59b25733 --- /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/assets/js/recommendations/aioseo-author-archive.js b/assets/js/recommendations/aioseo-author-archive.js new file mode 100644 index 000000000..0e5174444 --- /dev/null +++ b/assets/js/recommendations/aioseo-author-archive.js @@ -0,0 +1,24 @@ +/* global prplInteractiveTaskFormListener, progressPlanner */ + +/* + * All in One SEO: noindex the author archive. + * + * Dependencies: progress-planner/recommendations/interactive-task + */ + +prplInteractiveTaskFormListener.customSubmit( { + taskId: 'aioseo-author-archive', + popoverId: 'prpl-popover-aioseo-author-archive', + callback: () => { + fetch( progressPlanner.ajaxUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams( { + action: 'prpl_interactive_task_submit_aioseo-author-archive', + nonce: progressPlanner.nonce, + } ), + } ); + }, +} ); diff --git a/assets/js/recommendations/aioseo-crawl-settings-feed-authors.js b/assets/js/recommendations/aioseo-crawl-settings-feed-authors.js new file mode 100644 index 000000000..a22477dce --- /dev/null +++ b/assets/js/recommendations/aioseo-crawl-settings-feed-authors.js @@ -0,0 +1,24 @@ +/* global prplInteractiveTaskFormListener, progressPlanner */ + +/* + * All in One SEO: disable author RSS feeds. + * + * Dependencies: progress-planner/recommendations/interactive-task + */ + +prplInteractiveTaskFormListener.customSubmit( { + taskId: 'aioseo-crawl-settings-feed-authors', + popoverId: 'prpl-popover-aioseo-crawl-settings-feed-authors', + callback: () => { + fetch( progressPlanner.ajaxUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams( { + action: 'prpl_interactive_task_submit_aioseo-crawl-settings-feed-authors', + nonce: progressPlanner.nonce, + } ), + } ); + }, +} ); diff --git a/assets/js/recommendations/aioseo-crawl-settings-feed-comments.js b/assets/js/recommendations/aioseo-crawl-settings-feed-comments.js new file mode 100644 index 000000000..147ad14a4 --- /dev/null +++ b/assets/js/recommendations/aioseo-crawl-settings-feed-comments.js @@ -0,0 +1,24 @@ +/* global prplInteractiveTaskFormListener, progressPlanner */ + +/* + * All in One SEO: disable global comment RSS feeds. + * + * Dependencies: progress-planner/recommendations/interactive-task + */ + +prplInteractiveTaskFormListener.customSubmit( { + taskId: 'aioseo-crawl-settings-feed-comments', + popoverId: 'prpl-popover-aioseo-crawl-settings-feed-comments', + callback: () => { + fetch( progressPlanner.ajaxUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams( { + action: 'prpl_interactive_task_submit_aioseo-crawl-settings-feed-comments', + nonce: progressPlanner.nonce, + } ), + } ); + }, +} ); diff --git a/assets/js/recommendations/aioseo-date-archive.js b/assets/js/recommendations/aioseo-date-archive.js new file mode 100644 index 000000000..454853fa3 --- /dev/null +++ b/assets/js/recommendations/aioseo-date-archive.js @@ -0,0 +1,24 @@ +/* global prplInteractiveTaskFormListener, progressPlanner */ + +/* + * All in One SEO: noindex the date archive. + * + * Dependencies: progress-planner/recommendations/interactive-task + */ + +prplInteractiveTaskFormListener.customSubmit( { + taskId: 'aioseo-date-archive', + popoverId: 'prpl-popover-aioseo-date-archive', + callback: () => { + fetch( progressPlanner.ajaxUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams( { + action: 'prpl_interactive_task_submit_aioseo-date-archive', + nonce: progressPlanner.nonce, + } ), + } ); + }, +} ); diff --git a/assets/js/recommendations/aioseo-media-pages.js b/assets/js/recommendations/aioseo-media-pages.js new file mode 100644 index 000000000..727072d0b --- /dev/null +++ b/assets/js/recommendations/aioseo-media-pages.js @@ -0,0 +1,24 @@ +/* global prplInteractiveTaskFormListener, progressPlanner */ + +/* + * All in One SEO: redirect media pages. + * + * Dependencies: progress-planner/recommendations/interactive-task + */ + +prplInteractiveTaskFormListener.customSubmit( { + taskId: 'aioseo-media-pages', + popoverId: 'prpl-popover-aioseo-media-pages', + callback: () => { + fetch( progressPlanner.ajaxUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams( { + action: 'prpl_interactive_task_submit_aioseo-media-pages', + nonce: progressPlanner.nonce, + } ), + } ); + }, +} ); diff --git a/classes/class-base.php b/classes/class-base.php index 2aa14bbd4..87eb0a874 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 { @@ -170,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(); } /** diff --git a/classes/class-suggested-tasks.php b/classes/class-suggested-tasks.php index d4416a811..b6f6e79cb 100644 --- a/classes/class-suggested-tasks.php +++ b/classes/class-suggested-tasks.php @@ -197,6 +197,17 @@ public function maybe_complete_task() { return; } + $this->mark_task_as_completed( $task_id ); + } + + /** + * Complete a task. + * + * @param string $task_id The task ID. + * + * @return bool + */ + public function mark_task_as_completed( $task_id ) { if ( ! $this->was_task_completed( $task_id ) ) { $task = \progress_planner()->get_suggested_tasks_db()->get_post( $task_id ); @@ -205,8 +216,12 @@ public function maybe_complete_task() { // Insert an activity. $this->insert_activity( $task_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 000000000..a37160ef4 --- /dev/null +++ b/classes/front-end/class-front-end-onboarding.php @@ -0,0 +1,381 @@ +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'] ) { + ?> +
+

+ ', + '' + ); + ?> +

+
+ get_plugin_version() ); + + // Enqueue front-end-onboarding.js. + \wp_enqueue_script( 'prpl-popover-front-end-onboarding', \constant( 'PROGRESS_PLANNER_URL' ) . '/assets/front-end-onboarding/js/front-end-onboarding.js', [], \progress_planner()->get_plugin_version(), true ); + + \wp_localize_script( + 'prpl-popover-front-end-onboarding', + 'ProgressPlannerData', + [ + 'adminAjaxUrl' => \esc_url_raw( admin_url( 'admin-ajax.php' ) ), + 'nonceProgressPlanner' => \esc_js( \wp_create_nonce( 'progress_planner' ) ), + 'nonceWPAPI' => \esc_js( \wp_create_nonce( 'wp_rest' ) ), + ] + ); + } + + /** + * Add admin toolbar item. + * + * @return void + */ + public function add_admin_toolbar_item() { + if ( is_admin() ) { + return; + } + + \add_action( 'admin_bar_menu', [ $this, 'add_admin_toolbar_item_callback' ], 100 ); + } + + /** + * Add admin toolbar item callback. + * + * @param \WP_Admin_Bar $admin_bar The admin bar. + * @return void + */ + public function add_admin_toolbar_item_callback( $admin_bar ) { + $admin_bar->add_node( + [ + 'id' => 'progress-planner-tour', + 'title' => 'Progress Planner Tour', + 'href' => '#', + 'meta' => [ + 'onclick' => 'window.prplTour.startTour(); return false;', + ], + ] + ); + } + + /** + * Save the tour progress. + * + * @return void + */ + public function ajax_save_tour_progress() { + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + + if ( ! isset( $_POST['state'] ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'State is required.', 'progress-planner' ) ] ); + } + $progress = \sanitize_text_field( \wp_unslash( $_POST['state'] ) ); + + \error_log( print_r( $progress, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r, WordPress.PHP.DevelopmentFunctions.error_log_error_log + + // Save as user meta? + \update_user_meta( \get_current_user_id(), '_prpl_tour_progress', $progress ); + + \wp_send_json_success( [ 'message' => \esc_html__( 'Tour progress saved.', 'progress-planner' ) ] ); + } + + /** + * Complete a task. + * + * @return void + */ + public function ajax_complete_task() { + + if ( ! \current_user_can( 'manage_options' ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to complete this task.', 'progress-planner' ) ] ); + } + + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + + if ( ! isset( $_POST['task_id'] ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Task ID is required.', 'progress-planner' ) ] ); + } + + $task_id = \sanitize_text_field( \wp_unslash( $_POST['task_id'] ) ); + + // Aditional data for the task, besides the task ID. + $form_values = []; + if ( isset( $_POST['form_values'] ) ) { + $form_values = \sanitize_text_field( \wp_unslash( $_POST['form_values'] ) ); + $form_values = \json_decode( $form_values, true ); + } + + // Get the task. + $task = \progress_planner()->get_suggested_tasks_db()->get_post( $task_id ); + if ( ! $task ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Task not found.', 'progress-planner' ) ] ); + } + + // To get the provider and complete the task, we need to use the provider. + $provider = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_provider( $task->get_provider_id() ); + if ( ! $provider ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Provider not found.', 'progress-planner' ) ] ); + } + + // WIP: Complete the task. + $task_completed = $provider->complete_task( $form_values, $task_id ); + + // Note: Marking task as completed will set it it to pending, so user will get celebration. Do we want that? + $task_post_marked_as_completed = \progress_planner()->get_suggested_tasks()->mark_task_as_completed( $task_id ); + + if ( ! $task_completed || ! $task_post_marked_as_completed ) { + \error_log( 'Task not completed: ' . $task_id ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + \wp_send_json_error( [ 'message' => \esc_html__( 'Task not completed.', 'progress-planner' ) ] ); + } + + \error_log( 'Task completed: ' . $task_id ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + \wp_send_json_success( [ 'message' => \esc_html__( 'Task completed.', 'progress-planner' ) ] ); + } + + /** + * Add the popover. + * + * @return void + */ + public function add_popover() { + ?> +
+ +
+ +
+ + + + +
+ get_suggested_tasks_db()->get_tasks_by( [ 'task_id' => $task_id ] ); + + // If there is no 'blog description' task, create it. + if ( ! $task ) { + $task_provider = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_provider( $task_id ); + + if ( $task_provider ) { + $task_data = $task_provider->get_task_details(); + + \progress_planner()->get_suggested_tasks_db()->add( $task_data ); + + // Now get the task. + $task = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'task_id' => $task_id ] ); + } + } + + $task_formatted = [ + 'task_id' => $task[0]->task_id, + 'title' => $task[0]->post_title, + 'url' => $task[0]->url, + 'provider_id' => $task[0]->get_provider_id(), + 'points' => $task[0]->points, + ]; + + // WIP, add task specific data. + if ( 'core-blogdescription' === $task_id ) { + $task_formatted['site_description'] = \get_bloginfo( 'description' ); + } + + $tasks[ $task_id ] = $task_formatted; + } + + \progress_planner()->the_view( 'front-end-onboarding/welcome.php' ); + \progress_planner()->the_view( 'front-end-onboarding/first-task.php', [ 'task' => array_shift( $tasks ) ] ); // WIP: We need only 1 task for this step. + \progress_planner()->the_view( 'front-end-onboarding/badges.php' ); + \progress_planner()->the_view( 'front-end-onboarding/more-tasks.php', [ 'tasks' => $tasks ] ); // WIP: We need up to 5 tasks for this step. + ?> + + capability_required() ) { + return false; + } + + if ( ! isset( $args['blogdescription'] ) ) { + return false; + } + + // update_option will return false if the option value is the same as the one being set. + \update_option( 'blogdescription', \sanitize_text_field( $args['blogdescription'] ) ); + + return true; + } } diff --git a/classes/suggested-tasks/providers/class-select-locale.php b/classes/suggested-tasks/providers/class-select-locale.php index bc4c24364..c7ae10a6b 100644 --- a/classes/suggested-tasks/providers/class-select-locale.php +++ b/classes/suggested-tasks/providers/class-select-locale.php @@ -271,26 +271,13 @@ public function handle_interactive_task_specific_submit() { \wp_send_json_error( [ 'message' => \esc_html__( 'Missing setting path.', 'progress-planner' ) ] ); } - $option_updated = false; $language_for_update = \sanitize_text_field( \wp_unslash( $_POST['value'] ) ); if ( empty( $language_for_update ) ) { \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid language.', 'progress-planner' ) ] ); } - // Handle translation installation. - if ( \current_user_can( 'install_languages' ) ) { - require_once ABSPATH . 'wp-admin/includes/translation-install.php'; // @phpstan-ignore requireOnce.fileNotFound - - if ( \wp_can_install_language_pack() ) { - $language = \wp_download_language_pack( $language_for_update ); - if ( $language ) { - $language_for_update = $language; - - $option_updated = \update_option( 'WPLANG', $language_for_update ); - } - } - } + $option_updated = $this->update_language( $language_for_update ); if ( $option_updated ) { \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); @@ -315,4 +302,53 @@ public function add_task_actions( $data = [], $actions = [] ) { return $actions; } + + /** + * Complete the task. + * + * @param array $args The task data. + * @param string $task_id The task ID. + * + * @return bool + */ + public function complete_task( $args = [], $task_id = '' ) { + + if ( ! $this->capability_required() ) { + return false; + } + + if ( ! isset( $args['language'] ) ) { + return false; + } + + return $this->update_language( \sanitize_text_field( \wp_unslash( $args['language'] ) ) ); + } + + /** + * Update the language. + * + * @param string $language_for_update The language to update. + * + * @return bool + */ + protected function update_language( $language_for_update ) { + // Handle translation installation. + if ( \current_user_can( 'install_languages' ) ) { + require_once ABSPATH . 'wp-admin/includes/translation-install.php'; // @phpstan-ignore requireOnce.fileNotFound + + if ( \wp_can_install_language_pack() ) { + $language = \wp_download_language_pack( $language_for_update ); + if ( $language ) { + $language_for_update = $language; + + // update_option will return false if the option value is the same as the one being set. + \update_option( 'WPLANG', $language_for_update ); + + return true; + } + } + } + + return false; + } } diff --git a/classes/suggested-tasks/providers/class-select-timezone.php b/classes/suggested-tasks/providers/class-select-timezone.php index 4bf59fe06..4d0b9baf2 100644 --- a/classes/suggested-tasks/providers/class-select-timezone.php +++ b/classes/suggested-tasks/providers/class-select-timezone.php @@ -194,27 +194,9 @@ public function handle_interactive_task_specific_submit() { \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid timezone.', 'progress-planner' ) ] ); } - $update_options = false; + $option_updated = $this->update_timezone( $timezone_string ); - // Map UTC+- timezones to gmt_offsets and set timezone_string to empty. - if ( \preg_match( '/^UTC[+-]/', $timezone_string ) ) { - // Set the gmt_offset to the value of the timezone_string, strip the UTC prefix. - $gmt_offset = \preg_replace( '/UTC\+?/', '', $timezone_string ); - - // Reset the timezone_string to empty. - $timezone_string = ''; - - $update_options = true; - } elseif ( \in_array( $timezone_string, \timezone_identifiers_list( \DateTimeZone::ALL_WITH_BC ), true ) ) { - // $timezone_string is already set, reset the value for $gmt_offset. - $gmt_offset = ''; - - $update_options = true; - } - - if ( $update_options ) { - \update_option( 'timezone_string', $timezone_string ); - \update_option( 'gmt_offset', $gmt_offset ); + if ( $option_updated ) { // We're not checking for the return value of the update_option calls, because it will return false if the value is the same (for example if gmt_offset is already set to ''). \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); @@ -239,4 +221,64 @@ public function add_task_actions( $data = [], $actions = [] ) { return $actions; } + + /** + * Complete the task. + * + * @param array $args The task data. + * @param string $task_id The task ID. + * + * @return bool + */ + public function complete_task( $args = [], $task_id = '' ) { + + if ( ! $this->capability_required() ) { + return false; + } + + if ( ! isset( $args['timezone'] ) ) { + return false; + } + + $timezone_string = \sanitize_text_field( \wp_unslash( $args['timezone'] ) ); + + return $this->update_timezone( $timezone_string ); + } + + /** + * Update the timezone. + * + * @param string $timezone_string The timezone string to update. + * + * @return bool + */ + protected function update_timezone( $timezone_string ) { + + $update_options = false; + + // Map UTC+- timezones to gmt_offsets and set timezone_string to empty. + if ( \preg_match( '/^UTC[+-]/', $timezone_string ) ) { + // Set the gmt_offset to the value of the timezone_string, strip the UTC prefix. + $gmt_offset = \preg_replace( '/UTC\+?/', '', $timezone_string ); + + // Reset the timezone_string to empty. + $timezone_string = ''; + + $update_options = true; + } elseif ( \in_array( $timezone_string, \timezone_identifiers_list( \DateTimeZone::ALL_WITH_BC ), true ) ) { + // $timezone_string is already set, reset the value for $gmt_offset. + $gmt_offset = ''; + + $update_options = true; + } + + if ( $update_options ) { + \update_option( 'timezone_string', $timezone_string ); + \update_option( 'gmt_offset', $gmt_offset ); + + return true; + } + + return false; + } } diff --git a/classes/suggested-tasks/providers/class-site-icon.php b/classes/suggested-tasks/providers/class-site-icon.php index f2537fbf2..9b1716277 100644 --- a/classes/suggested-tasks/providers/class-site-icon.php +++ b/classes/suggested-tasks/providers/class-site-icon.php @@ -96,4 +96,28 @@ public function add_task_actions( $data = [], $actions = [] ) { return $actions; } + + /** + * Complete the task. + * + * @param array $args The task data. + * @param string $task_id The task ID. + * + * @return bool + */ + public function complete_task( $args = [], $task_id = '' ) { + + if ( ! $this->capability_required() ) { + return false; + } + + if ( ! isset( $args['post_id'] ) ) { + return false; + } + + // update_option will return false if the option value is the same as the one being set. + \update_option( 'site_icon', \sanitize_text_field( $args['post_id'] ) ); + + return true; + } } diff --git a/classes/suggested-tasks/providers/class-tasks.php b/classes/suggested-tasks/providers/class-tasks.php index fa81e27e8..a203a914c 100644 --- a/classes/suggested-tasks/providers/class-tasks.php +++ b/classes/suggested-tasks/providers/class-tasks.php @@ -727,4 +727,16 @@ public function task_has_activity( $task_id = '' ) { return ! empty( $activity ); } + + /** + * Complete the task. + * + * @param array $args The task data. + * @param string $task_id The task ID. + * + * @return bool + */ + public function complete_task( $args = [], $task_id = '' ) { + return false; + } } diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-add-aioseo-providers.php b/classes/suggested-tasks/providers/integrations/aioseo/class-add-aioseo-providers.php new file mode 100644 index 000000000..8b797d3bd --- /dev/null +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-add-aioseo-providers.php @@ -0,0 +1,52 @@ +providers = [ + new Archive_Author(), + new Archive_Date(), + new Media_Pages(), + new Crawl_Settings_Feed_Authors(), + new Crawl_Settings_Feed_Comments(), + new Organization_Logo(), + ]; + + return \array_merge( + $providers, + $this->providers + ); + } +} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-aioseo-interactive-provider.php b/classes/suggested-tasks/providers/integrations/aioseo/class-aioseo-interactive-provider.php new file mode 100644 index 000000000..37205c680 --- /dev/null +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-aioseo-interactive-provider.php @@ -0,0 +1,30 @@ +is_task_relevant() ) { + return false; + } + + // Check if author archives are already disabled in AIOSEO. + // AIOSEO uses 'show' property - when false, archives are hidden from search results. + // Get a fresh copy of the options to avoid caching issues. + $show_value = \aioseo()->options->searchAppearance->archives->author->show; + + // If show is false (disabled), the task is complete (return false means don't add task). + // Using loose comparison to handle string/int/bool variations. + if ( ! $show_value ) { + return false; + } + + return true; + } + + /** + * Check if the task is still relevant. + * For example, we have a task to disable author archives if there is only one author. + * If in the meantime more authors are added, the task is no longer relevant and the task should be removed. + * + * @return bool + */ + public function is_task_relevant() { + // If there is more than one author, we don't need to add the task. + return $this->get_data_collector()->collect() <= self::MINIMUM_AUTHOR_WITH_POSTS; + } + + /** + * Get the description. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'Your author archives are the same as your normal archives because you have only one author, so there\'s no reason for search engines to index these. That\'s why we suggest keeping them out of search results.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + ?> + + \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] ); + } + + // Check the nonce. + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + + \aioseo()->options->searchAppearance->archives->author->show = false; + + // Update the option. + \aioseo()->options->save(); + + \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); + } + + /** + * Add task actions specific to this task. + * + * @param array $data The task data. + * @param array $actions The existing actions. + * + * @return array + */ + public function add_task_actions( $data = [], $actions = [] ) { + $actions[] = [ + 'priority' => 10, + 'html' => '' . \esc_html__( 'Noindex', 'progress-planner' ) . '', + ]; + + return $actions; + } +} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php b/classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php new file mode 100644 index 000000000..6c5471731 --- /dev/null +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php @@ -0,0 +1,174 @@ +is_task_relevant() ) { + return false; + } + + // Check if date archives are already disabled in AIOSEO. + // AIOSEO uses 'show' property - when false, archives are hidden from search results. + $show_value = \aioseo()->options->searchAppearance->archives->date->show; + + // If show is false (disabled), the task is complete (return false means don't add task). + // Using loose comparison to handle string/int/bool variations. + if ( ! $show_value ) { + return false; + } + + return true; + } + + /** + * Check if the task is still relevant. + * For example, we have a task to disable author archives if there is only one author. + * If in the meantime more authors are added, the task is no longer relevant and the task should be removed. + * + * @return bool + */ + public function is_task_relevant() { + // If the permalink structure includes %year%, %monthnum%, or %day%, we don't need to add the task. + $permalink_structure = \get_option( 'permalink_structure' ); + return \strpos( $permalink_structure, '%year%' ) === false + && \strpos( $permalink_structure, '%monthnum%' ) === false + && \strpos( $permalink_structure, '%day%' ) === false; + } + + /** + * Get the description. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'Date archives rarely add any real value for users or search engines, so there\'s no reason for search engines to index these. That\'s why we suggest keeping them out of search results.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + ?> + + \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] ); + } + + // Check the nonce. + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + + \aioseo()->options->searchAppearance->archives->date->show = false; + + // Update the option. + \aioseo()->options->save(); + + \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); + } + + /** + * Add task actions specific to this task. + * + * @param array $data The task data. + * @param array $actions The existing actions. + * + * @return array + */ + public function add_task_actions( $data = [], $actions = [] ) { + $actions[] = [ + 'priority' => 10, + 'html' => '' . \esc_html__( 'Noindex', 'progress-planner' ) . '', + ]; + + return $actions; + } +} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php new file mode 100644 index 000000000..cd9b35e2c --- /dev/null +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php @@ -0,0 +1,185 @@ +is_task_relevant() ) { + return false; + } + + // Check if crawl cleanup is enabled and author feeds are disabled. + $disable_author_feed = \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->authors; + + // Check if author feeds are already disabled. + if ( $disable_author_feed === false ) { + return false; + } + + return true; + } + + /** + * Check if the task is still relevant. + * For example, we have a task to disable author archives if there is only one author. + * If in the meantime more authors are added, the task is no longer relevant and the task should be removed. + * + * @return bool + */ + public function is_task_relevant() { + // If there is more than one author, we don't need to add the task. + return $this->get_data_collector()->collect() <= self::MINIMUM_AUTHOR_WITH_POSTS; + } + + /** + * Get the description. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'The author feed on your site will be similar to your main feed if you have only one author, so there\'s no reason to have it.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + ?> + + \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] ); + } + + // Check the nonce. + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + + \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->authors = false; + + // Update the option. + \aioseo()->options->save(); + + \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); + } + + /** + * Add task actions specific to this task. + * + * @param array $data The task data. + * @param array $actions The existing actions. + * + * @return array + */ + public function add_task_actions( $data = [], $actions = [] ) { + $actions[] = [ + 'priority' => 10, + 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', + ]; + + return $actions; + } +} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-comments.php b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-comments.php new file mode 100644 index 000000000..aae197d54 --- /dev/null +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-comments.php @@ -0,0 +1,161 @@ +options->searchAppearance->advanced->crawlCleanup->feeds->globalComments; // @phpstan-ignore-line + $disable_post_comment_feed = \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->postComments; // @phpstan-ignore-line + + // Check if comment feeds are already disabled. + if ( $disable_global_comment_feed === false && $disable_post_comment_feed === false ) { + return false; + } + + return true; + } + + /** + * Get the description. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'We suggest disabling both the global "recent comments feed" from your site as well as the "comments feed" per post that WordPress generates. These feeds are rarely used by real users, but get crawled a lot. They don\'t have any interesting information for crawlers, so removing them leads to less bot-traffic on your site without downsides.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + ?> + + \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] ); + } + + // Check the nonce. + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + + // Global comment feed. + if ( \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->globalComments ) { // @phpstan-ignore-line + \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->globalComments = false; // @phpstan-ignore-line + } + + // Post comment feed. + if ( \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->postComments ) { // @phpstan-ignore-line + \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->postComments = false; // @phpstan-ignore-line + } + + // Update the option. + \aioseo()->options->save(); // @phpstan-ignore-line + + \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); + } + + /** + * Add task actions specific to this task. + * + * @param array $data The task data. + * @param array $actions The existing actions. + * + * @return array + */ + public function add_task_actions( $data = [], $actions = [] ) { + $actions[] = [ + 'priority' => 10, + 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', + ]; + + return $actions; + } +} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php b/classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php new file mode 100644 index 000000000..c4a445c42 --- /dev/null +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php @@ -0,0 +1,156 @@ + postTypes -> attachment -> redirectAttachmentUrls. + $redirect = \aioseo()->dynamicOptions->searchAppearance->postTypes->attachment->redirectAttachmentUrls; + + // The task is complete if redirectAttachmentUrls is set to 'attachment'. + // Possible values: 'disabled', 'attachment', or 'attachmentParent'. + // We recommend 'attachment' as it redirects to the attachment file itself. + if ( 'attachment' === $redirect ) { + return false; + } + + return true; + } + + /** + * Get the description. + * + * @return void + */ + public function print_popover_instructions() { + echo '

'; + \esc_html_e( 'WordPress creates a "page" for every image you upload. These don\'t add any value but do cause more crawling on your site, so we suggest removing those.', 'progress-planner' ); + echo '

'; + } + + /** + * Print the popover input field for the form. + * + * @return void + */ + public function print_popover_form_contents() { + ?> + + \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] ); + } + + // Check the nonce. + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + + \aioseo()->dynamicOptions->searchAppearance->postTypes->attachment->redirectAttachmentUrls = 'attachment'; + + // Update the option. + \aioseo()->options->save(); + + \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); + } + + /** + * Add task actions specific to this task. + * + * @param array $data The task data. + * @param array $actions The existing actions. + * + * @return array + */ + public function add_task_actions( $data = [], $actions = [] ) { + $actions[] = [ + 'priority' => 10, + 'html' => '' . \esc_html__( 'Redirect', 'progress-planner' ) . '', + ]; + + return $actions; + } +} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-organization-logo.php b/classes/suggested-tasks/providers/integrations/aioseo/class-organization-logo.php new file mode 100644 index 000000000..dfc1b4e41 --- /dev/null +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-organization-logo.php @@ -0,0 +1,101 @@ +get_ui__branding()->get_url( 'https://prpl.fyi/aioseo-organization-logo' ); + } + + $options = \aioseo()->options->searchAppearance->global->schema; + $is_person = isset( $options->siteRepresents ) && 'person' === $options->siteRepresents; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + return $is_person + ? \progress_planner()->get_ui__branding()->get_url( 'https://prpl.fyi/aioseo-person-logo' ) + : \progress_planner()->get_ui__branding()->get_url( 'https://prpl.fyi/aioseo-organization-logo' ); + } + + /** + * Determine if the task should be added. + * + * @return bool + */ + public function should_add_task() { + // Check if AIOSEO is active. + if ( ! \function_exists( 'aioseo' ) ) { + return false; + } + + $represents = \aioseo()->options->searchAppearance->global->schema->siteRepresents; + + // Check if logo is already set. + if ( $represents === 'person' ) { + return false; + } + + // Check organization logo. + return \aioseo()->options->searchAppearance->global->schema->organizationLogo === ''; + } + + /** + * Add task actions specific to this task. + * + * @param array $data The task data. + * @param array $actions The existing actions. + * + * @return array + */ + public function add_task_actions( $data = [], $actions = [] ) { + $actions[] = [ + 'priority' => 10, + 'html' => '' . \esc_html__( 'Set logo', 'progress-planner' ) . '', + ]; + + return $actions; + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 0f6866622..ca24110c3 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -42,3 +42,9 @@ parameters: - classes/suggested-tasks/data-collector/class-terms-without-description.php - classes/suggested-tasks/data-collector/class-post-tag-count.php - classes/suggested-tasks/data-collector/class-post-author.php + - classes/suggested-tasks/providers/integrations/aioseo/class-archive-author.php + - classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php + - classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php + - classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-global-comments.php + - classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php + - classes/suggested-tasks/providers/integrations/aioseo/class-organization-logo.php diff --git a/views/front-end-onboarding/badges.php b/views/front-end-onboarding/badges.php new file mode 100644 index 000000000..1e496f448 --- /dev/null +++ b/views/front-end-onboarding/badges.php @@ -0,0 +1,33 @@ + + + + diff --git a/views/front-end-onboarding/first-task.php b/views/front-end-onboarding/first-task.php new file mode 100644 index 000000000..844921902 --- /dev/null +++ b/views/front-end-onboarding/first-task.php @@ -0,0 +1,36 @@ + + + + diff --git a/views/front-end-onboarding/more-tasks.php b/views/front-end-onboarding/more-tasks.php new file mode 100644 index 000000000..d8faf8b3b --- /dev/null +++ b/views/front-end-onboarding/more-tasks.php @@ -0,0 +1,49 @@ + + + + diff --git a/views/front-end-onboarding/tasks/blog-description.php b/views/front-end-onboarding/tasks/blog-description.php new file mode 100644 index 000000000..8b40cd8fb --- /dev/null +++ b/views/front-end-onboarding/tasks/blog-description.php @@ -0,0 +1,35 @@ + + +
+
+

+ +

+

+ +

+
+
+ + +
+
diff --git a/views/front-end-onboarding/tasks/core-siteicon.php b/views/front-end-onboarding/tasks/core-siteicon.php new file mode 100644 index 000000000..96f025102 --- /dev/null +++ b/views/front-end-onboarding/tasks/core-siteicon.php @@ -0,0 +1,51 @@ + +
+
+
+

+ +

+

+ 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. +

+
+
+ +
+

+ ', + '' + ); + ?> +

+ + +
+
+ +
+
+
diff --git a/views/front-end-onboarding/tasks/select-locale.php b/views/front-end-onboarding/tasks/select-locale.php new file mode 100644 index 000000000..700a03bda --- /dev/null +++ b/views/front-end-onboarding/tasks/select-locale.php @@ -0,0 +1,66 @@ + + +
+
+
+

+ +

+

+ 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. +

+
+
+ 'language', + 'id' => 'language', + 'selected' => $prpl_locale, + 'languages' => $prpl_languages, + 'translations' => $prpl_translations, + 'show_available_translations' => \current_user_can( 'install_languages' ) && \wp_can_install_language_pack(), + 'echo' => true, + ] + ); + ?> + +
+
+
diff --git a/views/front-end-onboarding/tasks/select-timezone.php b/views/front-end-onboarding/tasks/select-timezone.php new file mode 100644 index 000000000..c4da3dc4f --- /dev/null +++ b/views/front-end-onboarding/tasks/select-timezone.php @@ -0,0 +1,42 @@ + + +
+
+
+

+ +

+

+ 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. +

+
+
+ + +
+
+
diff --git a/views/front-end-onboarding/welcome.php b/views/front-end-onboarding/welcome.php new file mode 100644 index 000000000..be34e3cd6 --- /dev/null +++ b/views/front-end-onboarding/welcome.php @@ -0,0 +1,39 @@ + + + +