diff --git a/.planning/revert-onboarding-plan.md b/.planning/revert-onboarding-plan.md new file mode 100644 index 0000000000..169aee1837 --- /dev/null +++ b/.planning/revert-onboarding-plan.md @@ -0,0 +1,289 @@ +# Plan: Revert New Onboarding (PR #714) and Preserve Work + +## Overview + +This plan describes how to revert PR #714 (New User Onboarding) on the `develop` branch while preserving all the new onboarding work on a separate branch. + +**PR #714 Details:** +- Merged: December 16, 2025 +- Merge commit: `e7323c4f21c9b71eb4b2ee3f96ae294fd53ca516` +- Title: "New user onboarding" + +## Current State Analysis + +### Commits Since PR #714 (53 total) +The following PRs were merged after #714: +- #746: Site editor sidebar (not onboarding related) +- #744: Fix interactive task resubmission (not onboarding related) +- #743: Safeguards for plugin update (not onboarding related) +- #742: Update for #740 (not onboarding related) +- #741: Onboarding tweaks (**onboarding related**) +- #740: Remove popover positioning (not onboarding related) +- #739: Exclude direct_file_access check (not onboarding related) +- #738: Fix year-week boundary bug (not onboarding related) +- #736: Delay onboarding init (**onboarding related**) +- #733: Reverse filter logic (**onboarding related**) +- #730: New onboarding tweaks (**onboarding related**) + +### PRs with Onboarding Changes (need special handling) + +| PR | Files Changed | Non-Onboarding Changes | +|----|---------------|------------------------| +| #730 | `assets/css/onboarding/onboarding.css`, `assets/js/onboarding/steps/SettingsStep.js`, `views/onboarding/settings.php` | `classes/class-plugin-upgrade-tasks.php` (upgrade task condition) | +| #733 | `classes/class-onboard-wizard.php`, `tests/phpunit/test-class-onboard-wizard.php` | None | +| #736 | `assets/js/onboarding/onboarding.js`, `classes/class-onboard-wizard.php` | `classes/utils/class-debug-tools.php`, `classes/utils/class-playground.php` | +| #741 | Multiple onboarding views and CSS | None | + +### Files Created by PR #714 (New Onboarding) +These files/directories didn't exist before and will need to be **deleted**: +``` +assets/css/onboarding/ +assets/images/onboarding/ +assets/js/onboarding/ +classes/class-onboard-wizard.php +tests/phpunit/test-class-onboard-wizard.php +views/onboarding/ +``` + +### Files Deleted by PR #714 (Old Onboarding) +These files need to be **restored** from before the merge: +``` +assets/css/onboard.css +assets/css/welcome.css +assets/js/onboard.js +views/welcome.php +``` + +### Files Modified by PR #714 (Need Selective Revert) +These existing files were modified and need **careful handling**: +``` +assets/css/admin.css +assets/js/settings.js +classes/admin/class-page-settings.php +classes/admin/class-page.php +classes/class-base.php +classes/class-suggested-tasks.php +classes/suggested-tasks/class-tasks-interface.php +classes/suggested-tasks/providers/class-blog-description.php +classes/suggested-tasks/providers/class-select-locale.php +classes/suggested-tasks/providers/class-select-timezone.php +classes/suggested-tasks/providers/class-site-icon.php +classes/suggested-tasks/providers/class-tasks.php +classes/ui/class-chart.php +classes/utils/class-date.php +tests/e2e/sequential/onboarding.spec.js +views/admin-page.php +``` + +--- + +## Execution Plan + +### Phase 1: Preserve New Onboarding Work + +1. **Create a preservation branch from current develop** + ```bash + git checkout develop + git pull origin develop + git checkout -b filip/new-onboarding-preserved + git push -u origin filip/new-onboarding-preserved + ``` + +2. **Document the branch purpose** + Add a note to the branch or create an issue tracking it. + +--- + +### Phase 2: Create Revert Branch + +1. **Create revert branch** + ```bash + git checkout develop + git checkout -b revert/old-onboarding + ``` + +--- + +### Phase 3: Restore Old Onboarding Files + +1. **Restore deleted files from commit before PR #714** + The parent commit (before merge) is: `e7323c4f21c9b71eb4b2ee3f96ae294fd53ca516^` + + ```bash + # Restore old onboarding files + git checkout e7323c4f21c9b71eb4b2ee3f96ae294fd53ca516^ -- assets/css/onboard.css + git checkout e7323c4f21c9b71eb4b2ee3f96ae294fd53ca516^ -- assets/css/welcome.css + git checkout e7323c4f21c9b71eb4b2ee3f96ae294fd53ca516^ -- assets/js/onboard.js + git checkout e7323c4f21c9b71eb4b2ee3f96ae294fd53ca516^ -- views/welcome.php + ``` + +--- + +### Phase 4: Delete New Onboarding Files + +1. **Remove new onboarding directories and files** + ```bash + rm -rf assets/css/onboarding/ + rm -rf assets/images/onboarding/ + rm -rf assets/js/onboarding/ + rm -f classes/class-onboard-wizard.php + rm -f tests/phpunit/test-class-onboard-wizard.php + rm -rf views/onboarding/ + ``` + +--- + +### Phase 5: Revert Modified Files (Manual Review Required) + +For each file below, compare the old version with the current version and selectively revert onboarding-related changes while keeping other improvements: + +#### 5.1 Core Files (Critical - Review Carefully) + +| File | Action | +|------|--------| +| `classes/class-base.php` | Revert `get_license_generator_content` changes | +| `classes/admin/class-page.php` | Restore `welcome_redirect` method | +| `classes/admin/class-page-settings.php` | Review licensing changes | +| `views/admin-page.php` | Revert onboarding container changes | + +**Commands to view diffs:** +```bash +# View what changed in each file +git diff e7323c4f21c9b71eb4b2ee3f96ae294fd53ca516^..e7323c4f21c9b71eb4b2ee3f96ae294fd53ca516 -- classes/class-base.php +git diff e7323c4f21c9b71eb4b2ee3f96ae294fd53ca516^..e7323c4f21c9b71eb4b2ee3f96ae294fd53ca516 -- classes/admin/class-page.php +git diff e7323c4f21c9b71eb4b2ee3f96ae294fd53ca516^..e7323c4f21c9b71eb4b2ee3f96ae294fd53ca516 -- views/admin-page.php +``` + +#### 5.2 Suggested Tasks Providers +These files had changes for onboarding task providers. Review and revert onboarding-specific code: +- `classes/class-suggested-tasks.php` +- `classes/suggested-tasks/class-tasks-interface.php` +- `classes/suggested-tasks/providers/class-blog-description.php` +- `classes/suggested-tasks/providers/class-select-locale.php` +- `classes/suggested-tasks/providers/class-select-timezone.php` +- `classes/suggested-tasks/providers/class-site-icon.php` +- `classes/suggested-tasks/providers/class-tasks.php` + +#### 5.3 CSS/JS Files +- `assets/css/admin.css` - Revert onboarding-related styles +- `assets/js/settings.js` - Review changes + +#### 5.4 Utility Files +- `classes/ui/class-chart.php` - Review `get_week_badge_gauge_html` method +- `classes/utils/class-date.php` - Review `format_date_for_display` method + +--- + +### Phase 6: Handle Non-Onboarding Changes from Onboarding PRs + +**IMPORTANT:** These changes should be kept even though they came from onboarding-related PRs: + +1. **From PR #730** - `classes/class-plugin-upgrade-tasks.php` + - Changed condition for upgrade tasks display + - **Keep this change** (related to privacy policy acceptance) + +2. **From PR #736** - Playground functionality + - `classes/utils/class-debug-tools.php` - Debug tools for playground + - `classes/utils/class-playground.php` - Playground hooks and license key insertion + - **Keep these changes** (but review if they reference new onboarding) + +--- + +### Phase 7: Update E2E Tests + +1. **Restore old onboarding E2E tests** + ```bash + git checkout e7323c4f21c9b71eb4b2ee3f96ae294fd53ca516^ -- tests/e2e/sequential/onboarding.spec.js + ``` + +2. **Review if tests need updates for any other changes made since** + +--- + +### Phase 8: Final Steps + +1. **Run tests** + ```bash + composer test + npm run test:e2e + ``` + +2. **Run code style checks** + ```bash + composer check-cs + composer phpstan + ``` + +3. **Manual testing checklist** + - [ ] Fresh plugin activation shows old onboarding + - [ ] Welcome page displays correctly + - [ ] Onboarding steps work as expected + - [ ] Dashboard loads without errors + - [ ] Settings page works correctly + - [ ] Playground functionality still works + +4. **Create PR** + ```bash + git add -A + git commit -m "Revert to old onboarding system + + This reverts the new onboarding introduced in PR #714 and subsequent + onboarding-related PRs (#730, #733, #736, #741). + + The new onboarding work is preserved in branch: filip/new-onboarding-preserved" + + git push -u origin revert/old-onboarding + gh pr create --draft --title "Revert to old onboarding system" --body "..." + ``` + +--- + +## Risk Assessment + +### High Risk Areas +1. **`classes/class-base.php`** - Core plugin functionality, changes affect initialization +2. **`views/admin-page.php`** - Main dashboard view +3. **Suggested tasks providers** - May have cross-dependencies + +### Medium Risk Areas +1. **CSS files** - May affect overall admin styling +2. **E2E tests** - May need updates for CI to pass + +### Low Risk Areas +1. **Deleting new onboarding files** - Clean removal +2. **Restoring old files** - Straightforward git checkout + +--- + +## Alternative Approach: Git Revert + +Instead of manual file manipulation, you could try: +```bash +git revert --no-commit e7323c4f21c9b71eb4b2ee3f96ae294fd53ca516 +``` + +However, this may cause conflicts with subsequent commits. The manual approach gives more control. + +--- + +## Rollback Plan + +If the revert causes issues: +1. The new onboarding is preserved in `filip/new-onboarding-preserved` +2. Can simply close the revert PR without merging +3. Current `develop` remains unchanged until PR is merged + +--- + +## Timeline Estimate + +- Phase 1-2: Branch setup - Quick +- Phase 3-4: File restoration/deletion - Quick +- Phase 5: Manual review of modified files - Requires careful review +- Phase 6: Non-onboarding changes - Quick review +- Phase 7-8: Testing and PR - Depends on test results + +--- + +*Plan created: January 21, 2026* +*Last updated: January 21, 2026* diff --git a/assets/css/admin.css b/assets/css/admin.css index 58938bef6c..d0c7981551 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -80,6 +80,24 @@ body.toplevel_page_progress-planner { margin-top: var(--prpl-padding); } +/*------------------------------------*\ + Styles for the container of the page when the privacy policy is not accepted. +\*------------------------------------*/ +.prpl-pp-not-accepted { + + .prpl-start-onboarding-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--prpl-padding); + } + + .prpl-start-onboarding-graphic { + width: 250px; + } +} + /*------------------------------------*\ Generic styles. \*------------------------------------*/ diff --git a/assets/css/onboard.css b/assets/css/onboard.css deleted file mode 100644 index bac24ea7dd..0000000000 --- a/assets/css/onboard.css +++ /dev/null @@ -1,23 +0,0 @@ -#prpl-onboarding-form .prpl-form-fields label { - display: grid; - grid-template-columns: 1fr 3fr; - margin-bottom: 0.5em; - gap: var(--prpl-padding); -} - -#prpl-onboarding-form label > span:has(input[type="checkbox"]) { - display: flex; - align-items: baseline; -} - -.prpl-onboard-form-radio-select { - - label { - display: block !important; - } -} - -#prpl-onboarding-submit-wrapper { - display: flex; - align-items: center; -} diff --git a/assets/css/onboarding/onboarding.css b/assets/css/onboarding/onboarding.css new file mode 100644 index 0000000000..ae11f3057c --- /dev/null +++ b/assets/css/onboarding/onboarding.css @@ -0,0 +1,1359 @@ +.prpl-popover-onboarding { + + --prpl-color-text: #4b5563; + + /* Paper */ + --prpl-onboarding-popover-background: var(--prpl-background-paper, #fff); + + /* Steps navigation */ + --prpl-background-steps: var(--prpl-background, #f6f7f9); + --prpl-background-info: var(--prpl-background-content, #f6f5fb); + --prpl-background-step-active: var(--prpl-graph-color-4, #534786); + --prpl-color-number-step-active: var(--prpl-background-paper, #fff); + --prpl-color-ui-icon: #6b7280; /* already exists in variables-color.css */ + --prpl-color-text-step-active: var(--prpl-color-headings, #38296d); + --prpl-color-border: #d1d5db; /* already exists in variables-color.css */ + + /* Button secondary */ + --prpl-color-button-secondary: var(--prpl-background-point, #f9b23c ); + --prpl-color-button-secondary-hover: var(--prpl-color-monthly, #faa310); + --prpl-color-button-secondary-text: #374151; + --prpl-color-button-secondary-icon: var(--prpl-color-button-secondary-text); + + /* Button disabled */ + --prpl-color-button-inactive: var(--prpl-color-gauge-remain, #e1e3e7 ); + --prpl-color-button-inactive-text: var(--prpl-color-text, #4b5563); + + --prpl-background-step-label: var(--prpl-background-step-active); + --prpl-color-step-label: var(--prpl-background-paper, #fff); + + /* Required text */ + --prpl-color-text-error: var(--prpl-color-button-primary, #dd324f); + --prpl-color-alert-error: #e73136; /* already exists in variables-color.css */ + + /* General error */ + --prpl-background-alert-error: #fdeded;/* already exists in variables-color.css */ + --prpl-color-alert-error-text: #7f1d1d; /* already exists in variables-color.css */ + + /* Custom contros (inputs, radio, checkbox) */ + --prpl-color-selection-controls-inactive: #9ca3af; + --prpl-color-selection-controls: var(--prpl-graph-color-4, #534786); + + --prpl-color-field-border: var(--prpl-color-border); + --prpl-color-field-border-active: var(--prpl-color-alert-info, #2563eb); + --prpl-background-field-active: var(--prpl-background-alert-info, #eff6ff); + + + font-family: system-ui, Arial, sans-serif; + font-size: 16px; + + padding: 0; + box-sizing: border-box; + + border-radius: 8px; + font-weight: 400; + max-height: 82vh; + width: 1200px; + max-width: 80vw; + color: var(--prpl-color-text); + background-color: var(--prpl-onboarding-popover-background); + border: 1px solid var(--prpl-color-border); + box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.07),-2px 0 6px rgba(0, 0, 0, 0.07); + + /* Popover backdrop. */ + &::backdrop { + background: rgba(0, 0, 0, 0.5); + } + + /* Popover close button. */ + .prpl-popover-close { + position: absolute; + top: 5px; + right: 5px; + padding: 8px; + cursor: pointer; + background: none; + border: none; + color: var(--prpl-color-text); + z-index: 10; + } + + /* General styles. */ + & * { + box-sizing: border-box; + } + + p { + font-size: 16px; + margin-top: 0; + margin-bottom: 8px; + + &:last-child { + margin-bottom: 0; + } + } + + h2 { + font-family: system-ui, Arial, sans-serif; + margin-top: 0; + margin-bottom: 12px; + font-size: 20px; + } + + h3 { + font-family: system-ui, Arial, sans-serif; + margin-top: 0; + margin-bottom: 8px; + font-size: 16px; + font-weight: 600; + color: var(--prpl-color-text); + + &:last-child { + margin-bottom: 0; + } + } + + /* Copied from WP Core CSS. */ + select { + font-size: 14px; + line-height: 2; + color: #2c3338; + border-color: var(--prpl-color-field-border); + box-shadow: none; + border-radius: 3px; + padding: 0 24px 0 8px; + min-height: 30px; + + /* max-width: 400px; */ + 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; + } + + input[type="text"], + input[type="email"] { + width: 100%; + padding: 8px; + border: 1px solid var(--prpl-color-field-border); + border-radius: 4px; + font-size: 14px; + line-height: 1.4; + color: var(--prpl-color-text); + } + + /* Used for radio and checkbox inputs. */ + .prpl-custom-inputs-wrapper{ + padding-left: 3px; + + /* To prevent custom radio and checkbox from being cut off. */ + display: flex; + flex-direction: column; + gap: 8px; + + .prpl-checkbox-wrapper, + .prpl-radio-wrapper { + display: flex; + flex-direction: column; + justify-content: flex-start; + } + + /* Hide the default input, because WP has it's own styles (which include pseudo-elements). */ + .prpl-custom-checkbox input[type="checkbox"], + .prpl-custom-radio input[type="radio"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; + } + + /* Shared styles for the custom radios and checkboxes. */ + .prpl-custom-control { + display: inline-block; + vertical-align: middle; + margin-right: 12px; + width: 20px; + height: 20px; + box-sizing: border-box; + position: relative; + transition: border-color 0.2s, background 0.2s; + flex-shrink: 0; + } + + /* Label text styling */ + .prpl-custom-checkbox, + .prpl-custom-radio { + display: flex; + align-items: center; + margin-bottom: 8px; + cursor: pointer; + user-select: none; + } + + /* Checkbox styles */ + .prpl-custom-checkbox { + + .prpl-custom-control { + border: 1px solid var(--prpl-color-selection-controls-inactive); + + /* border-radius: 6px; */ + background-color: var(--prpl-onboarding-popover-background); + } + + input[type="checkbox"] { + + /* Checkbox checked (on) */ + &:checked+.prpl-custom-control { + background: var(--prpl-color-selection-controls); + border-color: var(--prpl-color-selection-controls); + } + } + + /* Checkmark */ + .prpl-custom-control::after { + content: ""; + position: absolute; + left: 6px; + top: 2px; + width: 4px; + height: 9px; + border: solid var(--prpl-onboarding-popover-background); + border-width: 0 2px 2px 0; + opacity: 0; + transform: scale(0.8) rotate(45deg); + transition: opacity 0.2s, transform 0.2s; + } + + input[type="checkbox"]:checked+.prpl-custom-control::after { + opacity: 1; + transform: scale(1) rotate(45deg); + } + } + + /* Radio styles */ + .prpl-custom-radio { + + .prpl-custom-control { + border: 1px solid var(--prpl-color-selection-controls-inactive); + border-radius: 50%; + background-color: var(--prpl-onboarding-popover-background); + } + + /* Radio hover (off) */ + input[type="radio"] { + + /* Radio checked (on) */ + &:checked+.prpl-custom-control { + background: var(--prpl-color-selection-controls); + border-color: var(--prpl-color-selection-controls); + } + } + + /* Radio dot */ + .prpl-custom-control::after { + content: ""; + position: absolute; + top: 5px; + left: 5px; + width: 8px; + height: 8px; + background-color: var(--prpl-onboarding-popover-background); + border-radius: 50%; + opacity: 0; + transition: opacity 0.2s; + } + + input[type="radio"]:checked+.prpl-custom-control::after { + opacity: 1; + background-color: var(--prpl-onboarding-popover-background); + } + } + } + + /* Main layout container */ + .prpl-onboarding-layout { + display: flex; + + /* gap: 24px; */ + min-height: 350px; + } + + /* Left column: Step navigation */ + .prpl-onboarding-navigation { + width: 340px; + background: var(--prpl-background-steps); + border-right: none; + padding: 32px 24px 24px 24px; + flex-shrink: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 24px; + } + + .prpl-onboarding-logo { + height: 60px; + max-height: 100%; + + img, svg { + height: 100%; + width: auto; + max-width: 100%; + } + } + + .prpl-step-list { + list-style: none; + margin: 0; + padding: 0; + } + + .prpl-nav-step-item { + margin-top: 10px; + display: flex; + align-items: flex-start; + gap: 14px; + padding: 0; + margin-bottom: 4px; + cursor: default; + + &:first-child { + margin-top: 0; + } + } + + .prpl-step-icon { + width: 20px; + height: 20px; + border-radius: 50%; + border: 1px solid var(--prpl-color-border); + color: var(--prpl-color-text); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 2px; + font-size: 14px; + } + + .prpl-nav-step-item.prpl-active .prpl-step-icon { + background: var(--prpl-background-step-active); + border-color: var(--prpl-background-step-active); + color: var(--prpl-color-number-step-active); + } + + .prpl-nav-step-item.prpl-completed .prpl-step-icon { + background: var(--prpl-color-ui-icon); + border-color: var(--prpl-color-ui-icon); + color: var(--prpl-background-paper); + } + + .prpl-step-label { + font-size: 15px; + color: var(--prpl-color-ui-icon); + line-height: 1.5; + } + + .prpl-nav-step-item.prpl-active .prpl-step-label { + color: var(--prpl-color-text-step-active); + font-weight: 700; + } + + .prpl-nav-step-item.prpl-completed .prpl-step-label { + color: var(--prpl-color-text-step-active); + } + + #prpl-onboarding-mobile-step-label { + display: none; + margin-bottom: 4px; + font-size: 14px; + color: var(--prpl-background-step-active); + font-weight: 600; + } + + /* Right section: Content area */ + .prpl-onboarding-content { + padding: 32px 24px; + flex: 1; + display: flex; + flex-direction: column; + + /* gap: 24px; */ + } + + .tour-content-wrapper { + flex: 1; + + /* overflow-y: auto; */ + } + + .tour-header { + + .tour-title { + color: var(--prpl-background-step-active); + font-weight: 600; + } + } + + .tour-footer { + margin-top: 16px; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; + width: 100%; + + .prpl-tour-next-wrapper { + margin-left: auto; + display: flex; + align-items: center; + gap: 10px; + } + } + + .tour-content { + font-size: 16px; + line-height: 1.5; + + img { + max-width: 100%; + height: auto; + } + + } + + .prpl-columns-wrapper-flex { + display: flex; + gap: 24px; + + /* overflow: hidden; */ + + /* padding-bottom: 10px; */ + + .prpl-background-content { + padding: 20px; + border-radius: 12px; + background-color: var(--prpl-background-info); + } + + .prpl-column { + flex-grow: 1; + flex-basis: 50%; + flex-direction: column; + } + + &.prpl-columns-2-1 { + + .prpl-column:first-child { + flex: 2 1 0; + min-width: 0; + } + + .prpl-column:last-child { + flex: 1 1 0; + min-width: 0; + } + } + + &.prpl-columns-1-2 { + + .prpl-column:first-child { + flex: 1 1 0; + min-width: 0; + } + + .prpl-column:last-child { + flex: 2 1 0; + min-width: 0; + } + } + } + + .prpl-btn { + display: inline-block; + margin: 0; + padding: 12px 20px; + + text-decoration: none; + cursor: pointer; + font-size: 16px; + + /* color: var(--prpl-color-button-primary-text); */ + + /* background: var(--prpl-color-button-primary); */ + background-color: var(--prpl-color-button-secondary); + color: var(--prpl-color-button-secondary-text); + 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; + + flex-shrink: 0; + + /* &:not([disabled]):hover, + &:not([disabled]):focus { + background: var(--prpl-color-button-primary-hover); + } */ + + &.prpl-btn-secondary { + background-color: var(--prpl-color-button-secondary); + color: var(--prpl-color-button-secondary-text); + + &:not([disabled]):not(.prpl-btn-disabled):hover, + &:not([disabled]):not(.prpl-btn-disabled):focus { + background-color: var(--prpl-color-button-secondary-hover); + color: var(--prpl-color-button-secondary-text); + } + } + + &.prpl-btn-disabled, + &:disabled { + + &, + &:hover{ + color: var(--prpl-color-button-inactive-text); + color: rgb(from var(--prpl-color-button-inactive-text) r g b / 0.88); + background-color: var(--prpl-color-button-inactive); + cursor: not-allowed; + } + } + } + + .prpl-complete-task-btn:not(.prpl-btn) { + border: none; + background: none; + cursor: pointer; + padding: 0; + margin: 0; + font-size: 16px; + color: var(--prpl-color-link); + } + +/* + .prpl-complete-task-btn-completed:not(.prpl-btn) { + color: #059669; + pointer-events: none; + opacity: 0.5; + } */ + + .prpl-complete-task-btn-error { + color: #9f0712; + } + + /* Generic error message (reusable for all steps) */ + .prpl-error-message { + animation: prpl-slide-down 0.3s ease-out; + } + + @keyframes prpl-slide-down { + + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } + } + + .prpl-error-box { + display: flex; + align-items: flex-start; + gap: 15px; + background: var(--prpl-background-alert-error); + padding: 20px; + border-radius: 6px; + + .prpl-error-icon { + color: var(--prpl-color-alert-error); + width: 20px; + height: 20px; + flex-shrink: 0; + + img, svg { + width: 100%; + height: 100%; + } + } + + h3 { + margin: 0 0 8px 0; + font-size: 16px; + font-weight: 700; + color: var(--prpl-color-alert-error-text); + } + + p { + margin: 0; + font-size: 16px; + line-height: 1.4; + color: var(--prpl-color-alert-error-text); + } + } + + /* Popover: Quit confirmation */ + &:has(.prpl-quit-confirmation) { + + #prpl-tour-close-btn, + .prpl-onboarding-navigation { + display: none; + } + } + + .prpl-column:has(.prpl-quit-confirmation) { + justify-content: flex-start !important; + } + + .prpl-quit-confirmation { + display: flex; + flex-direction: column; + gap: 30px; + } + + .prpl-quit-message { + display: flex; + align-items: flex-start; + gap: 15px; + background: var(--prpl-background-alert-error); + padding: 20px; + border-radius: 6px; + border-left: 4px solid var(--prpl-color-alert-error); + + h3 { + font-size: 18px; + font-weight: 700; + } + + p { + font-size: 15px; + line-height: 1.6; + } + } + + .prpl-quit-actions { + display: flex; + gap: 40px; + justify-content: space-between; + align-items: center; + } + + .prpl-quit-link { + font-size: 15px; + color: var(--prpl-color-link); + text-decoration: underline; + cursor: pointer; + + &.prpl-quit-link-primary { + font-weight: 600; + font-size: 16px; + } + } + + #prpl-quit-confirmation-graphic { + display: flex; + justify-content: center; + align-items: center; + + img, + svg { + height: 250px; + + /* Set height since it is what we do in the PP dashboard. */ + max-width: 100%; + } + } + + /* Form for onboarding tasks. */ + .prpl-onboarding-task-form { + display: flex; + align-items: center; + gap: 8px; + border: none; + padding: 0; + margin: 0; + background: none; + + .prpl-complete-task-btn { + flex-shrink: 0; + margin-top: 16px; + } + } + + /* Welcome Step */ + &[data-prpl-step="0"] { + + .prpl-column:not(.prpl-column-content) { + display: flex; + justify-content: center; + align-items: center; + } + + #prpl-welcome-graphic { + display: flex; + justify-content: center; + align-items: center; + + img, svg { + height: 250px; /* Set height since it is what we do in the PP dashboard. */ + max-width: 100%; + } + } + + .prpl-welcome-note{ + font-size: 14px; + font-style: italic; + } + } + + /* Privacy checkbox */ + .prpl-privacy-checkbox-wrapper { + margin-top: 20px; + + label { + gap: 10px; + cursor: pointer; + font-size: 15px; + color: var(--prpl-color-text); + } + + input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + } + + a { + color: var(--prpl-color-link); + text-decoration: underline; + } + + .prpl-required-indicator { + font-size: 14px; + font-style: italic; + + &::before { + content: '('; + } + + &::after { + content: ')'; + } + + &.prpl-required-indicator-active { + color: var(--prpl-color-text-error); + font-size: 16px; + font-style: normal; + + &::before { + display: inline-flex; + align-items: center; + justify-content: center; + content: '!'; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--prpl-color-alert-error); + color: #fff; + font-size: 12px; + margin-right: 5px; + } + + &::after { + content: '!'; + } + } + } + } + + /* What's Next Step */ + .prpl-suggested-task-points { + font-size: var(--prpl-font-size-xs, 12px); + font-weight: 700; + color: var(--prpl-text-point); + background-color: var(--prpl-background-point); + width: 24px; + height: 24px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + } + + /* First Task Step */ + .prpl-onboarding-task { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + + /* .prpl-onboarding-task-title { + margin: 0 0 16px 0; + font-size: 18px; + font-weight: 600; + } */ + + .prpl-onboarding-task-form { + margin-top: 16px; + width: 100%; + flex-direction: column; + + .prpl-complete-task-btn { + align-self: flex-end; + + &.prpl-complete-task-btn-completed { + opacity: 0.5; + pointer-events: none; + } + } + } + } + + /* Badges Step */ + .prpl-gauge-wrapper { + max-width: 100%; + text-align: center; + } + + /* Email Frequency Step */ + + /* .prpl-email-frequency-options { + + . + display: flex; + gap: 10px; + flex-direction: column; + } */ + + #prpl-email-form { + margin-top: 16px; + + .prpl-form-field { + margin-top: 16px; + + &:first-child { + margin-top: 0; + } + + label { + display: inline-block; + margin-bottom: 8px; + } + } + } + + /* Settings Step */ + .tour-content-wrapper:has(.prpl-setting-item) { + display: flex; + flex-direction: column; + flex-grow: 1; + + .tour-content, + .prpl-setting-item, + .prpl-setting-content { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 24px; + } + + .prpl-setting-title { + margin: 0 0 16px 0; + } + + .prpl-settings-progress { + font-size: 14px; + color: var(--prpl-color-step-label); + padding: 4px 8px; + border-radius: 20px; + background-color: var(--prpl-background-step-active); + } + + .prpl-setting-footer { + display: flex; + justify-content: flex-end; + align-items: flex-start; + gap: 10px; + margin-top: 16px; + + .prpl-save-setting-btn { + flex-shrink: 0; + } + } + + .prpl-select-page { + + &.prpl-disabled { + opacity: 0.5; + pointer-events: none; + } + } + + .prpl-setting-note { + display: none; + gap: 10px; + border-radius: 12px; + padding: 16px; + color: var(--prpl-color-field-border-active); + font-size: 14px; + background-color: var(--prpl-background-field-active); + + .prpl-setting-note-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + color: var(--prpl-color-field-border-active); + } + } + + /* Post types sub-step */ + #prpl-post-types-include-wrapper { + display: flex; + flex-direction: column; + gap: 12px; + } + + /* Toggle checkbox for post types. */ + .prpl-post-type-toggle-wrapper { + display: flex; + align-items: center; + } + + .prpl-post-type-toggle-label { + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + position: relative; + } + + .prpl-post-type-toggle-input { + position: absolute; + opacity: 0; + width: 0; + height: 0; + margin: 0; + padding: 0; + } + + .prpl-post-type-toggle-switch { + position: relative; + width: 44px; + height: 24px; + border-radius: 12px; + background-color: var(--prpl-color-selection-controls-inactive); + transition: background-color 0.2s; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + &::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background-color: var(--prpl-onboarding-popover-background); + transition: transform 0.2s; + z-index: 1; + } + + svg { + position: absolute; + width: 12px; + height: 12px; + top: 50%; + left: 6px; + transform: translateY(-50%); + z-index: 2; + transition: opacity 0.2s, left 0.2s, color 0.2s; + color: var(--prpl-color-ui-icon); + } + + .prpl-toggle-icon-check { + display: none; + } + + .prpl-toggle-icon-x { + display: block; + } + } + + .prpl-post-type-toggle-label:hover .prpl-post-type-toggle-switch svg { + opacity: 0.6; + } + + .prpl-post-type-toggle-input:checked ~ .prpl-post-type-toggle-switch { + background-color: var(--prpl-background-step-active); + + &::after { + transform: translateX(20px); + } + + svg { + left: 26px; + color: var(--prpl-background-step-active); + transform: translateY(-50%); + } + + .prpl-toggle-icon-check { + display: block; + } + + .prpl-toggle-icon-x { + display: none; + } + } + + .prpl-post-type-toggle-text { + font-size: 16px; + line-height: 1.5; + transition: opacity 0.2s; + } + + .prpl-post-type-toggle-input:not(:checked) ~ .prpl-post-type-toggle-text { + opacity: 0.78; + } + + .prpl-post-type-toggle-input:checked ~ .prpl-post-type-toggle-text { + opacity: 1; + } + } + + /* More Tasks Step */ + + #prpl-success-graphic { + display: flex; + justify-content: center; + align-items: center; + + img, svg { + height: 250px; + max-width: 100%; + } + } + + /* Intro substep */ + .prpl-more-tasks-substep[data-substep="more-tasks-intro"] { + display: flex; + flex-direction: column; + gap: 24px; + } + + .prpl-more-tasks-intro-buttons { + margin-top: 16px; + display: flex; + align-items: center; + justify-content: center; + gap: 24px; + flex-wrap: wrap; + } + + .prpl-finish-onboarding { + background: none; + border: none; + color: var(--prpl-color-link, #534786); + text-decoration: underline; + font-size: 16px; + cursor: pointer; + padding: 12px 0; + } + + .prpl-finish-onboarding:hover, + .prpl-finish-onboarding:focus { + color: var(--prpl-color-link-hover, #38296d); + } + + /* Tasks substep */ + .prpl-more-tasks-substep[data-substep="more-tasks-tasks"] .prpl-task-list { + margin: 0; + } + + .prpl-column:has(.prpl-task-list) { + display: flex; + align-items: center; + } + + .prpl-task-list { + list-style: none; + padding: 0; + margin: 0; + width: 100%; + border: 1px solid #ccc; + + li { + padding: 7px 5px; + margin: 0; + + &:nth-child(odd) { + background-color: var(--prpl-background-steps); + } + + .task-title { + color: var(--prpl-color-text); + font-weight: 500; + } + } + } + + .prpl-complete-task-item { + display: flex; + gap: 30px; + justify-content: space-between; + } + + .prpl-task-arrow { + padding-right: 8px; + } + + .prpl-task-item-button-wrapper { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + } + + .prpl-task-completed-icon { + display: none; + width: 20px; + height: 20px; + border-radius: 50%; + align-items: center; + justify-content: center; + color: #fff; + background-color: #059669; + } + + .prpl-task-completed { + + /* Show the completed icon. */ + .prpl-task-completed-icon { + display: inline-flex; + } + + /* Hide the trigger button and +1. */ + .prpl-task-item-button-wrapper { + display: none; + } + } + + .tour-content-wrapper:has(.prpl-task-content-active) { + display: flex; + flex-direction: column; + flex-grow: 1; + + /* Task content active, TOOD: change markup so this is simpler. */ + .prpl-task-content-active { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 24px; + width: 100%; + + .prpl-onboarding-task { + display: flex; + flex-direction: column; + flex-grow: 1; + + .prpl-onboarding-task-form, + .tour-content { + display: flex; + flex-direction: column; + flex-grow: 1; + } + + .prpl-onboarding-task-form { + justify-content: space-between; + gap: 48px; + } + } + } + + /* Task buttons. */ + .prpl-task-buttons { + display: flex; + justify-content: flex-end; + gap: 16px; + width: 100%; + + .prpl-btn { + margin-top: 0; + margin-bottom: 0; + + &.prpl-task-close-btn { + background-color: var(--prpl-background-banner); + color: var(--prpl-color-text); + } + } + } + } + + /* File drop zone. */ + .prpl-file-drop-zone { + width: 100%; + border-radius: 12px; + padding: 40px; + text-align: center; + color: var(--prpl-color-text); + transition: background 0.2s, border-color 0.2s; + cursor: pointer; + border: 2px dashed var(--prpl-color-field-border); + + svg { + + path { + stroke: var(--prpl-color-ui-icon); + } + } + } + + .prpl-file-drop-zone.dragover { + background-color: var(--prpl-background-field-active); + border-color: var(--prpl-color-field-border-active); + } + + /* When an image has been uploaded. */ + .prpl-file-drop-zone.has-image { + border-color: var(--prpl-color-field-border-active); + background-color: var(--prpl-background-field-active); + + & > .prpl-icon-image, + & > p, + & > .prpl-file-upload-hints { + display: none; + } + + .prpl-file-preview img { + border: 2px solid var(--prpl-color-field-border); + border-radius: 8px; + padding: 8px; + background: var(--prpl-color-background-white); + } + } + + .prpl-file-browse-link { + color: var(--prpl-color-link); + text-decoration: underline; + cursor: pointer; + } + + .prpl-file-remove-btn { + border: none; + background: none; + cursor: pointer; + padding: 0; + margin: 12px 0 0; + font-size: 16px; + color: var(--prpl-color-link); + text-decoration: underline; + } + + .prpl-file-upload-hints { + display: flex; + flex-direction: column; + font-size: 14px; + color: var(--prpl-color-ui-icon); + } + + /* WIP */ + #prpl-upload-status { + margin-top: 10px; + font-family: monospace; + } + + .prpl-file-preview { + display: none; /* Hidden by default. */ + margin-top: 10px; + margin-left: auto; + margin-right: auto; + max-width: 200px; + height: auto; + } +} + +@media (max-width: 1023px) { + + .prpl-popover-onboarding { + --prpl-mobile-nav-height: 60px; + max-width: 90vw; + inset: 0 0 var(--prpl-mobile-nav-height) 0; /* TODO: Adjust this for smallest screen sizes. */ + margin: auto auto var(--prpl-mobile-nav-height) auto; /* Center in available space above nav */ + + /* Hide graphics */ + .prpl-hide-on-mobile { + display: none !important; + } + + /* Quit confirmation */ + .prpl-quit-actions { + flex-direction: column; + gap: 16px; + } + + /* Naviation section */ + .prpl-onboarding-navigation { + position: fixed; + left: 0; + bottom: 0; + width: 100%; + height: var(--prpl-mobile-nav-height); + z-index: 1000; + + flex-direction: row; + gap: 16px; + justify-content: center; + align-items: center; + padding: 8px; + + .prpl-step-list { + display: flex; + gap: 8px; + + .prpl-nav-step-item { + margin: 0; + } + + .prpl-step-label { + display: none; + } + } + + #prpl-onboarding-mobile-step-label { + display: block; + } + } + + /* Content section */ + .prpl-columns-wrapper-flex { + flex-direction: column; + } + + .prpl-setting-footer, /* On the settings steps */ + .tour-footer { + flex-direction: column; /* So the info / error message is on top of the button. */ + } + + /* Badges step */ + .prpl-gauge-wrapper { + max-width: 400px; + margin-left: auto; + margin-right: auto; + } + } +} diff --git a/assets/css/welcome.css b/assets/css/welcome.css deleted file mode 100644 index 20004600b4..0000000000 --- a/assets/css/welcome.css +++ /dev/null @@ -1,77 +0,0 @@ -.prpl-wrap.prpl-pp-not-accepted { - padding: 0; - background-color: var(--prpl-background-paper); - border: 1px solid var(--prpl-color-border); - border-radius: var(--prpl-border-radius); -} - -.prpl-welcome { - - .inner-content { - padding: calc(var(--prpl-gap) * 1.5); - padding-bottom: 0; - margin-bottom: calc(var(--prpl-gap) * 1.5); - display: flex; - grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); - gap: calc(var(--prpl-gap) * 2); - - .left { - flex-grow: 1; - } - - img { - max-width: 100%; - width: 550px; - height: auto; - } - } - - .welcome-header { - background: var(--prpl-background-banner); - display: flex; - justify-content: space-between; - align-items: center; - border-top-left-radius: var(--prpl-border-radius); - border-top-right-radius: var(--prpl-border-radius); - overflow: hidden; - - h1 { - font-size: var(--prpl-font-size-3xl); - padding: var(--prpl-padding) calc(var(--prpl-gap) * 1.5); - font-weight: 600; - } - - .welcome-header-icon { - background: var(--prpl-background-banner); - background: linear-gradient(105deg, var(--prpl-background-banner) 25%, var(--prpl-background-monthly) 25%); - padding: var(--prpl-padding); - padding-left: 100px; - padding-right: calc(var(--prpl-gap) * 1.5); - - svg { - height: 100px; - } - } - } - - .prpl-form-notice-title { - font-size: var(--prpl-font-size-lg); - } - - ul { - list-style: disc; - margin-left: 1rem; - } - - .prpl-onboard-form-radio-select { - margin-top: 0.75rem; - - label { - margin-top: 0.5rem; - - &:first-child { - margin-top: 0; - } - } - } -} diff --git a/assets/images/onboarding/icon_image.svg b/assets/images/onboarding/icon_image.svg new file mode 100644 index 0000000000..24f79c262a --- /dev/null +++ b/assets/images/onboarding/icon_image.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/onboarding/icon_info_solid.svg b/assets/images/onboarding/icon_info_solid.svg new file mode 100644 index 0000000000..9b6e2d98f2 --- /dev/null +++ b/assets/images/onboarding/icon_info_solid.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/onboarding/neglected_site_ravi.svg b/assets/images/onboarding/neglected_site_ravi.svg new file mode 100644 index 0000000000..14acee56f5 --- /dev/null +++ b/assets/images/onboarding/neglected_site_ravi.svg @@ -0,0 +1 @@ + diff --git a/assets/images/onboarding/success_ravi.svg b/assets/images/onboarding/success_ravi.svg new file mode 100644 index 0000000000..45e8535d4e --- /dev/null +++ b/assets/images/onboarding/success_ravi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/onboarding/thumbs_up_ravi_rtl.svg b/assets/images/onboarding/thumbs_up_ravi_rtl.svg new file mode 100644 index 0000000000..27234bc510 --- /dev/null +++ b/assets/images/onboarding/thumbs_up_ravi_rtl.svg @@ -0,0 +1 @@ + diff --git a/assets/js/license-generator.js b/assets/js/license-generator.js new file mode 100644 index 0000000000..5dfc2bb109 --- /dev/null +++ b/assets/js/license-generator.js @@ -0,0 +1,140 @@ +/** + * License Generator - Handles license key generation during onboarding + * Adapted from onboard.js + */ +/* global progressPlanner */ + +// eslint-disable-next-line no-unused-vars +class LicenseGenerator { + /** + * Store config for use in other methods. + * + * @type {Object} + */ + static config = null; + + /** + * Get the default config from progressPlanner global. + * + * @return {Object} Default configuration object. + */ + static getDefaultConfig() { + // eslint-disable-next-line no-undef + if ( typeof progressPlanner !== 'undefined' ) { + return { + // eslint-disable-next-line no-undef + onboardNonceURL: progressPlanner.onboardNonceURL, + // eslint-disable-next-line no-undef + onboardAPIUrl: progressPlanner.onboardAPIUrl, + // eslint-disable-next-line no-undef + adminAjaxUrl: progressPlanner.ajaxUrl, + // eslint-disable-next-line no-undef + nonce: progressPlanner.nonce, + }; + } + return {}; + } + + /** + * Make a request to save the license key. + * + * @param {string} licenseKey The license key. + * @return {Promise} Promise that resolves when license is saved + */ + static saveLicenseKey( licenseKey ) { + console.log( 'License key: ' + licenseKey ); + return LicenseGenerator.ajaxRequest( { + url: LicenseGenerator.config.adminAjaxUrl, + data: { + action: 'progress_planner_save_onboard_data', + _ajax_nonce: LicenseGenerator.config.nonce, + key: licenseKey, + }, + } ); + } + + /** + * Make the AJAX request to the API. + * + * @param {Object} data The data to send with the request. + * @return {Promise} Promise that resolves when request completes + */ + static ajaxAPIRequest( data ) { + return LicenseGenerator.ajaxRequest( { + url: LicenseGenerator.config.onboardAPIUrl, + data, + } ) + .then( ( response ) => { + // Make a local request to save the response data. + return LicenseGenerator.saveLicenseKey( response.license_key ); + } ) + .catch( ( error ) => { + console.warn( error ); + throw error; + } ); + } + + /** + * Make the AJAX request. + * + * Make a request to get the nonce. + * Once the nonce is received, make a request to the API. + * + * @param {Object} data The data to send with the request. + * @param {Object} config Optional configuration object. Falls back to progressPlanner global. + * @return {Promise} Promise that resolves when license is generated + */ + static generateLicense( data = {}, config = null ) { + // Store config for use in other methods, fall back to default if not provided. + LicenseGenerator.config = config || LicenseGenerator.getDefaultConfig(); + + return LicenseGenerator.ajaxRequest( { + url: LicenseGenerator.config.onboardNonceURL, + data, + } ).then( ( response ) => { + if ( 'ok' === response.status ) { + // Add the nonce to our data object. + data.nonce = response.nonce; + + // Make the request to the API. + return LicenseGenerator.ajaxAPIRequest( data ); + } + // Handle error response + const errorMessage = + response.message || + 'Failed to get nonce for license generation'; + throw new Error( errorMessage ); + } ); + } + + /** + * Helper function to make AJAX requests + * + * @param {Object} options Request options + * @param {string} options.url The URL to send the request to + * @param {Object} options.data The data to send with the request + * @return {Promise} Promise that resolves with response data + */ + static ajaxRequest( options ) { + const { url, data } = options; + + return fetch( url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams( data ), + credentials: 'same-origin', + } ) + .then( ( response ) => { + if ( ! response.ok ) { + throw new Error( 'Request failed: ' + response.status ); + } + return response.json(); + } ) + .catch( ( error ) => { + console.error( 'AJAX request error:', error ); + throw error; + } ); + } +} diff --git a/assets/js/onboard.js b/assets/js/onboard.js deleted file mode 100644 index 529b05831b..0000000000 --- a/assets/js/onboard.js +++ /dev/null @@ -1,168 +0,0 @@ -/* global progressPlanner, progressPlannerAjaxRequest */ -/* - * Onboard - * - * A script to handle the onboarding process. - * - * Dependencies: progress-planner/ajax-request, progress-planner/upgrade-tasks - */ - -/** - * Make a request to save the license key. - * - * @param {string} licenseKey The license key. - */ -const progressPlannerSaveLicenseKey = ( licenseKey ) => { - console.log( 'License key: ' + licenseKey ); - return progressPlannerAjaxRequest( { - url: progressPlanner.ajaxUrl, - data: { - action: 'progress_planner_save_onboard_data', - _ajax_nonce: progressPlanner.nonce, - key: licenseKey, - }, - } ); -}; - -/** - * Make the AJAX request. - * - * @param {Object} data The data to send with the request. - */ -const progressPlannerAjaxAPIRequest = ( data ) => { - progressPlannerAjaxRequest( { - url: progressPlanner.onboardAPIUrl, - data, - } ) - .then( ( response ) => { - // Make a local request to save the response data. - progressPlannerSaveLicenseKey( response.license_key ).then( () => { - // Refresh the page. - window.location.reload(); - } ); - } ) - .catch( ( error ) => { - console.warn( error ); - } ); -}; - -/** - * Make the AJAX request. - * - * Make a request to get the nonce. - * Once the nonce is received, make a request to the API. - * - * @param {Object} data The data to send with the request. - */ -const progressPlannerOnboardCall = ( data ) => { - progressPlannerAjaxRequest( { - url: progressPlanner.onboardNonceURL, - data, - } ).then( ( response ) => { - if ( 'ok' === response.status ) { - // Add the nonce to our data object. - data.nonce = response.nonce; - - // Make the request to the API. - progressPlannerAjaxAPIRequest( data ); - } - } ); -}; - -if ( document.getElementById( 'prpl-onboarding-form' ) ) { - document - .querySelectorAll( 'input[name="with-email"]' ) - .forEach( ( input ) => { - input.addEventListener( 'change', function () { - if ( 'no' === this.value ) { - document - .getElementById( 'prpl-onboarding-form' ) - .querySelectorAll( 'input' ) - .forEach( ( inputField ) => { - inputField.required = false; - } ); - } else { - document - .getElementById( 'prpl-onboarding-form' ) - .querySelectorAll( 'input' ) - .forEach( ( inputField ) => { - if ( - 'name' === inputField.name || - 'email' === inputField.name - ) { - inputField.required = true; - } - } ); - } - document - .getElementById( 'prpl-onboarding-form' ) - .querySelectorAll( - '.prpl-form-fields, .prpl-form-fields, .prpl-button-primary, .prpl-button-secondary--no-email' - ) - .forEach( ( el ) => el.classList.toggle( 'prpl-hidden' ) ); - } ); - } ); - - document - .querySelector( '#prpl-onboarding-form input[name="privacy-policy"]' ) - .addEventListener( 'change', function () { - const privacyPolicyAccepted = !! this.checked; - - if ( privacyPolicyAccepted ) { - document - .getElementById( 'prpl-onboarding-submit-wrapper' ) - .classList.remove( 'prpl-disabled' ); - } else { - document - .getElementById( 'prpl-onboarding-submit-wrapper' ) - .classList.add( 'prpl-disabled' ); - } - } ); - - document - .getElementById( 'prpl-onboarding-form' ) - .addEventListener( 'submit', function ( event ) { - event.preventDefault(); - - const privacyPolicyAccepted = !! document.querySelector( - '#prpl-onboarding-form input[name="privacy-policy"]' - ).checked; - - // Make sure the user accepted the privacy policy. - if ( ! privacyPolicyAccepted ) { - return; - } - - // Disable all (both buttons) submit buttons. - document - .querySelectorAll( - '#prpl-onboarding-form input[type="submit"]' - ) - .forEach( ( input ) => { - input.disabled = true; - } ); - - // Show the spinner. - const spinner = document.createElement( 'span' ); - spinner.classList.add( 'prpl-spinner' ); - spinner.innerHTML = - ''; // WP spinner. - - // Append spinner after submit button. - - document - .getElementById( 'prpl-onboarding-submit-wrapper' ) - .appendChild( spinner ); - - // Get all form data. - const data = Object.fromEntries( new FormData( event.target ) ); - - // If the user doesn't want to use email, remove the email and name. - if ( 'no' === data.with_email ) { - data.email = ''; - data.name = ''; - } - - progressPlannerOnboardCall( data ); - } ); -} diff --git a/assets/js/onboarding/OnboardTask.js b/assets/js/onboarding/OnboardTask.js new file mode 100644 index 0000000000..2db23b7816 --- /dev/null +++ b/assets/js/onboarding/OnboardTask.js @@ -0,0 +1,470 @@ +/** + * OnboardTask - Handles individual tasks that open within the column + * Used by the MoreTasksStep for tasks that require user input + * Toggles visibility of task list and shows task content in the same column + */ +/* global ProgressPlannerOnboardData, ProgressPlannerTourUtils */ + +// eslint-disable-next-line no-unused-vars +class PrplOnboardTask { + constructor( el, wizard ) { + this.el = el; + this.id = el.dataset.taskId; + this.wizard = wizard; + this.taskContent = null; + this.formValues = {}; + this.openTaskBtn = el.querySelector( '[prpl-open-task]' ); + + // Register task open event + this.openTaskBtn?.addEventListener( 'click', () => this.open() ); + } + + /** + * Get the tour footer element via the current step + * @return {HTMLElement|null} The tour footer element or null if not found + */ + getTourFooter() { + // Get the current step and use its getTourFooter method + const currentStep = + this.wizard?.tourSteps?.[ this.wizard.state.currentStep ]; + if ( currentStep && typeof currentStep.getTourFooter === 'function' ) { + return currentStep.getTourFooter(); + } + + // Fallback in case step doesn't have the method + return this.wizard?.contentWrapper?.querySelector( '.tour-footer' ); + } + + registerEvents() { + this.taskContent.addEventListener( 'click', ( e ) => { + if ( e.target.classList.contains( 'prpl-complete-task-btn' ) ) { + const formData = new FormData( + this.taskContent.querySelector( 'form' ) + ); + this.formValues = Object.fromEntries( formData.entries() ); + this.complete(); + } + } ); + + // Close button handler + const closeBtn = this.taskContent.querySelector( + '.prpl-task-close-btn' + ); + closeBtn?.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.taskContent ) { + return; // Already open + } + + // Find the column containing the task list + const taskList = this.wizard.popover.querySelector( '.prpl-task-list' ); + if ( ! taskList ) { + return; + } + + const column = taskList.closest( '.prpl-column' ); + if ( ! column ) { + return; + } + + // Hide the task list + taskList.style.display = 'none'; + + // Hide the tour footer (it's part of the step content) + const tourFooter = this.getTourFooter(); + if ( tourFooter ) { + tourFooter.style.display = 'none'; + } + + // Get task content from template + const content = this.el + .querySelector( 'template' ) + .content.cloneNode( true ); + + // Create task content wrapper + this.taskContent = document.createElement( 'div' ); + this.taskContent.className = 'prpl-task-content-active'; + this.taskContent.appendChild( content ); + + // Find the complete button in the form + const completeBtn = this.taskContent.querySelector( + '.prpl-complete-task-btn' + ); + + if ( completeBtn ) { + // Create close button + const closeBtn = document.createElement( 'button' ); + closeBtn.type = 'button'; + closeBtn.className = 'prpl-btn prpl-task-close-btn'; + closeBtn.innerHTML = + ' ' + + ProgressPlannerOnboardData.l10n.backToRecommendations; + + // Create button wrapper + const buttonWrapper = document.createElement( 'div' ); + buttonWrapper.className = 'prpl-task-buttons'; + + // Move complete button into wrapper + completeBtn.parentNode.insertBefore( buttonWrapper, completeBtn ); + buttonWrapper.appendChild( closeBtn ); + buttonWrapper.appendChild( completeBtn ); + } + + // Add task content to the column + column.appendChild( this.taskContent ); + + // Hide the popover close button + const popoverCloseBtn = this.wizard.popover.querySelector( + '#prpl-tour-close-btn' + ); + if ( popoverCloseBtn ) { + popoverCloseBtn.style.display = 'none'; + } + + // Register events + this.registerEvents(); + } + + close() { + if ( ! this.taskContent ) { + return; + } + + // Remove task content + this.taskContent.remove(); + + // Show the task list + const taskList = this.wizard.popover.querySelector( '.prpl-task-list' ); + if ( taskList ) { + taskList.style.display = ''; + } + + // Show the tour footer (it's part of the step content) + const tourFooter = this.getTourFooter(); + if ( tourFooter ) { + tourFooter.style.display = ''; + } + + // Show the popover close button + const popoverCloseBtn = this.wizard.popover.querySelector( + '#prpl-tour-close-btn' + ); + if ( popoverCloseBtn ) { + popoverCloseBtn.style.display = ''; + } + + // Clean up + this.taskContent = null; + } + + complete() { + ProgressPlannerTourUtils.completeTask( this.id, this.formValues ) + .then( () => { + this.el.classList.add( 'prpl-task-completed' ); + const taskBtn = this.el.querySelector( + '.prpl-complete-task-btn' + ); + if ( taskBtn ) { + taskBtn.disabled = true; + } + + 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.taskContent.querySelector( 'form' ); + const submitButton = this.taskContent.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.taskContent.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, + } ) + ); + } + ); + } + } ); + + // Remove button handler. + const removeBtn = uploadContainer.querySelector( '.prpl-file-remove-btn' ); + const previewDiv = uploadContainer.querySelector( '.prpl-file-preview' ); + removeBtn?.addEventListener( 'click', () => { + this.removeUploadedFile( uploadContainer, previewDiv ); + } ); + } + + async uploadFile( file, statusDiv ) { + // Validate file extension + if ( ! this.isValidFaviconFile( file ) ) { + const fileInput = + this.taskContent.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': ProgressPlannerOnboardData.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 ) => { + // Testing only, no need to display file name in production. + // statusDiv.textContent = `${ file.name } uploaded.`; + statusDiv.style.display = 'none'; + + // Update the file preview. + const previewDiv = + this.taskContent.querySelector( '.prpl-file-preview' ); + if ( previewDiv ) { + previewDiv.innerHTML = `${ file.name }`; + previewDiv.style.display = 'block'; + + // Add has-image class to drop zone to update styling. + const dropZone = this.taskContent.querySelector( + '.prpl-file-drop-zone' + ); + if ( dropZone ) { + dropZone.classList.add( 'has-image' ); + + // Show the remove button. + const removeBtn = dropZone.querySelector( + '.prpl-file-remove-btn' + ); + if ( removeBtn ) { + removeBtn.hidden = false; + } + } + } + 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.taskContent.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; + } ); + } + + /** + * Remove uploaded file and reset the drop zone state. + * @param {HTMLElement} dropZone The drop zone element. + * @param {HTMLElement} previewDiv The preview container element. + */ + removeUploadedFile( dropZone, previewDiv ) { + // Clear the preview. + previewDiv.innerHTML = ''; + previewDiv.style.display = 'none'; + + // Remove has-image class. + dropZone.classList.remove( 'has-image' ); + + // Hide the remove button. + const removeBtn = dropZone.querySelector( '.prpl-file-remove-btn' ); + if ( removeBtn ) { + removeBtn.hidden = true; + } + + // Clear the file input. + const fileInput = dropZone.querySelector( 'input[type="file"]' ); + if ( fileInput ) { + fileInput.value = ''; + } + + // Clear the hidden post_id input and trigger validation. + const postIdInput = dropZone.querySelector( 'input[name="post_id"]' ); + if ( postIdInput ) { + postIdInput.value = ''; + postIdInput.dispatchEvent( + new CustomEvent( 'change', { bubbles: true } ) + ); + } + + // Show status div again. + const statusDiv = dropZone.querySelector( '.prpl-upload-status' ); + if ( statusDiv ) { + statusDiv.style.display = ''; + statusDiv.textContent = ''; + } + } +} diff --git a/assets/js/onboarding/onboarding.js b/assets/js/onboarding/onboarding.js new file mode 100644 index 0000000000..3bda0afeb6 --- /dev/null +++ b/assets/js/onboarding/onboarding.js @@ -0,0 +1,501 @@ +/** + * Progress Planner Onboarding Wizard + * Handles the onboarding wizard functionality + * + * Dependencies: progress-planner/license-generator + */ +/* global ProgressPlannerOnboardData */ + +// eslint-disable-next-line no-unused-vars +class ProgressPlannerOnboardWizard { + constructor( config ) { + this.config = config; + this.state = { + currentStep: 0, + data: { + moreTasksCompleted: {}, + firstTaskCompleted: false, + finished: false, + }, + cleanup: null, + }; + + // Store previously focused element for accessibility + this.previouslyFocusedElement = null; + + // Restore saved progress if available + this.restoreSavedProgress(); + + // Make state work with reactive updates. + this.setupStateProxy(); + + // Set DOM related properties FIRST. + this.popover = document.getElementById( this.config.popoverId ); + this.contentWrapper = this.popover.querySelector( + '.tour-content-wrapper' + ); + + // Popover buttons. + this.closeBtn = this.popover.querySelector( '#prpl-tour-close-btn' ); + + // Initialize tour steps AFTER popover is set + this.tourSteps = this.initializeTourSteps(); + + // Setup event listeners after DOM is ready + this.setupEventListeners(); + } + + /** + * Restore saved progress from server + */ + restoreSavedProgress() { + if ( + ! this.config.savedProgress || + typeof this.config.savedProgress !== 'object' + ) { + return; + } + + const savedState = this.config.savedProgress; + + // Restore currentStep if valid + if ( + typeof savedState.currentStep === 'number' && + savedState.currentStep >= 0 + ) { + this.state.currentStep = savedState.currentStep; + console.log( + 'Restored onboarding progress to step:', + this.state.currentStep + ); + } + + // Restore data object if present + if ( savedState.data && typeof savedState.data === 'object' ) { + // Merge saved data with default state + this.state.data = { + ...this.state.data, + ...savedState.data, + }; + + // Ensure moreTasksCompleted is an object + if ( + ! this.state.data.moreTasksCompleted || + typeof this.state.data.moreTasksCompleted !== 'object' + ) { + this.state.data.moreTasksCompleted = {}; + } + + console.log( 'Restored onboarding data:', this.state.data ); + } + } + + /** + * Initialize tour steps configuration + * Creates instances of step components + */ + initializeTourSteps() { + // Create instances of step components. + const steps = this.config.steps.map( ( stepName ) => { + if ( + window[ `Prpl${ stepName }` ] && + typeof window[ `Prpl${ stepName }` ] === 'object' + ) { + return window[ `Prpl${ stepName }` ]; + } + + console.error( + `Step class "${ stepName }" not found. Available on window:`, + Object.keys( window ).filter( ( key ) => + key.includes( 'Step' ) + ) + ); + + return null; + } ); + + // Set wizard reference for each step + steps.forEach( ( step ) => step.setWizard( this ) ); + + return steps; + } + + /** + * Render current step + */ + renderStep() { + const step = this.tourSteps[ this.state.currentStep ]; + + // Render step content + this.contentWrapper.innerHTML = step.render(); + + // Cleanup previous step + if ( this.state.cleanup ) { + this.state.cleanup(); + this.state.cleanup = null; + } + + // Mount current step and store cleanup function + this.state.cleanup = step.onMount( this.state ); + + // Setup next button (handled by step now) + step.setupNextButton(); + + // Update step indicator + this.popover.dataset.prplStep = this.state.currentStep; + this.updateStepNavigation(); + } + + /** + * Update step navigation in left column + */ + updateStepNavigation() { + const stepItems = this.popover.querySelectorAll( + '.prpl-nav-step-item' + ); + let activeStepTitle = ''; + + stepItems.forEach( ( item, index ) => { + const icon = item.querySelector( '.prpl-step-icon' ); + const stepNumber = index + 1; + + // Remove all state classes + item.classList.remove( 'prpl-active', 'prpl-completed' ); + + // Add appropriate class and update icon + if ( index < this.state.currentStep ) { + // Completed step: show checkmark + item.classList.add( 'prpl-completed' ); + icon.textContent = 'âś“'; + } else if ( index === this.state.currentStep ) { + // Active step: show number + item.classList.add( 'prpl-active' ); + icon.textContent = stepNumber; + activeStepTitle = + item.querySelector( '.prpl-step-label' ).textContent; + } else { + // Future step: show number + icon.textContent = stepNumber; + } + } ); + + // Update mobile step label + const mobileStepLabel = this.popover.querySelector( + '#prpl-onboarding-mobile-step-label' + ); + if ( mobileStepLabel ) { + mobileStepLabel.textContent = activeStepTitle; + } + } + + /** + * Move to next step + */ + async nextStep() { + console.log( + 'nextStep() called, current step:', + this.state.currentStep + ); + const step = this.tourSteps[ this.state.currentStep ]; + + // Check if user can proceed from current step + if ( ! step.canProceed( this.state ) ) { + console.log( 'Cannot proceed - step requirements not met' ); + return; + } + + // Call beforeNextStep if step has it (for async operations like license generation) + if ( step.beforeNextStep ) { + try { + await step.beforeNextStep(); + } catch ( error ) { + console.error( 'Error in beforeNextStep:', error ); + return; // Don't proceed if beforeNextStep fails + } + } + + 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( 'Finishing tour - reached last step' ); + this.state.data.finished = true; + this.closeTour(); + + // Redirect to the Progress Planner dashboard + if ( + this.config.lastStepRedirectUrl && + this.config.lastStepRedirectUrl.length > 0 + ) { + window.location.href = this.config.lastStepRedirectUrl; + } + } + } + + /** + * Move to previous step, currently not used. + */ + 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; + + // Restore focus to previously focused element for accessibility + if ( + this.previouslyFocusedElement && + typeof this.previouslyFocusedElement.focus === 'function' + ) { + this.previouslyFocusedElement.focus(); + this.previouslyFocusedElement = null; + } + } + + /** + * Start the onboarding + */ + startOnboarding() { + if ( this.popover ) { + // Store currently focused element for accessibility + this.previouslyFocusedElement = + this.popover.ownerDocument.activeElement; + + this.popover.showPopover(); + this.updateStepNavigation(); + this.renderStep(); + + // Move focus to popover for keyboard accessibility + // Use setTimeout to ensure popover is visible before focusing + setTimeout( () => { + this.popover.focus(); + }, 0 ); + } + } + + /** + * 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_onboarding_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 + * Delegates to current step's updateNextButton method + */ + updateNextButton() { + const step = this.tourSteps[ this.state.currentStep ]; + if ( step && typeof step.updateNextButton === 'function' ) { + step.updateNextButton(); + } + } + + /** + * Update DOM, used for reactive updates. + * All changes which should happen when the state changes should be done here. + */ + updateDOM() { + this.updateNextButton(); + } + + /** + * 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' ); + } + } ); + + // Note: nextBtn click handler is now set up in renderStep() + // since the button is part of the step content + + if ( this.closeBtn ) { + this.closeBtn.addEventListener( 'click', ( e ) => { + console.log( 'Close button clicked!' ); + + // Display quit confirmation if on welcome step (since privacy policy is accepted there) + if ( this.state.currentStep === 0 ) { + e.preventDefault(); + this.showQuitConfirmation(); + return; + } + + this.state.data.finished = + this.state.currentStep === this.tourSteps.length - 1; + this.closeTour(); + + // If on PP Dashboard page and privacy was accepted during onboarding, + // refresh the page to properly initialize dashboard components. + if ( + this.state.data.privacyAccepted && + window.location.href.includes( + 'admin.php?page=progress-planner' + ) + ) { + window.location.reload(); + } + } ); + } + } else { + console.error( 'Popover not found!' ); + } + } + + /** + * Show quit confirmation when trying to close without accepting privacy + */ + showQuitConfirmation() { + // Replace content with confirmation message + const originalContent = this.contentWrapper.innerHTML; + + // Get template from DOM + const template = document.getElementById( + 'prpl-onboarding-quit-confirmation' + ); + if ( ! template ) { + console.error( 'Quit confirmation template not found' ); + return; + } + + this.contentWrapper.innerHTML = template.innerHTML; + + // Add event listeners + const quitYes = this.contentWrapper.querySelector( '#prpl-quit-yes' ); + const quitNo = this.contentWrapper.querySelector( '#prpl-quit-no' ); + + if ( quitYes ) { + quitYes.addEventListener( 'click', ( e ) => { + e.preventDefault(); + this.closeTour(); + } ); + } + + if ( quitNo ) { + quitNo.addEventListener( 'click', ( e ) => { + e.preventDefault(); + // Restore original content + this.contentWrapper.innerHTML = originalContent; + + // Re-mount the step + this.renderStep(); + } ); + } + } + + /** + * 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; + }, + } ); + } +} + +class ProgressPlannerTourUtils { + /** + * Complete a task via AJAX + * @param {string} taskId + * @param {Object} formValues + */ + static async completeTask( taskId, formValues = {} ) { + const response = await fetch( ProgressPlannerOnboardData.adminAjaxUrl, { + method: 'POST', + body: new URLSearchParams( { + form_values: JSON.stringify( formValues ), + task_id: taskId, + nonce: ProgressPlannerOnboardData.nonceProgressPlanner, + action: 'progress_planner_onboarding_complete_task', + } ), + } ); + + if ( ! response.ok ) { + throw new Error( 'Request failed: ' + response.status ); + } + + return response.json(); + } +} diff --git a/assets/js/onboarding/steps/BadgesStep.js b/assets/js/onboarding/steps/BadgesStep.js new file mode 100644 index 0000000000..516636fadb --- /dev/null +++ b/assets/js/onboarding/steps/BadgesStep.js @@ -0,0 +1,62 @@ +/** + * Badges step - Explains the badge system to users + * Simple informational step with no user interaction required + */ +/* global OnboardingStep */ + +class PrplBadgesStep extends OnboardingStep { + constructor() { + super( { + templateId: 'onboarding-step-badges', + } ); + } + + /** + * Mount badges step and lazy-load badge graphic + * Badge is only loaded after privacy policy is accepted + * @return {Function} Cleanup function + */ + onMount() { + const gaugeElement = document.getElementById( 'prpl-gauge-onboarding' ); + + if ( ! gaugeElement ) { + return () => {}; + } + + // Create badge element using innerHTML to properly instantiate the custom element + const badgeId = gaugeElement.getAttribute( 'data-badge-id' ); + const badgeName = gaugeElement.getAttribute( 'data-badge-name' ); + const brandingId = gaugeElement.getAttribute( 'data-branding-id' ); + + gaugeElement.innerHTML = ` + + `; + + // Increment badge point(s) after badge is loaded + setTimeout( () => { + if ( gaugeElement ) { + // Check if the first task was completed. + if ( this.wizard.state.data.firstTaskCompleted ) { + gaugeElement.value += 1; + } + } + }, 1500 ); + + return () => {}; + } + + /** + * User can always proceed from badges step + * @return {boolean} Always returns true + */ + canProceed() { + return true; + } +} + +window.PrplBadgesStep = new PrplBadgesStep(); diff --git a/assets/js/onboarding/steps/EmailFrequencyStep.js b/assets/js/onboarding/steps/EmailFrequencyStep.js new file mode 100644 index 0000000000..ff9ffc56ea --- /dev/null +++ b/assets/js/onboarding/steps/EmailFrequencyStep.js @@ -0,0 +1,208 @@ +/** + * Email Frequency step - Allow users to opt in/out of weekly emails + * If opted in, collects name and email for subscription + */ +/* global OnboardingStep, ProgressPlannerOnboardData, LicenseGenerator */ + +class PrplEmailFrequencyStep extends OnboardingStep { + constructor() { + super( { + templateId: 'onboarding-step-email-frequency', + } ); + } + + /** + * Mount the email frequency step + * Sets up radio button and form field listeners + * @param {Object} state - Wizard state + * @return {Function} Cleanup function + */ + onMount( state ) { + const emailWeeklyRadio = + this.popover.querySelector( '#prpl-email-weekly' ); + const dontEmailRadio = this.popover.querySelector( '#prpl-dont-email' ); + const emailForm = this.popover.querySelector( '#prpl-email-form' ); + const nameInput = this.popover.querySelector( '#prpl-email-name' ); + const emailInput = this.popover.querySelector( '#prpl-email-address' ); + + if ( ! emailWeeklyRadio || ! dontEmailRadio || ! emailForm ) { + return () => {}; + } + + // Initialize state + if ( ! state.data.emailFrequency ) { + state.data.emailFrequency = { + choice: 'weekly', // Default to 'weekly' + name: nameInput ? nameInput.value.trim() : '', // Get pre-populated value + email: emailInput ? emailInput.value.trim() : '', // Get pre-populated value + }; + } + + // Set radio button state from wizard state + if ( state.data.emailFrequency.choice === 'weekly' ) { + emailWeeklyRadio.checked = true; + emailForm.style.display = 'block'; + } else if ( state.data.emailFrequency.choice === 'none' ) { + dontEmailRadio.checked = true; + emailForm.style.display = 'none'; + } + + // Set form values from state (or keep pre-populated values) + if ( nameInput ) { + nameInput.value = state.data.emailFrequency.name || nameInput.value; + } + if ( emailInput ) { + emailInput.value = + state.data.emailFrequency.email || emailInput.value; + } + + // Radio button change handlers + const weeklyHandler = ( e ) => { + if ( e.target.checked ) { + state.data.emailFrequency.choice = 'weekly'; + emailForm.style.display = 'block'; + + // Update button state + this.updateNextButton(); + } + }; + + const dontEmailHandler = ( e ) => { + if ( e.target.checked ) { + state.data.emailFrequency.choice = 'none'; + emailForm.style.display = 'none'; + + // Update button state + this.updateNextButton(); + } + }; + + // Form input handlers + const nameHandler = ( e ) => { + state.data.emailFrequency.name = e.target.value.trim(); + this.updateNextButton(); + }; + + const emailHandler = ( e ) => { + state.data.emailFrequency.email = e.target.value.trim(); + this.updateNextButton(); + }; + + // Add event listeners + emailWeeklyRadio.addEventListener( 'change', weeklyHandler ); + dontEmailRadio.addEventListener( 'change', dontEmailHandler ); + + if ( nameInput ) { + nameInput.addEventListener( 'input', nameHandler ); + } + if ( emailInput ) { + emailInput.addEventListener( 'input', emailHandler ); + } + + // Cleanup function + return () => { + emailWeeklyRadio.removeEventListener( 'change', weeklyHandler ); + dontEmailRadio.removeEventListener( 'change', dontEmailHandler ); + + if ( nameInput ) { + nameInput.removeEventListener( 'input', nameHandler ); + } + if ( emailInput ) { + emailInput.removeEventListener( 'input', emailHandler ); + } + }; + } + + /** + * User can proceed if: + * - "Don't email me" is selected, OR + * - "Email me weekly" is selected AND both name and email fields are filled + * @param {Object} state - Wizard state + * @return {boolean} True if can proceed + */ + canProceed( state ) { + // Initialize state if needed (defensive check) + if ( ! state.data.emailFrequency ) { + state.data.emailFrequency = { + choice: null, + name: '', + email: '', + }; + } + + const emailFrequency = state.data.emailFrequency; + + if ( ! emailFrequency.choice ) { + return false; + } + + // If user chose "don't email", they can proceed immediately + if ( emailFrequency.choice === 'none' ) { + return true; + } + + // If user chose "weekly", check that name and email are filled + if ( emailFrequency.choice === 'weekly' ) { + return !! ( emailFrequency.name && emailFrequency.email ); + } + + return false; + } + + /** + * Called before advancing to next step + * Fires AJAX request to subscribe user if "Email me weekly" was selected + * @return {Promise} Resolves when action is complete + */ + async beforeNextStep() { + const state = this.getState(); + + // Only send AJAX if user chose to receive emails + if ( state.data.emailFrequency.choice !== 'weekly' ) { + return Promise.resolve(); + } + + // Show spinner + const spinner = this.showSpinner( this.nextBtn ); + + try { + // Use LicenseGenerator to handle the license generation process + await LicenseGenerator.generateLicense( + { + name: state.data.emailFrequency.name, + email: state.data.emailFrequency.email, + site: ProgressPlannerOnboardData.site, + timezone_offset: ProgressPlannerOnboardData.timezone_offset, + 'with-email': 'yes', + }, + { + onboardNonceURL: ProgressPlannerOnboardData.onboardNonceURL, + onboardAPIUrl: ProgressPlannerOnboardData.onboardAPIUrl, + adminAjaxUrl: ProgressPlannerOnboardData.adminAjaxUrl, + nonce: ProgressPlannerOnboardData.nonceProgressPlanner, + } + ); + + console.log( 'Successfully subscribed' ); + } catch ( error ) { + console.error( 'Failed to subscribe:', error ); + + // Display error message to user + this.showErrorMessage( + error.message || 'Failed to subscribe. Please try again.', + 'Error subscribing' + ); + + // Re-enable the button so user can retry + this.setNextButtonDisabled( false ); + + // Don't proceed to next step + throw error; + } finally { + // Remove spinner + spinner.remove(); + } + } +} + +window.PrplEmailFrequencyStep = new PrplEmailFrequencyStep(); diff --git a/assets/js/onboarding/steps/FirstTaskStep.js b/assets/js/onboarding/steps/FirstTaskStep.js new file mode 100644 index 0000000000..633f109432 --- /dev/null +++ b/assets/js/onboarding/steps/FirstTaskStep.js @@ -0,0 +1,68 @@ +/** + * First Task step - User completes their first task + * Handles task completion and form submission + */ +/* global OnboardingStep, ProgressPlannerTourUtils */ +class PrplFirstTaskStep extends OnboardingStep { + constructor() { + super( { + templateId: 'onboarding-step-first-task', + } ); + } + + /** + * Mount the first task step + * Sets up event listener for task completion + * @param {Object} state - Wizard state + * @return {Function} Cleanup function + */ + onMount( state ) { + const btn = this.popover.querySelector( '.prpl-complete-task-btn' ); + if ( ! btn ) { + return () => {}; + } + + const handler = ( e ) => { + const thisBtn = e.target.closest( 'button' ); + const form = thisBtn.closest( 'form' ); + let formValues = {}; + + if ( form ) { + const formData = new FormData( form ); + formValues = Object.fromEntries( formData.entries() ); + } + + ProgressPlannerTourUtils.completeTask( + thisBtn.dataset.taskId, + formValues + ) + .then( () => { + thisBtn.classList.add( 'prpl-complete-task-btn-completed' ); + this.updateState( 'firstTaskCompleted', { + [ thisBtn.dataset.taskId ]: true, + } ); + + // Automatically 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 ); + } + + /** + * User can only proceed if they've completed the first task + * @param {Object} state - Wizard state + * @return {boolean} True if first task is completed + */ + canProceed( state ) { + return !! state.data.firstTaskCompleted; + } +} + +window.PrplFirstTaskStep = new PrplFirstTaskStep(); diff --git a/assets/js/onboarding/steps/MoreTasksStep.js b/assets/js/onboarding/steps/MoreTasksStep.js new file mode 100644 index 0000000000..998d943ade --- /dev/null +++ b/assets/js/onboarding/steps/MoreTasksStep.js @@ -0,0 +1,143 @@ +/** + * More Tasks step - User completes additional tasks + * Handles multiple tasks that can be completed in any order + * Each task may open a sub-popover with its own form + * Split into 2 substeps: intro screen and task list + */ +/* global OnboardingStep, PrplOnboardTask */ + +class PrplMoreTasksStep extends OnboardingStep { + subSteps = [ 'more-tasks-intro', 'more-tasks-tasks' ]; + + constructor() { + super( { + templateId: 'onboarding-step-more-tasks', + } ); + this.tasks = []; + this.currentSubStep = 0; + } + + /** + * Mount the more tasks step + * Initializes all tasks and sets up event listeners + * @param {Object} state - Wizard state + * @return {Function} Cleanup function + */ + onMount( state ) { + // Always start from first sub-step + this.currentSubStep = 0; + + // Hide footer initially (will show on tasks substep) + this.toggleStepFooter( false ); + + // Render the current sub-step + this.renderSubStep( state ); + + // Setup continue button listener + const continueBtn = this.popover.querySelector( + '.prpl-more-tasks-continue' + ); + if ( continueBtn ) { + continueBtn.addEventListener( 'click', () => { + this.advanceSubStep( state ); + } ); + } + + // Setup finish onboarding link in intro + const finishLink = this.popover.querySelector( + '.prpl-more-tasks-substep[data-substep="intro"] .prpl-finish-onboarding' + ); + if ( finishLink ) { + finishLink.addEventListener( 'click', ( e ) => { + e.preventDefault(); + this.wizard.finishOnboarding(); + } ); + } + + // Initialize task completion tracking + const moreTasks = this.popover.querySelectorAll( + '.prpl-task-item[data-task-id]' + ); + moreTasks.forEach( ( btn ) => { + if ( ! state.data.moreTasksCompleted ) { + state.data.moreTasksCompleted = {}; + } + state.data.moreTasksCompleted[ btn.dataset.taskId ] = false; + } ); + + // Initialize PrplOnboardTask instances for each task, passing wizard reference + this.tasks = Array.from( + this.popover.querySelectorAll( '[data-popover="task"]' ) + ).map( ( t ) => new PrplOnboardTask( t, this.wizard ) ); + + // Listen for task completion events + const handler = ( e ) => { + // Update state when a task is completed + state.data.moreTasksCompleted[ e.detail.id ] = true; + + // Update next button state + this.updateNextButton(); + }; + + this.popover.addEventListener( 'taskCompleted', handler ); + + // Return cleanup function + return () => { + this.popover.removeEventListener( 'taskCompleted', handler ); + // Clean up task instances + this.tasks = []; + // Show footer when leaving this step + this.toggleStepFooter( true ); + }; + } + + /** + * Render the current sub-step + * @param {Object} state - Wizard state + */ + renderSubStep( state ) { + const subStepName = this.subSteps[ this.currentSubStep ]; + + // Show/hide sub-step containers + this.subSteps.forEach( ( step ) => { + const container = this.popover.querySelector( + `.prpl-more-tasks-substep[data-substep="${ step }"]` + ); + if ( container ) { + container.style.display = step === subStepName ? '' : 'none'; + } + } ); + + // Show footer only on tasks substep + const isTasksSubStep = subStepName === 'more-tasks-tasks'; + this.toggleStepFooter( isTasksSubStep ); + + // Update Next button state if on tasks sub-step + if ( isTasksSubStep ) { + this.updateNextButton(); + } + } + + /** + * Advance to next sub-step + * @param {Object} state - Wizard state + */ + advanceSubStep( state ) { + if ( this.currentSubStep < this.subSteps.length - 1 ) { + this.currentSubStep++; + this.renderSubStep( state ); + } + } + + /** + * User can only proceed if on tasks substep + * @param {Object} state - Wizard state + * @return {boolean} True if can proceed + */ + canProceed( state ) { + // Can only proceed if on tasks substep + return this.currentSubStep === this.subSteps.length - 1; + } +} + +window.PrplMoreTasksStep = new PrplMoreTasksStep(); diff --git a/assets/js/onboarding/steps/OnboardingStep.js b/assets/js/onboarding/steps/OnboardingStep.js new file mode 100644 index 0000000000..14a9c8f5ee --- /dev/null +++ b/assets/js/onboarding/steps/OnboardingStep.js @@ -0,0 +1,326 @@ +/** + * Base class for onboarding steps + * All step components should extend this class + */ +class OnboardingStep { + /** + * Constructor + * @param {Object} config - Step configuration + * @param {string} config.id - Unique step identifier + * @param {string} config.templateId - ID of the template element containing the step HTML + */ + constructor( config ) { + this.templateId = config.templateId; + this.wizard = null; // Reference to parent wizard + this.popover = null; // Reference to popover element + this.cleanup = null; // Cleanup function for event listeners + this.nextBtn = null; // Reference to next button element + } + + /** + * Set wizard reference + * @param {ProgressPlannerOnboardWizard} wizard + */ + setWizard( wizard ) { + this.wizard = wizard; + this.popover = wizard.popover; + } + + /** + * Get the step's HTML content + * @return {string} HTML content + */ + render() { + const template = document.getElementById( this.templateId ); + if ( ! template ) { + console.error( `Template not found: ${ this.templateId }` ); + return ''; + } + return template.innerHTML; + } + + /** + * Called when step is mounted to DOM + * Override this method to setup event listeners and step-specific logic + * @param {Object} state - Wizard state + * @return {Function} Cleanup function to be called when step unmounts + */ + onMount( state ) { + // Override in subclass + return () => {}; + } + + /** + * Check if user can proceed to next step + * Override this method to add step-specific validation + * @param {Object} state - Wizard state + * @return {boolean} True if user can proceed + */ + canProceed( state ) { + // Override in subclass + return true; + } + + /** + * Called when step is about to be unmounted + * Override this method for cleanup logic + */ + onUnmount() { + if ( this.cleanup ) { + this.cleanup(); + this.cleanup = null; + } + } + + /** + * Utility method to update wizard state + * @param {string} key - State key to update + * @param {*} value - New value + */ + updateState( key, value ) { + if ( this.wizard ) { + this.wizard.state.data[ key ] = value; + } + } + + /** + * Utility method to get current state + * @return {Object} Current wizard state + */ + getState() { + return this.wizard ? this.wizard.state : null; + } + + /** + * Utility method to advance to next step + */ + nextStep() { + if ( this.wizard ) { + this.wizard.nextStep(); + } + } + + /** + * Get the tour footer element + * @return {HTMLElement|null} The tour footer element or null if not found + */ + getTourFooter() { + return this.wizard?.contentWrapper?.querySelector( '.tour-footer' ); + } + + /** + * Show error message to user + * @param {string} message Error message to display + * @param {string} title Optional error title + */ + showErrorMessage( message, title = '' ) { + // Remove existing error if any + this.clearErrorMessage(); + + // Build title HTML if provided + const titleHtml = title ? `

${ this.escapeHtml( title ) }

` : ''; + + // Get error icon from wizard config + const errorIcon = this.wizard?.config?.errorIcon || ''; + + // Create error message element + const errorDiv = document.createElement( 'div' ); + errorDiv.className = 'prpl-error-message'; + errorDiv.innerHTML = ` +
+ + ${ errorIcon } + +
+ ${ titleHtml } +

${ this.escapeHtml( message ) }

+
+
+ `; + + // Add error message to tour footer + const footer = this.getTourFooter(); + if ( footer ) { + footer.prepend( errorDiv ); + } + } + + /** + * Clear error message + */ + clearErrorMessage() { + const existingError = this.wizard?.popover?.querySelector( + '.prpl-error-message' + ); + if ( existingError ) { + existingError.remove(); + } + } + + /** + * Escape HTML to prevent XSS + * @param {string} text Text to escape + * @return {string} Escaped text + */ + escapeHtml( text ) { + const div = document.createElement( 'div' ); + div.textContent = text; + return div.innerHTML; + } + + /** + * Show spinner before a button and disable the button + * @param {HTMLElement} button Button element to show spinner before and disable + * @return {HTMLElement} The created spinner element + */ + showSpinner( button ) { + const spinner = document.createElement( 'span' ); + spinner.classList.add( 'prpl-spinner' ); + spinner.innerHTML = + ''; + + button.parentElement.insertBefore( spinner, button ); + button.disabled = true; + + return spinner; + } + + /** + * Toggle visibility of the footer in this step's template + * @param {boolean} visible - Whether to show the footer + */ + toggleStepFooter( visible ) { + const stepFooter = this.getTourFooter(); + if ( stepFooter ) { + stepFooter.style.display = visible ? 'flex' : 'none'; + } + } + + /** + * Called before advancing to next step + * Fires AJAX request to subscribe user if "Email me weekly" was selected + * @return {Promise} Resolves when action is complete + */ + async beforeNextStep() { + // Override in subclass + return Promise.resolve(); + } + + /** + * Setup next button after step is rendered + * Finds button, attaches click handler, and initializes state + * Called automatically by wizard after rendering + */ + setupNextButton() { + // Find the next button in the rendered step content + this.nextBtn = + this.wizard?.contentWrapper?.querySelector( '.prpl-tour-next' ); + + if ( ! this.nextBtn ) { + // Step doesn't have a next button (e.g., SettingsStep with sub-steps) + return; + } + + // Remove any existing listeners by cloning the button + const newBtn = this.nextBtn.cloneNode( true ); + if ( this.nextBtn.parentNode ) { + this.nextBtn.parentNode.replaceChild( newBtn, this.nextBtn ); + } + this.nextBtn = newBtn; + + // Add click listener + this.nextBtn.addEventListener( 'click', () => { + console.log( 'Next button clicked!' ); + this.nextStep(); + } ); + + // Initialize button state + this.updateNextButton(); + + // Call hook for subclasses to add custom button behavior + // Returns optional cleanup function + const customCleanup = this.onNextButtonSetup(); + + // If step provided a cleanup function, chain it with existing cleanup + if ( customCleanup && typeof customCleanup === 'function' ) { + const originalCleanup = this.cleanup; + this.cleanup = () => { + customCleanup(); + if ( originalCleanup ) { + originalCleanup(); + } + }; + } + } + + /** + * Called after next button is setup + * Override to add custom button behavior + * @return {Function|void} Optional cleanup function + */ + onNextButtonSetup() { + // Override in subclass + // Return a cleanup function if you need to remove event listeners + } + + /** + * Update next button state (text and enabled/disabled) + * Called when step state changes + */ + updateNextButton() { + if ( ! this.nextBtn ) { + return; + } + + const state = this.getState(); + const canProceed = this.canProceed( state ); + + // Update enabled/disabled state + this.setNextButtonDisabled( ! canProceed ); + + // Update button text + this.updateNextButtonText(); + } + + /** + * Update next button text based on step configuration and wizard state + * Currently this is only used to change button text on the last step to "Take me to the Recommendations dashboard" + */ + updateNextButtonText() { + if ( ! this.nextBtn || ! this.wizard ) { + return; + } + + const isLastStep = + this.wizard.state.currentStep === this.wizard.tourSteps.length - 1; + + // Check if step provides custom button text + if ( isLastStep ) { + // On last step, use "Take me to the Recommendations dashboard" text + const dashboardText = + this.wizard.config?.l10n?.dashboard || + 'Take me to the Recommendations dashboard'; + this.nextBtn.innerHTML = dashboardText; + } + } + + /** + * Enable or disable the next button + * Separated into its own method for easy customization + * @param {boolean} disabled - Whether to disable the button + */ + setNextButtonDisabled( disabled ) { + if ( ! this.nextBtn ) { + return; + } + + // Using prpl-btn-disabled CSS class instead of the disabled attribute + if ( disabled ) { + this.nextBtn.classList.add( 'prpl-btn-disabled' ); + this.nextBtn.setAttribute( 'aria-disabled', 'true' ); + } else { + this.nextBtn.classList.remove( 'prpl-btn-disabled' ); + this.nextBtn.setAttribute( 'aria-disabled', 'false' ); + } + } +} diff --git a/assets/js/onboarding/steps/SettingsStep.js b/assets/js/onboarding/steps/SettingsStep.js new file mode 100644 index 0000000000..8986db6e74 --- /dev/null +++ b/assets/js/onboarding/steps/SettingsStep.js @@ -0,0 +1,492 @@ +/** + * Settings step - Configure About, Contact, FAQ pages, and Post Types + * Multi-step process with 5 sub-steps + */ +/* global OnboardingStep, ProgressPlannerOnboardData */ + +class PrplSettingsStep extends OnboardingStep { + subSteps = [ 'homepage', 'about', 'contact', 'faq', 'post-types' ]; + + defaultSettings = { + homepage: { + hasPage: true, // true if checkbox is NOT checked (default: unchecked) + pageId: null, + }, + about: { + hasPage: true, // true if checkbox is NOT checked (default: unchecked) + pageId: null, + }, + contact: { + hasPage: true, + pageId: null, + }, + faq: { + hasPage: true, + pageId: null, + }, + 'post-types': { + selectedTypes: [], // Array of selected post type slugs + }, + }; + + constructor() { + super( { + templateId: 'onboarding-step-settings', + } ); + this.currentSubStep = 0; + } + + /** + * Mount the settings step + * Sets up event listeners for page select and save button + * @param {Object} state - Wizard state + * @return {Function} Cleanup function + */ + onMount( state ) { + // Initialize state + if ( ! state.data.settings ) { + state.data.settings = {}; + } + + // Initialize missing sub-steps + for ( const [ key, defaultValue ] of Object.entries( + this.defaultSettings + ) ) { + if ( ! state.data.settings[ key ] ) { + state.data.settings[ key ] = { ...defaultValue }; + } + } + + // Always start from first sub-step + this.currentSubStep = 0; + + // Hide footer in step template initially (will show on last sub-step) + this.toggleStepFooter( false ); + + // Render the current sub-step + this.renderSubStep( state ); + + // Return cleanup function + return () => { + // Show footer when leaving this step (for other steps that might need it) + this.toggleStepFooter( true ); + }; + } + + /** + * Render the current sub-step + * @param {Object} state - Wizard state + */ + renderSubStep( state ) { + const subStepName = this.subSteps[ this.currentSubStep ]; + const subStepData = state.data.settings[ subStepName ]; + + // Update progress indicator + /* + const progressIndicator = this.popover.querySelector( + '.prpl-settings-progress' + ); + if ( progressIndicator ) { + progressIndicator.textContent = `${ this.currentSubStep + 1 }/${ + this.subSteps.length + }`; + } + */ + + // Show/hide sub-step containers + this.subSteps.forEach( ( step, index ) => { + const container = this.popover.querySelector( + `.prpl-setting-item[data-page="${ step }"]` + ); + if ( container ) { + container.style.display = + index === this.currentSubStep ? 'flex' : 'none'; + } + } ); + + // Hide "Save setting" button on last sub-step (show Next/Dashboard instead) + const isLastSubStep = this.currentSubStep === this.subSteps.length - 1; + const saveButton = this.popover.querySelector( + `#prpl-save-${ subStepName }-setting` + ); + if ( saveButton ) { + saveButton.style.display = isLastSubStep ? 'none' : ''; + } + + // Setup event listeners for current sub-step + this.setupSubStepListeners( subStepName, subStepData, state ); + + // Show/hide footer based on sub-step + this.toggleStepFooter( isLastSubStep ); + + // Update Next/Dashboard button state if on last sub-step + if ( isLastSubStep ) { + this.updateNextButton(); + } + } + + /** + * Setup event listeners for a sub-step + * @param {string} subStepName - Name of sub-step (about/contact/faq/post-types) + * @param {Object} subStepData - Data for this sub-step + * @param {Object} state - Wizard state + */ + setupSubStepListeners( subStepName, subStepData, state ) { + // Handle page selection sub-steps (about, contact, faq) + if ( + [ 'homepage', 'about', 'contact', 'faq' ].includes( subStepName ) + ) { + this.setupPageSelectListeners( subStepName, subStepData, state ); + return; + } + + // Handle post types sub-step + if ( subStepName === 'post-types' ) { + this.setupPostTypesListeners( subStepName, subStepData, state ); + } + } + + /** + * Setup event listeners for page select sub-steps (about, contact, faq) + * @param {string} subStepName - Name of sub-step + * @param {Object} subStepData - Data for this sub-step + * @param {Object} state - Wizard state + */ + setupPageSelectListeners( subStepName, subStepData, state ) { + // Get select and checkbox + const pageSelect = this.popover.querySelector( + `select[name="pages[${ subStepName }][id]"]` + ); + const noPageCheckbox = this.popover.querySelector( + `#prpl-no-${ subStepName }-page` + ); + + // Get save button + const saveButton = this.popover.querySelector( + `#prpl-save-${ subStepName }-setting` + ); + + if ( ! pageSelect || ! noPageCheckbox || ! saveButton ) { + return; + } + + // Get select wrapper + const selectWrapper = this.popover.querySelector( + `.prpl-setting-item[data-page="${ subStepName }"] .prpl-select-page` + ); + + // Set initial states from saved data + if ( subStepData.pageId ) { + pageSelect.value = subStepData.pageId; + } + + if ( ! subStepData.hasPage ) { + noPageCheckbox.checked = true; + if ( selectWrapper ) { + selectWrapper.classList.add( 'prpl-disabled' ); + } + } + + // Page select handler + pageSelect.addEventListener( 'change', ( e ) => { + subStepData.pageId = e.target.value; + this.updateSaveButtonState( saveButton, subStepData ); + + // Update Next/Dashboard button if on last sub-step + if ( this.currentSubStep === this.subSteps.length - 1 ) { + this.updateNextButton(); + } + } ); + + // Checkbox handler + noPageCheckbox.addEventListener( 'change', ( e ) => { + subStepData.hasPage = ! e.target.checked; + + // Display the note if the checkbox is checked. + const note = this.popover.querySelector( + `.prpl-setting-item[data-page="${ subStepName }"] .prpl-setting-footer .prpl-setting-note` + ); + + // Hide/show select based on checkbox + if ( e.target.checked ) { + // Checkbox is checked - hide select + if ( selectWrapper ) { + selectWrapper.classList.add( 'prpl-disabled' ); + } + pageSelect.value = ''; // Reset selection + subStepData.pageId = null; + if ( note ) { + note.style.display = 'flex'; + } + } else if ( selectWrapper ) { + // Checkbox is unchecked - show select + if ( selectWrapper ) { + selectWrapper.classList.remove( 'prpl-disabled' ); + } + + if ( note ) { + note.style.display = 'none'; + } + } + + this.updateSaveButtonState( saveButton, subStepData ); + + // Update Next/Dashboard button if on last sub-step + if ( this.currentSubStep === this.subSteps.length - 1 ) { + this.updateNextButton(); + } + } ); + + // Save button handler - just advances to next sub-step + saveButton.addEventListener( 'click', () => { + this.advanceSubStep( state ); + } ); + + // Initial button state + this.updateSaveButtonState( saveButton, subStepData ); + } + + /** + * Setup event listeners for post types sub-step + * @param {string} subStepName - Name of sub-step + * @param {Object} subStepData - Data for this sub-step + * @param {Object} state - Wizard state + */ + setupPostTypesListeners( subStepName, subStepData, state ) { + const container = this.popover.querySelector( + `.prpl-setting-item[data-page="${ subStepName }"]` + ); + const saveButton = this.popover.querySelector( + `#prpl-save-${ subStepName }-setting` + ); + + if ( ! container || ! saveButton ) { + return; + } + + // Get all checkboxes + const checkboxes = container.querySelectorAll( + 'input[type="checkbox"][name="prpl-post-types-include[]"]' + ); + + // Initialize selected types from checkboxes that are already checked (from template) + // or from saved data if available + if ( + subStepData.selectedTypes && + subStepData.selectedTypes.length > 0 + ) { + // Use saved data if available + checkboxes.forEach( ( checkbox ) => { + checkbox.checked = subStepData.selectedTypes.includes( + checkbox.value + ); + } ); + } else { + // Initialize from checkboxes that are already checked in the template + subStepData.selectedTypes = Array.from( checkboxes ) + .filter( ( cb ) => cb.checked ) + .map( ( cb ) => cb.value ); + + // If no checkboxes are checked, default to all checked + if ( subStepData.selectedTypes.length === 0 ) { + checkboxes.forEach( ( checkbox ) => { + checkbox.checked = true; + subStepData.selectedTypes.push( checkbox.value ); + } ); + } + } + + // Add change listeners to checkboxes + checkboxes.forEach( ( checkbox ) => { + checkbox.addEventListener( 'change', () => { + // Update selected types array + subStepData.selectedTypes = Array.from( checkboxes ) + .filter( ( cb ) => cb.checked ) + .map( ( cb ) => cb.value ); + + this.updateSaveButtonState( saveButton, subStepData ); + + // Update Next/Dashboard button if on last sub-step + if ( this.currentSubStep === this.subSteps.length - 1 ) { + this.updateNextButton(); + } + } ); + } ); + + // Save button handler - just advances to next sub-step + saveButton.addEventListener( 'click', () => { + this.advanceSubStep( state ); + } ); + + // Initial button state + this.updateSaveButtonState( saveButton, subStepData ); + } + + /** + * Advance to next sub-step + * @param {Object} state - Wizard state + */ + advanceSubStep( state ) { + if ( this.currentSubStep < this.subSteps.length - 1 ) { + this.currentSubStep++; + this.renderSubStep( state ); + // Footer visibility is handled in renderSubStep() + } + } + + /** + * Update save button state + * @param {HTMLElement} button - Save button element + * @param {Object} subStepData - Sub-step data + */ + updateSaveButtonState( button, subStepData ) { + const canSave = this.canSaveSubStep( subStepData ); + button.disabled = ! canSave; + } + + /** + * Check if sub-step can be saved + * @param {Object} subStepData - Sub-step data + * @return {boolean} True if can save + */ + canSaveSubStep( subStepData ) { + // Handle page selection sub-steps (about, contact, faq) + if ( subStepData.hasPage !== undefined ) { + // If user has the page, they must select one + if ( subStepData.hasPage && ! subStepData.pageId ) { + return false; + } + + // If checkbox is checked (don't have page), can save + if ( ! subStepData.hasPage ) { + return true; + } + + // If page is selected, can save + return !! subStepData.pageId; + } + + // Handle post types sub-step - at least one must be selected + if ( subStepData.selectedTypes !== undefined ) { + return subStepData.selectedTypes.length > 0; + } + + return false; + } + + /** + * User can proceed if on last sub-step and it's valid + * @param {Object} state - Wizard state + * @return {boolean} True if can proceed + */ + canProceed( state ) { + if ( ! state.data.settings ) { + return false; + } + + // Can only proceed if on last sub-step + if ( this.currentSubStep !== this.subSteps.length - 1 ) { + return false; + } + + // Check if all sub-steps have valid data + return this.subSteps.every( ( step ) => { + const subStepData = state.data.settings[ step ]; + return this.canSaveSubStep( subStepData ); + } ); + } + + /** + * Called before advancing to next step + * Saves all settings via AJAX + * @return {Promise} Resolves when settings are saved + */ + async beforeNextStep() { + const state = this.getState(); + + // Show spinner on Next button + const spinner = this.showSpinner( this.nextBtn ); + + try { + // Collect all settings data for a single AJAX request + const formDataObj = new FormData(); + formDataObj.append( 'action', 'prpl_save_all_onboarding_settings' ); + formDataObj.append( + 'nonce', + ProgressPlannerOnboardData.nonceProgressPlanner + ); + + // Collect page settings (about, contact, faq) + const pages = {}; + for ( const subStepName of this.subSteps ) { + const subStepData = state.data.settings[ subStepName ]; + + if ( + [ 'homepage', 'about', 'contact', 'faq' ].includes( + subStepName + ) + ) { + pages[ subStepName ] = { + id: subStepData.pageId || '', + have_page: subStepData.hasPage ? 'yes' : 'no', + }; + } + } + + // Add pages data as JSON + if ( Object.keys( pages ).length > 0 ) { + formDataObj.append( 'pages', JSON.stringify( pages ) ); + } + + // Add post types + const postTypesData = state.data.settings[ 'post-types' ]; + if ( postTypesData && postTypesData.selectedTypes ) { + postTypesData.selectedTypes.forEach( ( postType ) => { + formDataObj.append( 'prpl-post-types-include[]', postType ); + } ); + } + + // Send single AJAX request + const response = await fetch( + ProgressPlannerOnboardData.adminAjaxUrl, + { + method: 'POST', + body: formDataObj, + credentials: 'same-origin', + } + ); + + if ( ! response.ok ) { + throw new Error( 'Request failed: ' + response.status ); + } + + const result = await response.json(); + + if ( ! result.success ) { + throw new Error( + result.data?.message || 'Failed to save settings' + ); + } + + console.log( 'Successfully saved all onboarding settings' ); + } catch ( error ) { + console.error( 'Failed to save settings:', error ); + + // Display error message + this.showErrorMessage( + error.message || 'Failed to save settings. Please try again.', + 'Error saving setting' + ); + + // Re-enable button + this.setNextButtonDisabled( false ); + + // Don't proceed to next step + throw error; + } finally { + spinner.remove(); + } + } +} + +window.PrplSettingsStep = new PrplSettingsStep(); diff --git a/assets/js/onboarding/steps/WelcomeStep.js b/assets/js/onboarding/steps/WelcomeStep.js new file mode 100644 index 0000000000..8ad4498be3 --- /dev/null +++ b/assets/js/onboarding/steps/WelcomeStep.js @@ -0,0 +1,165 @@ +/** + * Welcome step - First step in the onboarding flow + * Displays a welcome message, logo, and privacy policy checkbox + */ +/* global OnboardingStep, LicenseGenerator, ProgressPlannerOnboardData */ + +class PrplWelcomeStep extends OnboardingStep { + constructor() { + super( { + templateId: 'onboarding-step-welcome', + } ); + this.isGeneratingLicense = false; + } + + /** + * Mount the welcome step + * Sets up checkbox listener and initializes state + * @param {Object} state - Wizard state + * @return {Function} Cleanup function + */ + onMount( state ) { + const checkbox = this.popover.querySelector( '#prpl-privacy-checkbox' ); + + if ( ! checkbox ) { + return () => {}; + } + + // Initialize state from checkbox if not already set in saved state + if ( state.data.privacyAccepted === undefined ) { + state.data.privacyAccepted = checkbox.checked; + } else { + // Set checkbox state from wizard state + checkbox.checked = state.data.privacyAccepted; + } + + const handler = ( e ) => { + state.data.privacyAccepted = e.target.checked; + + // Remove active class from required indicator. + this.popover + .querySelector( + '.prpl-privacy-checkbox-wrapper .prpl-required-indicator' + ) + .classList.remove( 'prpl-required-indicator-active' ); + }; + + checkbox.addEventListener( 'change', handler ); + + return () => { + checkbox.removeEventListener( 'change', handler ); + }; + } + + /** + * Setup custom handler for disabled button clicks + * Shows error message when user tries to proceed without accepting privacy policy + * @return {Function} Cleanup function + */ + onNextButtonSetup() { + const disabledClickHandler = ( e ) => { + if ( this.nextBtn.classList.contains( 'prpl-btn-disabled' ) ) { + e.preventDefault(); + e.stopPropagation(); + + this.popover + .querySelector( + '.prpl-privacy-checkbox-wrapper .prpl-required-indicator' + ) + .classList.add( 'prpl-required-indicator-active' ); + } + }; + + this.nextBtn.addEventListener( 'click', disabledClickHandler ); + + // Return cleanup function + return () => { + this.nextBtn?.removeEventListener( 'click', disabledClickHandler ); + }; + } + + /** + * User can only proceed if privacy policy is accepted + * Sites with existing license bypass this check (no privacy checkbox shown). + * @param {Object} state - Wizard state + * @return {boolean} True if privacy is accepted or license exists + */ + canProceed( state ) { + // Sites with license already skip the privacy checkbox. + if ( ProgressPlannerOnboardData.hasLicense ) { + return true; + } + return !! state.data.privacyAccepted; + } + + /** + * Called before advancing to next step + * Generates license and shows spinner + * Branded sites with existing license skip this step. + * @return {Promise} Resolves when license is generated + */ + async beforeNextStep() { + // Skip license generation if site already has a license (branded sites). + if ( ProgressPlannerOnboardData.hasLicense ) { + return; + } + + if ( this.isGeneratingLicense ) { + return; + } + + this.isGeneratingLicense = true; + + // Clear any existing error messages + this.clearErrorMessage(); + + // Show spinner + const spinner = this.showSpinner( this.nextBtn ); + + try { + // Generate license + await this.generateLicense(); + } catch ( error ) { + console.error( 'Failed to generate license:', error ); + + // Display error message to user + this.showErrorMessage( error.message, 'Error generating license' ); + + // Re-enable the button so user can retry + this.setNextButtonDisabled( false ); + + // Don't proceed to next step + throw error; + } finally { + // Remove spinner + spinner.remove(); + this.isGeneratingLicense = false; + } + } + + /** + * Generate license on server + * Uses LicenseGenerator utility class + * @return {Promise} Resolves when license is generated + */ + async generateLicense() { + // Use LicenseGenerator to handle the license generation process + return LicenseGenerator.generateLicense( + { + name: '', + email: '', + 'with-email': 'no', + site: ProgressPlannerOnboardData.site, + timezone_offset: ProgressPlannerOnboardData.timezone_offset, + }, + { + onboardNonceURL: ProgressPlannerOnboardData.onboardNonceURL, + onboardAPIUrl: ProgressPlannerOnboardData.onboardAPIUrl, + adminAjaxUrl: ProgressPlannerOnboardData.adminAjaxUrl, + nonce: ProgressPlannerOnboardData.nonceProgressPlanner, + } + ); + } +} + +window.PrplWelcomeStep = new PrplWelcomeStep(); diff --git a/assets/js/onboarding/steps/WhatsWhatStep.js b/assets/js/onboarding/steps/WhatsWhatStep.js new file mode 100644 index 0000000000..c46a49c90a --- /dev/null +++ b/assets/js/onboarding/steps/WhatsWhatStep.js @@ -0,0 +1,34 @@ +/** + * Whats What step - Explains the badge system to users + * Simple informational step with no user interaction required + */ +/* global OnboardingStep */ +class PrplWhatsWhatStep extends OnboardingStep { + constructor() { + super( { + templateId: 'onboarding-step-whats-what', + } ); + } + + /** + * No special mounting logic needed for badges step + * @param {Object} state - Wizard state + * @return {Function} Cleanup function + */ + onMount( state ) { + // Whats Next step is informational only + // No special logic needed + return () => {}; + } + + /** + * User can always proceed from badges step + * @param {Object} state - Wizard state + * @return {boolean} Always returns true + */ + canProceed( state ) { + return true; + } +} + +window.PrplWhatsWhatStep = new PrplWhatsWhatStep(); diff --git a/assets/js/settings.js b/assets/js/settings.js index 6695710039..32d7670391 100644 --- a/assets/js/settings.js +++ b/assets/js/settings.js @@ -1,10 +1,10 @@ -/* global progressPlanner, progressPlannerAjaxRequest, progressPlannerSaveLicenseKey, prplL10n */ +/* global prplL10n, LicenseGenerator */ /* * Settings * * A script to handle the settings page. * - * Dependencies: progress-planner/ajax-request, progress-planner/onboard, wp-util, progress-planner/l10n + * Dependencies: progress-planner/l10n, progress-planner/license-generator */ // Submit the email. @@ -22,47 +22,23 @@ if ( !! settingsLicenseForm ) { data[ key ] = value; } - progressPlannerAjaxRequest( { - url: progressPlanner.onboardNonceURL, - data, - } ) - .then( ( response ) => { - if ( 'ok' === response.status ) { - // Add the nonce to our data object. - data.nonce = response.nonce; - - // Make the request to the API. - progressPlannerAjaxRequest( { - url: progressPlanner.onboardAPIUrl, - data, - } ) - .then( ( apiResponse ) => { - // Make a local request to save the response data. - progressPlannerSaveLicenseKey( - apiResponse.license_key - ); + document.getElementById( 'submit-license-key' ).disabled = true; + document.getElementById( 'submit-license-key' ).innerHTML = + prplL10n( 'subscribing' ); - document.getElementById( - 'submit-license-key' - ).innerHTML = prplL10n( 'subscribed' ); + LicenseGenerator.generateLicense( data ) + .then( () => { + document.getElementById( 'submit-license-key' ).innerHTML = + prplL10n( 'subscribed' ); - // Timeout so the license key is saved. - setTimeout( () => { - // Reload the page. - window.location.reload(); - }, 500 ); - } ) - .catch( ( error ) => { - console.warn( error ); - } ); - } + // Timeout so the license key is saved. + setTimeout( () => { + // Reload the page. + window.location.reload(); + }, 500 ); } ) .catch( ( error ) => { console.warn( error ); } ); - - document.getElementById( 'submit-license-key' ).disabled = true; - document.getElementById( 'submit-license-key' ).innerHTML = - prplL10n( 'subscribing' ); } ); } diff --git a/classes/admin/class-page-settings.php b/classes/admin/class-page-settings.php index e0976691cb..ba5e396897 100644 --- a/classes/admin/class-page-settings.php +++ b/classes/admin/class-page-settings.php @@ -138,13 +138,12 @@ public function set_page_values( $pages ) { } /** - * Save the redirect on login setting. + * Save the settings. * * @param bool $redirect_on_login Whether to redirect on login. - * * @return void */ - public function save_redirect_on_login( $redirect_on_login = false ) { + public function save_settings( $redirect_on_login ) { \update_user_meta( \get_current_user_id(), 'prpl_redirect_on_login', $redirect_on_login ); } diff --git a/classes/admin/class-page.php b/classes/admin/class-page.php index 21c58a8647..57793818f0 100644 --- a/classes/admin/class-page.php +++ b/classes/admin/class-page.php @@ -296,15 +296,6 @@ public function enqueue_styles() { // Enqueue ugprading (onboarding) tasks styles, these are needed both when privacy policy is accepted and when it is not. \progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/upgrade-tasks' ); } - - $prpl_privacy_policy_accepted = \progress_planner()->is_privacy_policy_accepted(); - if ( ! $prpl_privacy_policy_accepted ) { - // Enqueue welcome styles. - \progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/welcome' ); - - // Enqueue onboarding styles. - \progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/onboard' ); - } } /** diff --git a/classes/class-base.php b/classes/class-base.php index 7fa27f1ff3..7978a9367d 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\Onboard_Wizard get_onboard_wizard() */ class Base { @@ -180,6 +181,9 @@ public function init() { // Init the enqueue class. $this->get_admin__enqueue()->init(); + + // TODO: Decide when this needs to be initialized. + $this->get_onboard_wizard(); } /** diff --git a/classes/class-onboard-wizard.php b/classes/class-onboard-wizard.php new file mode 100644 index 0000000000..f484b487e5 --- /dev/null +++ b/classes/class-onboard-wizard.php @@ -0,0 +1,604 @@ +get_ui__branding()->get_branding_id(); + $show_onboarding = ! \progress_planner()->is_privacy_policy_accepted() + || \get_option( self::PROGRESS_OPTION_NAME, false ) + || $is_branded; + + /** + * Filter whether to show the onboarding wizard. + * + * Hosting integrations can use this filter to force showing + * or hiding the onboarding wizard. + * + * @param bool $show_onboarding Whether to show the onboarding wizard. + */ + $show_onboarding = \apply_filters( 'progress_planner_show_onboarding', $show_onboarding ); + + if ( ! $show_onboarding ) { + return; + } + + // Add popover on front end. + \add_action( 'wp_footer', [ $this, 'add_popover' ] ); + \add_action( 'wp_footer', [ $this, 'add_popover_step_templates' ] ); + \add_action( 'wp_enqueue_scripts', [ $this, 'add_popover_scripts' ] ); + + // Add popover on admin. + \add_action( 'admin_footer', [ $this, 'add_popover' ] ); + \add_action( 'admin_footer', [ $this, 'add_popover_step_templates' ] ); + \add_action( 'admin_enqueue_scripts', [ $this, 'add_popover_scripts' ] ); + + // Trigger the onboarding wizard on the front end. + \add_action( 'wp_footer', [ $this, 'trigger_onboarding' ] ); + \add_action( 'admin_footer', [ $this, 'trigger_onboarding' ] ); + + // Define steps and their order. + \add_action( 'init', [ $this, 'define_steps_and_order' ], 101 ); + + // Allow only images for the front-end upload. + \add_filter( 'rest_pre_insert_attachment', [ $this, 'rest_pre_insert_attachment' ], 10, 2 ); + } + + /** + * Define steps and their order. + * + * @return void + */ + public function define_steps_and_order() { + $saved_progress = $this->get_saved_progress(); + + // We need to know if the first task is already completed, in case user resumes the onboarding. + $was_first_task_completed = isset( $saved_progress['data'] ) && ! empty( $saved_progress['data']['firstTaskCompleted'] ); + + // Get the onboarding tasks. + $onboarding_tasks = [ + 'core-blogdescription', + 'select-timezone', + 'select-locale', + 'core-siteicon', + ]; + + $tasks = []; + + foreach ( $onboarding_tasks as $task_id ) { + $task = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'task_id' => $task_id ] ); + $task_provider = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_provider( $task_id ); + + // If there is no task, create it. + if ( ! $task && $task_provider ) { + $task_data = $task_provider->get_task_details(); + + // Task will not be inserted if it already exists. + \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 ] ); + } + + // Safety check: Skip if task could not be created or retrieved. + if ( empty( $task ) ) { + \error_log( 'Onboarding: Could not retrieve or create task: ' . $task_id ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + continue; + } + + $task_formatted = [ + 'task_id' => $task[0]->get_task_id(), + 'title' => $task[0]->post_title ?? '', + 'url' => $task[0]->url ?? '', + 'provider_id' => $task[0]->get_provider_id(), + 'points' => $task[0]->points ?? 0, + 'action_label' => $task_provider ? $task_provider->get_task_action_label() : \esc_html__( 'Do it', 'progress-planner' ), + ]; + + // Add task specific data. + if ( 'core-blogdescription' === $task_id ) { + $task_formatted['site_description'] = \get_bloginfo( 'description' ); + } + + $tasks[ $task_id ] = $task_formatted; + } + + $this->steps = [ + [ + 'script_file_name' => 'WelcomeStep', + 'template_file_name' => 'welcome', + 'template_id' => 'onboarding-step-welcome', + /* translators: %s: Progress Planner name. */ + 'title' => sprintf( esc_html__( 'Welcome to %s', 'progress-planner' ), \esc_html( \progress_planner()->get_ui__branding()->get_admin_menu_name() ) ), + ], + [ + 'script_file_name' => 'WhatsWhatStep', + 'template_file_name' => 'whats-what', + 'template_id' => 'onboarding-step-whats-what', + 'title' => esc_html__( 'What\'s what?', 'progress-planner' ), + ], + ]; + + // Add first task step if there are tasks or if the first task is already completed. + if ( ! empty( $tasks ) || $was_first_task_completed ) { + $this->steps[] = [ + 'script_file_name' => 'FirstTaskStep', + 'template_file_name' => 'first-task', + 'template_data' => ! $was_first_task_completed ? [ 'task' => \array_shift( $tasks ) ] : [], + 'template_id' => 'onboarding-step-first-task', + 'title' => esc_html__( 'Complete your first task!', 'progress-planner' ), + ]; + } + + $this->steps[] = [ + 'script_file_name' => 'BadgesStep', + 'template_file_name' => 'badges', + 'template_id' => 'onboarding-step-badges', + 'title' => esc_html__( 'Our badges are waiting for you', 'progress-planner' ), + ]; + + $this->steps[] = [ + 'script_file_name' => 'EmailFrequencyStep', + 'template_file_name' => 'email-frequency', + 'template_id' => 'onboarding-step-email-frequency', + 'title' => esc_html__( 'Email Frequency', 'progress-planner' ), + ]; + + $this->steps[] = [ + 'script_file_name' => 'SettingsStep', + 'template_file_name' => 'settings', + 'template_id' => 'onboarding-step-settings', + 'title' => esc_html__( 'Settings', 'progress-planner' ), + ]; + + // Add more-tasks step if there are remaining tasks. + if ( ! empty( $tasks ) ) { + $this->steps[] = [ + 'script_file_name' => 'MoreTasksStep', + 'template_file_name' => 'more-tasks', + 'template_data' => [ 'tasks' => $tasks ], + 'template_id' => 'onboarding-step-more-tasks', + 'title' => esc_html__( 'Finish onboarding!', 'progress-planner' ), + ]; + } + } + + /** + * Allow only images for the front-end upload. + * + * @param array $attachment The attachment. + * @param \WP_REST_Request $request The request. + * @return array|\WP_Error The attachment or WP_Error. + */ + public function rest_pre_insert_attachment( $attachment, $request ) { + + // Only run for our file upload. + if ( isset( $request['prplFileUpload'] ) && $request['prplFileUpload'] ) { + + $files = $request->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; + } + + /** + * Add popover scripts. + * + * @return void + */ + public function add_popover_scripts() { + // Enqueue variables-color.css. + \wp_enqueue_style( 'prpl-variables-color', \constant( 'PROGRESS_PLANNER_URL' ) . '/assets/css/variables-color.css', [], \progress_planner()->get_plugin_version() ); + + \wp_add_inline_style( 'prpl-variables-color', \progress_planner()->get_ui__branding()->get_custom_css() ); + + // Enqueue onboarding.css. + progress_planner()->get_admin__enqueue()->enqueue_style( 'onboarding/onboarding' ); + + // Enqueue PrplOnboardTask (used by MoreTasksStep). + \progress_planner()->get_admin__enqueue()->enqueue_script( 'onboarding/OnboardTask' ); + + // Enqueue base step class. + \progress_planner()->get_admin__enqueue()->enqueue_script( 'onboarding/steps/OnboardingStep' ); + + // Enqueue step components. + foreach ( $this->steps as $step ) { + \progress_planner()->get_admin__enqueue()->enqueue_script( 'onboarding/steps/' . $step['script_file_name'] ); + } + + \progress_planner()->get_admin__enqueue()->enqueue_script( 'web-components/prpl-gauge' ); + + // Get saved progress from user meta. + $saved_progress = $this->get_saved_progress(); + + // Enqueue main onboarding.js (depends on all step components). + \progress_planner()->get_admin__enqueue()->enqueue_script( + 'onboarding/onboarding', + [ + 'name' => 'ProgressPlannerOnboardData', + 'data' => [ + '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' ) ), + 'popoverId' => 'prpl-popover-onboarding', + 'onboardAPIUrl' => \progress_planner()->get_utils__onboard()->get_remote_url( 'onboard' ), + 'onboardNonceURL' => \progress_planner()->get_utils__onboard()->get_remote_url( 'get-nonce' ), + 'site' => \esc_attr( \set_url_scheme( \site_url() ) ), + 'timezone_offset' => (float) ( \wp_timezone()->getOffset( new \DateTime( 'midnight' ) ) / 3600 ), + 'savedProgress' => $saved_progress, + 'lastStepRedirectUrl' => \esc_url_raw( admin_url( 'admin.php?page=progress-planner' ) ), + 'fullscreenMode' => true, // Enable fullscreen takeover mode. + 'hasLicense' => false !== \progress_planner()->get_license_key(), + 'l10n' => [ + 'next' => \esc_html__( 'Next', 'progress-planner' ), + 'startOnboarding' => \esc_html__( 'Start onboarding', 'progress-planner' ), + /* translators: %s: Progress Planner name. */ + 'privacyPolicyError' => sprintf( \esc_html__( 'You need to agree with the privacy policy to use the %s plugin.', 'progress-planner' ), \esc_html( \progress_planner()->get_ui__branding()->get_admin_menu_name() ) ), + /* translators: %s: arrow icon */ + 'dashboard' => sprintf( \esc_html__( 'Take me to the dashboard %s', 'progress-planner' ), '' ), + 'backToRecommendations' => \esc_html__( 'Back to recommendations', 'progress-planner' ), + ], + 'errorIcon' => \progress_planner()->get_asset( 'images/icon_exclamation_circle.svg' ), + 'steps' => array_column( $this->steps, 'script_file_name' ), + ], + ] + ); + } + + /** + * Get saved progress from user meta. + * + * @return array|null + */ + protected function get_saved_progress() { + if ( ! \get_current_user_id() ) { + return null; + } + + $onboarding_progress = \get_option( self::PROGRESS_OPTION_NAME, true ); + if ( ! $onboarding_progress ) { + return null; + } + + $decoded = \json_decode( $onboarding_progress, true ); + if ( ! $decoded || ! \is_array( $decoded ) ) { + return null; + } + + return $decoded; + } + + /** + * Verify AJAX security (capability and nonce). + * + * @return void + */ + protected function verify_ajax_security() { + if ( ! \current_user_can( 'manage_options' ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to perform this action.', 'progress-planner' ) ] ); + } + + if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + } + + /** + * Save the tour progress. + * + * @return void + */ + public function ajax_save_onboarding_progress() { + $this->verify_ajax_security(); + + if ( ! isset( $_POST['state'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax_security(). + \wp_send_json_error( [ 'message' => \esc_html__( 'State is required.', 'progress-planner' ) ] ); + } + $progress = \sanitize_text_field( \wp_unslash( $_POST['state'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax_security(). + + \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_option( self::PROGRESS_OPTION_NAME, $progress ); + + \wp_send_json_success( [ 'message' => \esc_html__( 'Tour progress saved.', 'progress-planner' ) ] ); + } + + /** + * Complete a task. + * + * @return void + */ + public function ajax_complete_task() { + $this->verify_ajax_security(); + + if ( ! isset( $_POST['task_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax_security(). + \wp_send_json_error( [ 'message' => \esc_html__( 'Task ID is required.', 'progress-planner' ) ] ); + } + + $task_id = \sanitize_text_field( \wp_unslash( $_POST['task_id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax_security(). + + // Aditional data for the task, besides the task ID. + $form_values = []; + if ( isset( $_POST['form_values'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax_security(). + $form_values = \sanitize_text_field( \wp_unslash( $_POST['form_values'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax_security(). + $form_values = \json_decode( $form_values, true ); + } + + // Safety check: Ensure form_values is an array after decoding. + if ( ! \is_array( $form_values ) ) { + $form_values = []; + } + + // 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' ) ] ); + } + + // Complete the task. + $task_completed = $provider->complete_task( $form_values, $task_id ); + + // It will skip the celebration and set the task's post status to trash. + $task_post_marked_as_completed = \progress_planner()->get_suggested_tasks()->mark_task_as_completed( $task_id, null, true ); + + 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' ) ] ); + } + + /** + * Handle saving all onboarding settings in a single request. + * + * @return void + */ + public function ajax_save_all_onboarding_settings() { + $this->verify_ajax_security(); + + $page_settings = \progress_planner()->get_admin__page_settings(); + + // Handle page settings (about, contact, faq). + if ( isset( $_POST['pages'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + $pages_json = \sanitize_text_field( \wp_unslash( $_POST['pages'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + $pages = \json_decode( $pages_json, true ); + + if ( \is_array( $pages ) ) { + // Convert to the format expected by set_page_values. + $pages_formatted = []; + foreach ( $pages as $page_type => $page_data ) { + if ( isset( $page_data['id'] ) && isset( $page_data['have_page'] ) ) { + $pages_formatted[ $page_type ] = [ + 'id' => (int) $page_data['id'], + 'have_page' => $page_data['have_page'], + ]; + } + } + + if ( ! empty( $pages_formatted ) ) { + $page_settings->set_page_values( $pages_formatted ); + } + } + } + + // Handle post types. + $include_post_types = isset( $_POST['prpl-post-types-include'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing + ? \array_map( 'sanitize_text_field', \wp_unslash( $_POST['prpl-post-types-include'] ) ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing + : []; + $page_settings->save_post_types( $include_post_types ); + + // Handle login destination. + $redirect_on_login = isset( $_POST['prpl-redirect-on-login'] ) ? (bool) \sanitize_text_field( \wp_unslash( $_POST['prpl-redirect-on-login'] ) ) : false; // phpcs:ignore WordPress.Security.NonceVerification.Missing + $page_settings->save_settings( $redirect_on_login ); + + \wp_send_json_success( [ 'message' => \esc_html__( 'All settings saved successfully.', 'progress-planner' ) ] ); + } + + /** + * Trigger the onboarding wizard on the front end. + * + * @return void + */ + public function trigger_onboarding() { + + // If the request is an AJAX request, do not trigger the onboarding wizard. + if ( \wp_doing_ajax() ) { + return; + } + + // Dont trigger it if user is not logged in and is not a admin. + if ( ! \is_user_logged_in() || ! \current_user_can( 'manage_options' ) ) { + return; + } + + $get_saved_progress = $this->get_saved_progress(); + + // If there is no saved progress, trigger the onboarding wizard. + if ( ! $get_saved_progress ) { + ?> + + +
+
+ +
+
+
+ steps[0]['title'] ); ?> +
+
    + steps as $step ) : + ?> +
  1. + + [] ] ); ?> +
  2. + +
+
+ +
+ + +
+
+ + +
+
+
+ + +
+ steps as $step ) { + \progress_planner()->the_view( 'onboarding/' . $step['template_file_name'] . '.php', isset( $step['template_data'] ) ? $step['template_data'] : [] ); + } + + // Add quit confirmation template. + \progress_planner()->the_view( 'onboarding/quit-confirmation.php' ); + ?> + + mark_task_as_completed( $task_id, $user_id ); + } + + /** + * Complete a task. + * + * @param string $task_id The task ID. + * @param int|null $user_id Optional. The user ID for token deletion. If provided, the token will be deleted. + * @param bool $skip_celebration Optional. Whether to skip the celebration. + * + * @return bool + */ + public function mark_task_as_completed( $task_id, $user_id = null, $skip_celebration = false ) { if ( ! $this->was_task_completed( $task_id ) ) { $task = \progress_planner()->get_suggested_tasks_db()->get_post( $task_id ); if ( $task ) { - \progress_planner()->get_suggested_tasks_db()->update_recommendation( $task->ID, [ 'post_status' => 'pending' ] ); + $post_status = $skip_celebration ? 'trash' : 'pending'; + \progress_planner()->get_suggested_tasks_db()->update_recommendation( $task->ID, [ 'post_status' => $post_status ] ); // Insert an activity. $this->insert_activity( $task_id ); - // Delete the token after successful use (one-time use). - $this->delete_task_completion_token( $task_id, $user_id ); + // Delete the token after successful use (one-time use) if user_id is provided. + if ( $user_id ) { + $this->delete_task_completion_token( $task_id, $user_id ); + } + + return true; } } + + return false; } /** diff --git a/classes/suggested-tasks/class-tasks-interface.php b/classes/suggested-tasks/class-tasks-interface.php index 18b3f8f3aa..bf9a9ca598 100644 --- a/classes/suggested-tasks/class-tasks-interface.php +++ b/classes/suggested-tasks/class-tasks-interface.php @@ -114,6 +114,13 @@ public function get_popover_id(); */ public function add_task_actions( $data = [], $actions = [] ); + /** + * Get the task action label. + * + * @return string + */ + public function get_task_action_label(); + /** * Check if the task has activity. * @@ -122,4 +129,14 @@ public function add_task_actions( $data = [], $actions = [] ); * @return bool */ public function task_has_activity( $task_id = '' ); + + /** + * 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 = '' ); } diff --git a/classes/suggested-tasks/providers/class-blog-description.php b/classes/suggested-tasks/providers/class-blog-description.php index 61ec8bb519..add79362a3 100644 --- a/classes/suggested-tasks/providers/class-blog-description.php +++ b/classes/suggested-tasks/providers/class-blog-description.php @@ -142,9 +142,42 @@ public function print_popover_form_contents() { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Set tagline', 'progress-planner' ) . '', + 'html' => '' . \esc_html( $this->get_task_action_label() ) . '', ]; return $actions; } + + /** + * Get the task action label. + * + * @return string + */ + public function get_task_action_label() { + return \__( 'Set tagline', 'progress-planner' ); + } + + /** + * 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['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 313d4aa5cf..b15251b393 100644 --- a/classes/suggested-tasks/providers/class-select-locale.php +++ b/classes/suggested-tasks/providers/class-select-locale.php @@ -269,27 +269,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' ) ) { - // @phpstan-ignore-next-line requireOnce.fileNotFound - require_once ABSPATH . 'wp-admin/includes/translation-install.php'; - - 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' ) ] ); @@ -309,9 +295,68 @@ public function handle_interactive_task_specific_submit() { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Select locale', 'progress-planner' ) . '', + 'html' => '' . \esc_html( $this->get_task_action_label() ) . '', ]; return $actions; } + + /** + * Get the task action label. + * + * @return string + */ + public function get_task_action_label() { + return \__( 'Select locale', 'progress-planner' ); + } + + /** + * 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' ) ) { + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-admin/includes/translation-install.php'; + + 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 3eb54c8bf1..14807d3bfe 100644 --- a/classes/suggested-tasks/providers/class-select-timezone.php +++ b/classes/suggested-tasks/providers/class-select-timezone.php @@ -192,6 +192,75 @@ public function handle_interactive_task_specific_submit() { \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid timezone.', 'progress-planner' ) ] ); } + $option_updated = $this->update_timezone( $timezone_string ); + + 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' ) ] ); + } + + \wp_send_json_error( [ 'message' => \esc_html__( 'Failed to update setting.', '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( $this->get_task_action_label() ) . '', + ]; + + return $actions; + } + + /** + * Get the task action label. + * + * @return string + */ + public function get_task_action_label() { + return \__( 'Select timezone', 'progress-planner' ); + } + + /** + * 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. @@ -214,27 +283,9 @@ public function handle_interactive_task_specific_submit() { \update_option( 'timezone_string', $timezone_string ); \update_option( 'gmt_offset', $gmt_offset ); - // 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' ) ] ); + return true; } - \wp_send_json_error( [ 'message' => \esc_html__( 'Failed to update setting.', '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__( 'Select timezone', 'progress-planner' ) . '', - ]; - - return $actions; + return false; } } diff --git a/classes/suggested-tasks/providers/class-site-icon.php b/classes/suggested-tasks/providers/class-site-icon.php index 7bf34c85f4..7dfd56f88d 100644 --- a/classes/suggested-tasks/providers/class-site-icon.php +++ b/classes/suggested-tasks/providers/class-site-icon.php @@ -154,9 +154,42 @@ protected function get_enqueue_data() { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Set site icon', 'progress-planner' ) . '', + 'html' => '' . \esc_html( $this->get_task_action_label() ) . '', ]; return $actions; } + + /** + * Get the task action label. + * + * @return string + */ + public function get_task_action_label() { + return \__( 'Set site icon', 'progress-planner' ); + } + + /** + * 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 854bfebe99..d074ed83a9 100644 --- a/classes/suggested-tasks/providers/class-tasks.php +++ b/classes/suggested-tasks/providers/class-tasks.php @@ -268,7 +268,7 @@ public function get_external_link_url() { * Get the task ID. * * Generates a unique task ID by combining the provider ID with optional task-specific data. - * For repetitive tasks, includes the current year-week (oW format) to create weekly instances. + * For repetitive tasks, includes the current year-week (YW format) to create weekly instances. * * Example task IDs: * - Non-repetitive: "update-core" @@ -782,6 +782,15 @@ public function add_task_actions( $data = [], $actions = [] ) { return $actions; } + /** + * Get the task action label. + * + * @return string + */ + public function get_task_action_label() { + return \__( 'Do it', 'progress-planner' ); + } + /** * Check if the task has activity. * @@ -803,4 +812,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/utils/class-debug-tools.php b/classes/utils/class-debug-tools.php index d6f1d5e302..6b42a5ca69 100644 --- a/classes/utils/class-debug-tools.php +++ b/classes/utils/class-debug-tools.php @@ -48,6 +48,7 @@ public function __construct() { \add_action( 'init', [ $this, 'check_toggle_migrations' ] ); \add_action( 'init', [ $this, 'check_delete_single_task' ] ); \add_action( 'init', [ $this, 'check_toggle_recommendations_ui' ] ); + \add_action( 'init', [ $this, 'check_delete_onboarding_progress' ] ); if ( \defined( '\IS_PLAYGROUND_PREVIEW' ) && \constant( '\IS_PLAYGROUND_PREVIEW' ) === true ) { \add_action( 'init', [ $this, 'check_toggle_placeholder_demo' ] ); } @@ -99,6 +100,8 @@ public function add_toolbar_items( $admin_bar ) { $this->add_toggle_recommendations_ui_submenu_item( $admin_bar ); $this->add_placeholder_demo_submenu_item( $admin_bar ); + + $this->add_onboarding_submenu_item( $admin_bar ); } /** @@ -727,4 +730,74 @@ public function filter_tasks_show_ui( $show_ui ) { } return $show_ui; } + + /** + * Add Onboarding submenu to the debug menu. + * + * @param \WP_Admin_Bar $admin_bar The WordPress admin bar object. + * @return void + */ + protected function add_onboarding_submenu_item( $admin_bar ) { + $admin_bar->add_node( + [ + 'id' => 'prpl-onboarding', + 'parent' => 'prpl-debug', + 'title' => 'Onboarding', + ] + ); + + // Start onboarding. + $admin_bar->add_node( + [ + 'id' => 'prpl-start-onboarding', + 'parent' => 'prpl-onboarding', + 'title' => 'Start Onboarding', + 'href' => '#', + 'meta' => [ + 'onclick' => 'window.prplOnboardWizard.startOnboarding(); return false;', + ], + ] + ); + + // Delete onboarding progress. + $admin_bar->add_node( + [ + 'id' => 'prpl-delete-onboarding-progress', + 'parent' => 'prpl-onboarding', + 'title' => 'Delete Onboarding Progress', + 'href' => \add_query_arg( 'prpl_delete_onboarding_progress', '1', $this->current_url ), + ] + ); + } + + /** + * Check and process the delete onboarding progress action. + * + * Deletes onboarding progress if the appropriate query parameter is set + * and user has required capabilities. + * + * @return void + */ + public function check_delete_onboarding_progress() { + if ( + ! isset( $_GET['prpl_delete_onboarding_progress'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $_GET['prpl_delete_onboarding_progress'] !== '1' || // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ! \current_user_can( 'manage_options' ) + ) { + return; + } + + // Verify nonce for security. + $this->verify_nonce(); + + // Delete the onboarding progress. + \Progress_Planner\Onboard_Wizard::delete_progress(); + + // Delete the license key. + \delete_option( 'progress_planner_license_key' ); + + // Redirect to the same page without the parameter. + \wp_safe_redirect( \remove_query_arg( [ 'prpl_delete_onboarding_progress', '_wpnonce' ] ) ); + exit; + } } diff --git a/classes/utils/class-playground.php b/classes/utils/class-playground.php index 988043db81..130a004315 100644 --- a/classes/utils/class-playground.php +++ b/classes/utils/class-playground.php @@ -31,7 +31,6 @@ public function register_hooks() { if ( ! \progress_planner()->get_license_key() && ! \get_option( 'progress_planner_demo_data_generated', false ) ) { $this->generate_data(); \update_option( 'progress_planner_license_key', \str_replace( ' ', '-', $this->create_random_string( 20 ) ) ); - \update_option( 'progress_planner_force_show_onboarding', false ); \update_option( 'progress_planner_todo', [ @@ -48,7 +47,6 @@ public function register_hooks() { \update_option( 'progress_planner_demo_data_generated', true ); } \add_action( 'progress_planner_admin_page_header_before', [ $this, 'show_header_notice' ] ); - \add_action( 'wp_ajax_progress_planner_hide_onboarding', [ $this, 'hide_onboarding' ] ); \add_action( 'wp_ajax_progress_planner_show_onboarding', [ $this, 'show_onboarding' ] ); \progress_planner()->get_settings()->set( 'activation_date', ( new \DateTime() )->modify( '-2 months' )->format( 'Y-m-d' ) ); @@ -80,48 +78,23 @@ public function enable_debug_tools() { } /** - * Toggle the onboarding visibility in the Playground environment. - * - * @param string $action Either 'show' or 'hide'. + * Show the onboarding in the Playground environment. * * @return void */ - private function toggle_onboarding( $action ) { - $nonce_action = "progress_planner_{$action}_onboarding"; - \check_ajax_referer( $nonce_action, 'nonce' ); + public function show_onboarding() { + \check_ajax_referer( 'progress_planner_show_onboarding', 'nonce' ); if ( ! \current_user_can( 'manage_options' ) ) { \wp_die( \esc_html__( 'You do not have sufficient permissions to access this page.', 'progress-planner' ) ); } - if ( $action === 'hide' ) { - \add_option( 'progress_planner_license_key', \str_replace( ' ', '-', $this->create_random_string( 20 ) ) ); - $message = \esc_html__( 'Onboarding hidden successfully', 'progress-planner' ); - } else { - \delete_option( 'progress_planner_license_key' ); - $message = \esc_html__( 'Onboarding shown successfully', 'progress-planner' ); - } - \update_option( 'progress_planner_force_show_onboarding', $action !== 'hide' ); - - \wp_send_json_success( [ 'message' => $message ] ); - } - - /** - * Hide the onboarding in the Playground environment. - * - * @return void - */ - public function hide_onboarding() { - $this->toggle_onboarding( 'hide' ); - } + // Delete onboarding progress to trigger fresh onboarding. + \Progress_Planner\Onboard_Wizard::delete_progress(); + // Delete the license key to trigger onboarding (privacy not accepted). + \delete_option( 'progress_planner_license_key' ); - /** - * Show the onboarding in the Playground environment. - * - * @return void - */ - public function show_onboarding() { - $this->toggle_onboarding( 'show' ); + \wp_send_json_success( [ 'message' => \esc_html__( 'Onboarding shown successfully', 'progress-planner' ) ] ); } /** @@ -135,10 +108,7 @@ public function show_header_notice() { return; } - $show_onboarding = \get_option( 'progress_planner_force_show_onboarding', false ); - $button_text = $show_onboarding ? \__( 'Hide onboarding', 'progress-planner' ) : \__( 'Show onboarding', 'progress-planner' ); - $action = $show_onboarding ? 'hide' : 'show'; - $nonce = \wp_create_nonce( "progress_planner_{$action}_onboarding" ); + $nonce = \wp_create_nonce( 'progress_planner_show_onboarding' ); ?>
@@ -150,16 +120,16 @@ public function show_header_notice() {

-

diff --git a/views/onboarding/email-frequency.php b/views/onboarding/email-frequency.php new file mode 100644 index 0000000000..e0696a043e --- /dev/null +++ b/views/onboarding/email-frequency.php @@ -0,0 +1,107 @@ +display_name ?? ''; +$prpl_user_email = $prpl_current_user->user_email ?? ''; +?> + + + diff --git a/views/onboarding/first-task.php b/views/onboarding/first-task.php new file mode 100644 index 0000000000..310043ffb1 --- /dev/null +++ b/views/onboarding/first-task.php @@ -0,0 +1,47 @@ + + + + diff --git a/views/onboarding/form-inputs/checkbox.php b/views/onboarding/form-inputs/checkbox.php new file mode 100644 index 0000000000..f8abf8b017 --- /dev/null +++ b/views/onboarding/form-inputs/checkbox.php @@ -0,0 +1,40 @@ + + +
+
+ + + + + +
+
diff --git a/views/onboarding/form-inputs/radio.php b/views/onboarding/form-inputs/radio.php new file mode 100644 index 0000000000..deb6a46e4b --- /dev/null +++ b/views/onboarding/form-inputs/radio.php @@ -0,0 +1,40 @@ + + +
+
+ + + + + +
+
diff --git a/views/onboarding/more-tasks.php b/views/onboarding/more-tasks.php new file mode 100644 index 0000000000..5e0493a523 --- /dev/null +++ b/views/onboarding/more-tasks.php @@ -0,0 +1,85 @@ + + + + diff --git a/views/onboarding/quit-confirmation.php b/views/onboarding/quit-confirmation.php new file mode 100644 index 0000000000..4d38064c5d --- /dev/null +++ b/views/onboarding/quit-confirmation.php @@ -0,0 +1,46 @@ + + + + + diff --git a/views/onboarding/settings.php b/views/onboarding/settings.php new file mode 100644 index 0000000000..015e619c86 --- /dev/null +++ b/views/onboarding/settings.php @@ -0,0 +1,216 @@ + [ + 'id' => 'homepage', + 'title' => __( 'Home page', 'progress-planner' ), + 'description' => \esc_html__( 'Help us understand your site a little better so we can give you more useful recommendations. Let\'s start with the home page.', 'progress-planner' ), + 'note' => __( 'A Home page is important. We\'ll remind you to make one at a later time.', 'progress-planner' ), + ], + 'about' => [ + 'id' => 'about', + 'title' => __( 'About page', 'progress-planner' ), + 'description' => \esc_html__( 'Next up, pick the page you use as your about page.', 'progress-planner' ), + 'note' => __( 'An About page is important. We\'ll remind you to make one at a later time.', 'progress-planner' ), + ], + 'contact' => [ + 'id' => 'contact', + 'title' => __( 'Contact page', 'progress-planner' ), + 'description' => \esc_html__( 'Now choose the page you use as your contact page.', 'progress-planner' ), + 'note' => __( 'A Contact page is important. We\'ll remind you to make one at a later time.', 'progress-planner' ), + ], + 'faq' => [ + 'id' => 'faq', + 'title' => __( 'FAQ page', 'progress-planner' ), + 'description' => \esc_html__( 'Next, pick the page you use as your FAQ page.', 'progress-planner' ), + 'note' => __( 'An FAQ page is important. We\'ll remind you to make one at a later time.', 'progress-planner' ), + ], +]; + +// Get post types for the post types sub-step. +$prpl_saved_settings = \progress_planner()->get_settings()->get_post_types_names(); +$prpl_post_types = \progress_planner()->get_settings()->get_public_post_types(); + +$prpl_total_number_of_steps = 5; +$prpl_current_step_number = 0; + +?> + + + diff --git a/views/onboarding/tasks/core-blogdescription.php b/views/onboarding/tasks/core-blogdescription.php new file mode 100644 index 0000000000..1cd7ba0e0e --- /dev/null +++ b/views/onboarding/tasks/core-blogdescription.php @@ -0,0 +1,38 @@ + + +
+
+

+ +

+

+ +

+
+
+ + +
+
diff --git a/views/onboarding/tasks/core-siteicon.php b/views/onboarding/tasks/core-siteicon.php new file mode 100644 index 0000000000..44baee3e39 --- /dev/null +++ b/views/onboarding/tasks/core-siteicon.php @@ -0,0 +1,55 @@ + +
+

+ +

+

+ +

+
+ +
+ + the_file( 'assets/images/onboarding/icon_image.svg' ); ?> + +

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

+ + PNG, ICO, WEBP +
+

+ + +
+
+ +
+ +
+
diff --git a/views/onboarding/tasks/select-locale.php b/views/onboarding/tasks/select-locale.php new file mode 100644 index 0000000000..a248d3c9d5 --- /dev/null +++ b/views/onboarding/tasks/select-locale.php @@ -0,0 +1,63 @@ + + +
+

+ +

+

+ +

+
+ '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/onboarding/tasks/select-timezone.php b/views/onboarding/tasks/select-timezone.php new file mode 100644 index 0000000000..142b434934 --- /dev/null +++ b/views/onboarding/tasks/select-timezone.php @@ -0,0 +1,37 @@ + + +
+

+ +

+

+ +

+
+ + +
+
diff --git a/views/onboarding/welcome.php b/views/onboarding/welcome.php new file mode 100644 index 0000000000..3e2a819835 --- /dev/null +++ b/views/onboarding/welcome.php @@ -0,0 +1,83 @@ + + + + diff --git a/views/onboarding/whats-what.php b/views/onboarding/whats-what.php new file mode 100644 index 0000000000..43cebc7f55 --- /dev/null +++ b/views/onboarding/whats-what.php @@ -0,0 +1,63 @@ + + + + diff --git a/views/welcome.php b/views/welcome.php deleted file mode 100644 index 5a55a3b742..0000000000 --- a/views/welcome.php +++ /dev/null @@ -1,157 +0,0 @@ -get_license_key() ) { - return; -} - -// Enqueue styles. -\progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/welcome' ); -\progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/onboard' ); -?> -
-
-

- - - the_asset( 'images/icon_progress_planner.svg' ); ?> - -
-
-
-
-
- -
    -
  • - tag, %2$s: tag */ ?> - ', '' ); ?> -
  • -
  • - tag, %2$s: tag */ ?> - ', '' ); ?> -
  • -
  • - tag, %2$s: tag */ ?> - ', '' ); ?> -
  • -
- get_ui__branding()->get_url( 'https://prpl.fyi/home' ) ) . '" target="_blank">progressplanner.com' - ) - ?> -
-
- -
- - -
-
-
- - - - -
-
-
- -
-
-
-
- - - -
- -
-
-
-
- -
-
-