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'] ) { + ?> +
+ ', + '' + ); + ?> +
+'; + \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 @@ + + ++ +
++ 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. +
+